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

Open
bes-r wants to merge 207 commits from bes-r/kleurplaten-v0-4-65 into main AGit
22 changed files with 611 additions and 58 deletions
Showing only changes of commit 0841962824 - Show all commits

View file

@ -1 +1 @@
0.4.64-beta
0.4.65-beta

View file

@ -0,0 +1,7 @@
-- Kleurplaten gebruiken dezelfde klas-standaard-met-leerling-override als de
-- andere vakgebieden. Bestaande niveaukeuzes blijven ongewijzigd; alleen de
-- toegestane waarde 'creatief' wordt aan de constraint toegevoegd.
ALTER TABLE pupil_levels DROP CONSTRAINT IF EXISTS pupil_levels_subject_check;
ALTER TABLE pupil_levels
ADD CONSTRAINT pupil_levels_subject_check
CHECK (subject IN ('taal','rekenen','world','creatief'));

46
public/css/coloring.css Normal file
View file

@ -0,0 +1,46 @@
/* Thematische kleurplaten */
.coloring-app{height:100%;min-height:0;display:flex;flex-direction:column;background:var(--surface);color:var(--ink)}
.coloring-choices,.coloring-tools{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 10px;border-bottom:1px solid var(--line);background:color-mix(in srgb,var(--orange) 7%,var(--surface))}
.coloring-choices label{display:grid;grid-template-columns:auto minmax(120px,1fr);align-items:center;gap:6px;color:var(--muted);font-size:11px;font-weight:850}
.coloring-choices select{height:34px;max-width:230px;border:1px solid var(--line);border-radius:9px;padding:0 28px 0 9px;background:var(--surface);color:var(--ink);font:inherit;font-weight:750}
.coloring-fit{margin-left:auto;padding:5px 9px;border-radius:999px;background:var(--surface);color:var(--muted);font-size:11px;font-weight:800;white-space:nowrap}
.coloring-tools{gap:6px;background:var(--surface)}
.coloring-tool-buttons,.coloring-palette,.coloring-sizes{display:flex;align-items:center;gap:4px;padding:3px;border-radius:11px;background:var(--surface-2)}
.coloring-tools button{width:36px;height:36px;display:grid;place-items:center;border:0;border-radius:9px;background:transparent;color:var(--ink);font:inherit;font-size:18px;cursor:pointer}
.coloring-tools button:is(:hover,:focus-visible),.coloring-tools button.on{background:var(--surface);box-shadow:0 1px 5px rgba(30,50,90,.18)}
.coloring-tool-buttons button.on{outline:2px solid var(--orange);outline-offset:-2px}
.coloring-color i{width:22px;height:22px;display:block;border:1px solid rgba(38,52,74,.22);border-radius:50%;box-shadow:inset 0 0 0 1px rgba(255,255,255,.18)}
.coloring-color.on{outline:2.5px solid var(--ink);outline-offset:-3px}
.coloring-size i{display:block;border-radius:50%;background:var(--ink)}
.coloring-size.on{outline:2px solid var(--orange);outline-offset:-2px}
.coloring-custom{display:flex;align-items:center;gap:5px;color:var(--muted);font-size:10px;font-weight:800}
.coloring-custom input{width:36px;height:36px;padding:3px;border:1px solid var(--line);border-radius:9px;background:var(--surface);cursor:pointer}
.coloring-tools .coloring-reset{margin-left:auto;background:var(--red-soft);color:var(--red)}
.coloring-tools .coloring-done{width:auto;min-width:112px;padding:0 12px;color:#fff;font-size:13px}
.coloring-stage{flex:1;min-height:0;overflow:auto;display:grid;place-items:center;padding:12px;background:linear-gradient(135deg,var(--surface-2),color-mix(in srgb,var(--purple) 5%,var(--surface)))}
.coloring-paper{position:relative;width:min(100%,900px);aspect-ratio:4/3;flex:none;overflow:hidden;border:1px solid var(--line);border-radius:16px;background:#fff;box-shadow:0 10px 28px rgba(30,50,90,.14);isolation:isolate}
.coloring-art,.coloring-paper canvas{position:absolute;inset:0;width:100%;height:100%}
.coloring-art svg{width:100%;height:100%;display:block;background:#fff}
.coloring-art [hidden]{display:none}
.coloring-paper canvas{z-index:2;touch-action:none;cursor:crosshair;mix-blend-mode:multiply}
.coloring-app[data-tool="bucket"] .coloring-art{cursor:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30'%3E%3Ctext y='24' font-size='23'%3E%F0%9F%AA%A3%3C/text%3E%3C/svg%3E") 6 24,pointer}
.coloring-app[data-tool="bucket"] .coloring-art [data-fillable]{pointer-events:all}
.coloring-hint{min-height:30px;padding:7px 11px;color:var(--muted);font-size:11px;font-weight:700;text-align:center;background:var(--surface)}
.widget[data-widget="coloring"]{--widget-accent:var(--orange)}
@media(max-width:620px){
.coloring-choices,.coloring-tools{padding:6px;gap:4px}
.coloring-choices label{grid-template-columns:1fr;gap:2px;flex:1;min-width:125px}
.coloring-choices select{width:100%;max-width:none;height:32px}
.coloring-fit{width:100%;margin:0;text-align:center}
.coloring-tools button{width:32px;height:32px;font-size:16px}
.coloring-color i{width:18px;height:18px}
.coloring-custom span{display:none}.coloring-custom input{width:32px;height:32px}
.coloring-tools .coloring-reset{margin-left:0}
.coloring-tools .coloring-done{min-width:90px;font-size:11px}
.coloring-stage{place-items:start center;padding:6px}
.coloring-paper{min-width:360px;border-radius:10px}
.coloring-hint{font-size:10px}
}
@media(pointer:coarse){.coloring-tools button,.coloring-custom input{min-width:40px;min-height:40px}.coloring-choices select{min-height:40px}}
@media(prefers-reduced-motion:reduce){.coloring-app *{scroll-behavior:auto!important}}

View file

@ -7,6 +7,7 @@
<link rel="icon" type="image/svg+xml" href="img/logo.svg?v=__V__">
<link rel="preload" href="fonts/quicksand.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="css/teach.css?v=__V__">
<link rel="stylesheet" href="css/coloring.css?v=__V__">
<link rel="stylesheet" href="css/live-classroom.css?v=__V__">
<link rel="stylesheet" href="css/image-catalog.css?v=__V__">
<!-- alle app-scripts: defer = parallel downloaden, uitvoeren in dézelfde
@ -49,6 +50,7 @@
<script src="js/widgets/sentence.js?v=__V__" defer></script>
<script src="js/widgets/learning.js?v=__V__" defer></script>
<script src="js/widgets/world.js?v=__V__" defer></script>
<script src="js/widgets/coloring.js?v=__V__" defer></script>
<script src="js/board.js?v=__V__" defer></script>
<script src="js/pupil.js?v=__V__" defer></script>
<script src="js/pupil-live-actions.js?v=__V__" defer></script>

View file

@ -24,7 +24,7 @@
/* Klassenmanagement: welke klas/vakgebied nu getoond wordt, blijft behouden
over her-renders heen zoals adminTab hierboven. */
let kmClass = null, kmSubject = "rekenen";
const KM_SUBJECTS = ["taal", "rekenen", "world"];
const KM_SUBJECTS = ["taal", "rekenen", "world", "creatief"];
let stopDashboardLive=()=>{};
let liveDashboardState="reconnecting";

View file

@ -50,6 +50,7 @@ const REGISTRY = [
{ id:"birds", cat:"world", icon:"🐦", w:620, h:600, minW:360, minH:400, mount:mountBirds },
{ id:"plants", cat:"world", icon:"🌼", w:620, h:600, minW:360, minH:400, mount:mountPlants },
{ id:"topography", cat:"world", icon:"🗺️", w:680, h:640, minW:380, minH:440, mount:mountTopography },
{ id:"coloring", cat:"creatief", icon:"🎨", w:760, h:700, minW:390, minH:430, mount:mountColoring },
{ 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 },
@ -68,7 +69,7 @@ const REGISTRY = [
{ id:"avatar", cat:"class", icon:"🦖", w:620, h:560, minW:400, minH:400, mount:mountAvatarWidget },
{ id:"media", cat:"media", icon:"🎬", w:560, h:420, minW:320, minH:240, mount:mountMedia }
];
const WIDGET_CATS = [["favorites","galleryFavorites"],["taal","catTaal"],["rekenen","catRekenen"],["world","catWorld"],["class","catClass"],["tools","catTools"],["media","catMedia"]];
const WIDGET_CATS = [["favorites","galleryFavorites"],["taal","catTaal"],["rekenen","catRekenen"],["world","catWorld"],["creatief","catCreative"],["class","catClass"],["tools","catTools"],["media","catMedia"]];
/* houdt een (opgeslagen of standaard) widgetgrootte binnen het bord. Op een
telefoon is de beschikbare ruimte leidend: een desktop-minimum van bv.
@ -369,7 +370,7 @@ function makeWidget(def, saved){
? boardLearningMountState(def, state, boardLearning) : state;
mountedApi = def.mount(body, mountedState) || {};
if(typeof appendLearningProfileBadge === "function" && mountedState &&
["taal","rekenen"].includes(def.cat)){
["taal","rekenen","creatief"].includes(def.cat)){
appendLearningProfileBadge(body, def.cat, mountedState, def.id);
}
if(boardLearning && typeof appendBoardLearningLevelControls === "function"){

View file

@ -37,7 +37,7 @@ let castNotice = "";
let castActiveKeys = new Set();
let castError = "";
const CAST_WIDGET_CATEGORIES=new Set(["taal","rekenen"]);
const CAST_WIDGET_CATEGORIES=new Set(["taal","rekenen","creatief"]);
const CAST_THEME_WIDGETS=new Set(["letters","flits","hak","ball"]);
function assignmentLibraryWidgets(){
return REGISTRY.filter(widget=>CAST_WIDGET_CATEGORIES.has(widget.cat));

View file

@ -2,7 +2,7 @@
"use strict";
/* version shown until /api/version resolves (or if the fetch fails, e.g. offline).
Kept in sync by hand with the VERSION file at the repo root on every release. */
const VERSION = "0.4.64-beta";
const VERSION = "0.4.65-beta";
/* Safari en oudere mobiele browsers behandelen 100vh/100% soms als de hoogte
achter hun adres- en navigatiebalk. visualViewport geeft de werkelijk
zichtbare hoogte; de CSS gebruikt deze pixelwaarde als betrouwbare bron en
@ -49,6 +49,12 @@ const I18N = {
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.",
wg_coloring:"Kleurplaten", wg_coloring_d:"Kies thematische kleurplaten op leerjaar en niveau; kleur met een echt werkend kleurpotlood of vul een vak met de verfemmer.",
colorTheme:"Thema", colorPage:"Kleurplaat", colorProfile:"Groep {year} · niveau {level}",
colorThemeAll:"Alle thema's", colorThemeAnimals:"Dieren", colorThemeSeasons:"Seizoenen", colorThemeNature:"Natuur", colorThemeScience:"Wetenschap & techniek", colorThemeCulture:"Cultuur & omgeving",
colorToolPencil:"Kleurpotlood", colorToolBucket:"Verfemmer",
colorPencilHint:"Teken meerdere keren over dezelfde plek om de kleur donkerder te maken, net als met een echt kleurpotlood.", colorBucketHint:"Tik op een omsloten vak om het in één keer te kleuren.", colorEraserHint:"Veeg alleen de potloodlaag weg; ingekleurde vakken blijven staan.",
colorReset:"Kleurplaat leegmaken", colorDone:"Ik ben klaar", colorFinished:"Kleurplaat klaar!",
bgBlank:"Leeg blad", bgLines:"Lijntjes", bgWrite:"Schrijflijnen",
bgGridS:"Kleine rekenblokjes", bgGridB:"Grote rekenblokjes",
penSize:"Dikte", penColor:"Kleur", penExtras:"Hulpmiddelen", recentColors:"Recent gebruikt", paletteColors:"Kleuren", customColor:"Eigen kleur", eraser:"Gum", clearAll:"Alles wissen",
@ -73,7 +79,7 @@ const I18N = {
anchorLibEmptyGrid:"Zet eerst woorden in het diagram.",
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", catWorld:"Wereldoriëntatie", catClass:"Feest & klas", catTools:"Hulpmiddelen", catMedia:"Media",
catTaal:"Taal", catRekenen:"Rekenen", catWorld:"Wereldoriëntatie", catCreative:"Creatief", catClass:"Feest & klas", 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.",
@ -263,8 +269,8 @@ const I18N = {
wikiOfficialSource:"Open officiële SLO-publicatie",
wikiOfficialArea:"Bekijk {area} in de officiële publicatie",
wikiTuleTitle:"TULE en de nieuwe leerlijnen",
wikiYearProfiles:"Leerjaarprofielen taal en rekenen",
wikiYearProfilesText:"Praktische Teach-uitwerking voor widgetopdrachten per groep. Gebaseerd op de kerndoelen 2026 en TULE; geen officiële SLO-jaarnormen.",
wikiYearProfiles:"Leerjaarprofielen taal, rekenen en creatief",
wikiYearProfilesText:"Praktische Teach-uitwerking voor widgetopdrachten per groep. De creatieve profielen volgen de opbouw in detail en techniek; het zijn geen officiële jaarnormen.",
wikiYear:"Groep {n}", wikiProfileGoals:"Leerdoelen",
wikiTuleText:"TULE werkt de kerndoelen uit 2006 voorbeeldmatig uit. Nieuwe SLO-leerlijnen bij de doelen van 2026 zijn nog in ontwikkeling. Een niveau-indeling in Teach is daarom geen officiële SLO-leerlijn.",
wikiTuleSource:"Open TULE bij SLO", wikiProgressionsSource:"Volg de ontwikkeling van nieuwe leerlijnen",
@ -310,9 +316,9 @@ const I18N = {
vwPreview:"Voorbeeldweergave", vwPreviewReset:"Terug naar systeem",
vwTeacherPath:"🎯 Opdrachtenreeks", vwTeacherDashboard:"📈 Klasdashboard",
vwParentPreview:"Voorbeeld van het ouderportaal. Echte kindgegevens en acties zijn in deze weergave uitgeschakeld.",
vwPupilPreview:"Voorbeeld van de leerlingomgeving met de taal- en rekenwidgets van het huidige bord. Bediening en voortgang zijn uitgeschakeld.",
vwPupilPreview:"Voorbeeld van de leerlingomgeving met de taal-, reken- en creatieve widgets van het huidige bord. Bediening en voortgang zijn uitgeschakeld.",
vwPupilAddWidget:"Widget toevoegen", vwPupilChooseWidget:"Kies een leerlingwidget",
vwPupilAddWidgetNote:"Alleen taal- en rekenwidgets die in de leerlingomgeving werken worden getoond.",
vwPupilAddWidgetNote:"Alleen taal-, reken- en creatieve widgets die in de leerlingomgeving werken worden getoond.",
vwNote:"Deze voorkeuren gelden voor dit apparaat.",
pwChanged:"Wachtwoord gewijzigd ✓", pwCurrentPh:"huidig wachtwoord",
namePh:"naam", passPh:"wachtwoord",
@ -342,7 +348,7 @@ const I18N = {
parentUnlink:"Ontkoppel",
parentUnlinkWarn:"Verwijdert alleen de koppeling; het account van je kind blijft bestaan.",
parentProgress:"📈 Voortgang", parentProgressEmpty:"Nog geen voortgang.",
parentLevel:"🎯 Leerniveau", parentLevelTaal:"Taal", parentLevelRekenen:"Rekenen",
parentLevel:"🎯 Leerniveau", parentLevelTaal:"Taal", parentLevelRekenen:"Rekenen", parentLevelCreative:"Creatief",
parentLevelYear:"Groep", parentLevelPath:"Uitwerking", parentLevelSave:"Opslaan",
parentLevelSaved:"Leerniveau opgeslagen.",
dtLen:"Lengte", dtContrast:"Hoog contrast",
@ -409,7 +415,7 @@ const I18N = {
castClasses:"Klassen", castPupils:"Leerlingen",
castNoClasses:"Geen klassen in deze school.", castNoPupils:"Geen leerlingen in deze school.",
castActive:"✓ Actief", castPathTitle:"Opdrachtenreeks (optioneel)", castPathPlaceholder:"Naam van de reeks…",
castPathAdd:" Geselecteerde widget als stap", castPathOnlyLearning:"Reeksen ondersteunen taal- en rekenwidgets.", castPathDefault:"Mijn opdrachtenreeks", castPathHint:"{count} van maximaal 12 stappen. Gebruik de pijlen om de volgorde te bepalen.",
castPathAdd:" Geselecteerde widget als stap", castPathOnlyLearning:"Reeksen ondersteunen taal-, reken- en creatieve widgets.", castPathDefault:"Mijn opdrachtenreeks", castPathHint:"{count} van maximaal 12 stappen. Gebruik de pijlen om de volgorde te bepalen.",
castIntro:"Stel zelfstandig een opdracht of reeks samen uit alle leerlingwidgets en geef deze daarna in één keer door.",
castLibrarySubtitle:"Zelfstandige leerlingopdrachten · geen bord nodig",
castStepChoose:"Kies vorm", castStepBuild:"Stel samen", castStepAudience:"Geef door",
@ -451,7 +457,7 @@ const I18N = {
amLevelClassDefault:"Klas-standaard", amLevelPupils:"Leerlingen",
amLevelInherited:"erft van klas", amLevelNone:"nog niet ingesteld",
amLevelReset:"terug naar klas-standaard", amLevelSaved:"Opgeslagen ✓",
amSubject_taal:"Taal", amSubject_rekenen:"Rekenen", amSubject_world:"Wereldoriëntatie",
amSubject_taal:"Taal", amSubject_rekenen:"Rekenen", amSubject_world:"Wereldoriëntatie", amSubject_creatief:"Creatief",
amConfirmDel:"Weet je zeker dat je deze gebruiker wilt verwijderen?",
amCopy:"kopieer", amCopyAll:"kopieer alles",
amResetClass:"reset wachtwoorden klas", amResetSchool:"reset alle leerlingwachtwoorden",
@ -473,6 +479,12 @@ const I18N = {
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.",
wg_coloring:"Colouring pages", wg_coloring_d:"Choose themed colouring pages by year and level; use a realistic coloured pencil or fill an area with the paint bucket.",
colorTheme:"Theme", colorPage:"Colouring page", colorProfile:"Year {year} · level {level}",
colorThemeAll:"All themes", colorThemeAnimals:"Animals", colorThemeSeasons:"Seasons", colorThemeNature:"Nature", colorThemeScience:"Science & technology", colorThemeCulture:"Culture & surroundings",
colorToolPencil:"Coloured pencil", colorToolBucket:"Paint bucket",
colorPencilHint:"Draw over the same spot to make the colour darker, just like a real coloured pencil.", colorBucketHint:"Tap an enclosed area to colour it in one go.", colorEraserHint:"Erase only the pencil layer; filled areas stay in place.",
colorReset:"Clear colouring page", colorDone:"I'm finished", colorFinished:"Colouring page finished!",
bgBlank:"Blank sheet", bgLines:"Lines", bgWrite:"Handwriting lines",
bgGridS:"Small maths squares", bgGridB:"Big maths squares",
penSize:"Thickness", penColor:"Colour", penExtras:"Tools", recentColors:"Recently used", paletteColors:"Colours", customColor:"Custom colour", eraser:"Eraser", clearAll:"Clear all",
@ -497,7 +509,7 @@ const I18N = {
anchorLibEmptyGrid:"Put words in the diagram first.",
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", catWorld:"World studies", catClass:"Celebration & class", catTools:"Tools", catMedia:"Media",
catTaal:"Language", catRekenen:"Maths", catWorld:"World studies", catCreative:"Creative", catClass:"Celebration & class", 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.",
@ -687,8 +699,8 @@ const I18N = {
wikiOfficialSource:"Open the official SLO publication",
wikiOfficialArea:"View {area} in the official publication",
wikiTuleTitle:"TULE and the new learning progressions",
wikiYearProfiles:"Language and maths year profiles",
wikiYearProfilesText:"Practical Teach interpretation for widget tasks per year. Based on the 2026 targets and TULE; not official SLO year standards.",
wikiYearProfiles:"Language, maths and creative year profiles",
wikiYearProfilesText:"Practical Teach interpretation for widget tasks per year. Creative profiles progress through detail and technique; these are not official year standards.",
wikiYear:"Year {n}", wikiProfileGoals:"Learning goals",
wikiTuleText:"TULE is an illustrative elaboration of the 2006 targets. New SLO learning progressions for the 2026 targets are still being developed. Teach levels are therefore not official SLO progressions.",
wikiTuleSource:"Open TULE at SLO", wikiProgressionsSource:"Follow the development of new progressions",
@ -734,9 +746,9 @@ const I18N = {
vwPreview:"Preview mode", vwPreviewReset:"Return to system",
vwTeacherPath:"🎯 Learning path", vwTeacherDashboard:"📈 Class dashboard",
vwParentPreview:"Preview of the parent portal. Real child data and actions are disabled in this view.",
vwPupilPreview:"Preview of the pupil environment using language and maths widgets from the current board. Controls and progress are disabled.",
vwPupilPreview:"Preview of the pupil environment using language, maths and creative widgets from the current board. Controls and progress are disabled.",
vwPupilAddWidget:"Add widget", vwPupilChooseWidget:"Choose a pupil widget",
vwPupilAddWidgetNote:"Only language and maths widgets supported in the pupil environment are shown.",
vwPupilAddWidgetNote:"Only language, maths and creative widgets supported in the pupil environment are shown.",
vwNote:"These preferences apply to this device.",
pwChanged:"Password changed ✓", pwCurrentPh:"current password",
namePh:"name", passPh:"password",
@ -766,7 +778,7 @@ const I18N = {
parentUnlink:"Unlink",
parentUnlinkWarn:"Removes only the link; your child's account remains.",
parentProgress:"📈 Progress", parentProgressEmpty:"No progress yet.",
parentLevel:"🎯 Learning level", parentLevelTaal:"Language", parentLevelRekenen:"Maths",
parentLevel:"🎯 Learning level", parentLevelTaal:"Language", parentLevelRekenen:"Maths", parentLevelCreative:"Creative",
parentLevelYear:"Year", parentLevelPath:"Path", parentLevelSave:"Save",
parentLevelSaved:"Learning level saved.",
dtLen:"Length", dtContrast:"High contrast",
@ -833,7 +845,7 @@ const I18N = {
castClasses:"Classes", castPupils:"Pupils",
castNoClasses:"No classes in this school.", castNoPupils:"No pupils in this school.",
castActive:"✓ Active", castPathTitle:"Learning path (optional)", castPathPlaceholder:"Name of the path…",
castPathAdd:" Add selected widget as step", castPathOnlyLearning:"Paths support language and maths widgets.", castPathDefault:"My learning path", castPathHint:"{count} of up to 12 steps. Use the arrows to set the order.",
castPathAdd:" Add selected widget as step", castPathOnlyLearning:"Paths support language, maths and creative widgets.", castPathDefault:"My learning path", castPathHint:"{count} of up to 12 steps. Use the arrows to set the order.",
castIntro:"Build an independent assignment or path from every pupil widget, then share it in one go.",
castLibrarySubtitle:"Independent pupil assignments · no board required",
castStepChoose:"Choose format", castStepBuild:"Build it", castStepAudience:"Assign it",
@ -875,7 +887,7 @@ const I18N = {
amLevelClassDefault:"Class default", amLevelPupils:"Pupils",
amLevelInherited:"inherited from class", amLevelNone:"not set yet",
amLevelReset:"reset to class default", amLevelSaved:"Saved ✓",
amSubject_taal:"Language", amSubject_rekenen:"Maths", amSubject_world:"World orientation",
amSubject_taal:"Language", amSubject_rekenen:"Maths", amSubject_world:"World orientation", amSubject_creatief:"Creative",
amConfirmDel:"Are you sure you want to delete this user?",
amCopy:"copy", amCopyAll:"copy all",
amResetClass:"reset class passwords", amResetSchool:"reset all pupil passwords",

View file

@ -91,8 +91,8 @@ const CURRICULUM_PO_2026=Object.freeze({
id:"kunst-en-cultuur",icon:"🎨",title:"Kunst en cultuur",goalsRange:"3537",page:92,
description:"Artistiek creatief vermogen ontwikkelen, kunstzinnige uitingen maken en kunst en cultuur beleven en duiden.",
goals:[
{id:35,domain:"Artistiek creatief vermogen",summary:"Leerlingen gebruiken creatieve strategieën in een iteratief proces en geven betekenis aan eigen en andermans werk.",parts:["Creatieve maak- en denkstrategieën","Ontwikkelen van creatieve vermogens","Betekenis geven"],coverage:"support",widgets:["write","mind","media","birthday","avatar"]},
{id:36,domain:"Maken en betekenis geven",summary:"Leerlingen verbeelden ideeën en gebruiken technieken, vaardigheden, materialen en vaktaal uit verschillende kunstdisciplines.",parts:["Verbeelden en creëren","Kunstzinnige technieken en vaardigheden"],coverage:"support",widgets:["write","media","birthday","avatar"]},
{id:35,domain:"Artistiek creatief vermogen",summary:"Leerlingen gebruiken creatieve strategieën in een iteratief proces en geven betekenis aan eigen en andermans werk.",parts:["Creatieve maak- en denkstrategieën","Ontwikkelen van creatieve vermogens","Betekenis geven"],coverage:"support",widgets:["write","mind","media","birthday","avatar","coloring"]},
{id:36,domain:"Maken en betekenis geven",summary:"Leerlingen verbeelden ideeën en gebruiken technieken, vaardigheden, materialen en vaktaal uit verschillende kunstdisciplines.",parts:["Verbeelden en creëren","Kunstzinnige technieken en vaardigheden"],coverage:"support",widgets:["write","media","birthday","avatar","coloring"]},
{id:37,domain:"Meemaken en betekenis geven",summary:"Leerlingen beleven kunst en cultuur actief en verbinden uitingen aan maker, tijd, plaats, functie en context.",parts:["Kunst en cultuur actief meemaken","Kunst en cultuur in context"],coverage:"open",widgets:[]}
]
},

View file

@ -1,4 +1,4 @@
/* teach - gedeelde leerjaarprofielen voor taal en rekenen
/* teach - gedeelde leerjaarprofielen voor taal, rekenen en creatief
Praktische Teach-uitwerking van de nieuwe kerndoelen po (SLO, juli 2026)
en TULE. SLO ontwikkelt de nieuwe leerlijnen nog en kent ze geen wettelijke
status toe; deze profielen zijn daarom geen officiele SLO-jaarnormen. */
@ -31,6 +31,17 @@ const LEARNING_YEAR_PROFILES=Object.freeze({
lp("5-6","Grote getallen, kommagetallen en procenten",["standaardprocedures gebruiken","kommagetallen en breuken verbinden","eenvoudige procenten en verhoudingen berekenen"]),
lp("7-8","Verhoudingen, procenten en meten",["breuken, kommagetallen en procenten verbinden","bewerkingen doelmatig kiezen","meetkundige problemen oplossen"]),
lp("7-8","Redeneren en rekenen in samenhang",["meer-stapsproblemen modelleren","schatten en uitkomsten beoordelen","rekenwijzen helder uitleggen"])
]),
creatief:Object.freeze([
null,
lp("1-2","Grote vormen en vrij kleur ontdekken",["grote vlakken doelgericht kleuren","potloodgreep en druk verkennen","herkenbare thema's uit de leefwereld kiezen"]),
lp("1-2","Kleur en eenvoudige patronen",["binnen duidelijke contouren werken","kleuren combineren","eenvoudige herhaling herkennen"]),
lp("3-4","Vormen combineren en details zien",["middelgrote vlakken vullen","details met een potlood toevoegen","kleurkeuzes bij een thema uitleggen"]),
lp("3-4","Detail, herhaling en contrast",["kleinere vakken nauwkeurig kleuren","patronen voortzetten","licht-donkercontrast toepassen"]),
lp("5-6","Ruimte, natuur en verbeelding",["voor- en achtergrond onderscheiden","thematische kleuren onderzoeken","eigen details aan lijnwerk toevoegen"]),
lp("5-6","Kleurharmonie en complexe vormen",["kleurfamilies bewust combineren","complexere contouren beheersen","techniek en sfeer verbinden"]),
lp("7-8","Complexe patronen en betekenis",["veel detail planmatig uitwerken","kleurcontrast doelgericht inzetten","beeldkeuzes aan een thema koppelen"]),
lp("7-8","Zelfstandige stijl en verfijning",["een eigen kleurpalet ontwikkelen","gelaagd en nauwkeurig werken","artistieke keuzes toelichten"])
])
});
const LANGUAGE_WIDGET_TASKS=Object.freeze({
@ -53,6 +64,9 @@ const MATH_WIDGET_TASKS=Object.freeze({
placevalue:["","hoeveelheid en cijfer","tientallen en eenheden","getallen tot 100","getallen tot 1.000","getallen tot 10.000","getallen tot 100.000","getallen tot 1.000.000","grote getalstructuur"],
money:["","munten herkennen","hele eurobedragen","eenvoudige bedragen","euros en centen","bedragen en teruggeven","precies rekenen met centen","korting en wisselgeld","budget en procentuele verandering"]
});
const CREATIVE_WIDGET_TASKS=Object.freeze({
coloring:["","grote kleurvlakken","duidelijke vormen en patronen","middelgrote vakken en details","meer detail en contrast","thematische composities","complexe contouren","fijne patronen en kleurplan","zelfstandige kleurstijl"]
});
function learningYearGroup(value){const year=Number(value);return Number.isInteger(year)&&year>=1&&year<=8?year:null}
function learningLevel(value){return Math.max(1,Math.min(3,Number(value)||1))}
@ -63,7 +77,8 @@ function learningProfile(subject,yearGroup,level){
function learningLevelLabel(level){return (LEARNING_LEVEL_LABELS[LANG]||LEARNING_LEVEL_LABELS.nl)[learningLevel(level)]}
function learningProfileSummary(subject,yearGroup,level,widgetId){
const p=learningProfile(subject,yearGroup,level);if(!p)return"";
const tasks=subject==="taal"?LANGUAGE_WIDGET_TASKS:MATH_WIDGET_TASKS,task=tasks[widgetId]&&tasks[widgetId][p.yearGroup];
const tasks=subject==="taal"?LANGUAGE_WIDGET_TASKS:subject==="rekenen"?MATH_WIDGET_TASKS:CREATIVE_WIDGET_TASKS;
const task=tasks[widgetId]&&tasks[widgetId][p.yearGroup];
return (LANG==="nl"?"Groep ":"Year ")+p.yearGroup+" | "+learningLevelLabel(p.level)+" | "+(task||p.title)
}
function languageWordComplexity(word){
@ -147,14 +162,15 @@ function mathTaskRules(yearGroup,level){
return{addSub:Math.max(10,Math.round(addCap*factor/5)*5),timesTable:year<3?2:year===3?(l===1?2:l===2?5:10):year===4?(l===1?5:10):12,factorMax:year<=3?5:year<=4?10:year<=6?12:20,numberlineMax:Math.max(20,Math.round(numberMax*(l===1?.4:l===2?.7:1))),numberlineMin:year>=7&&l>=2?-100:0,numberlineStep:lineStep,fractionDen:year===1?2:year<=3?4:year===4?8:12,clockLevel:year<=2?1:year<=4?2:3,clockMinutes:year===1?[0]:year===2?[0,30]:year===3?[0,15,30,45]:null,placeLevel:year<=2?1:year<=4?2:3,moneyLevel:year<=2?1:year<=4?2:3,percentSets:year<=5?[50,100]:year===6?[10,25,50,75,100]:year===7?[5,10,20,25,50,75,100]:[5,10,15,20,25,30,40,50,60,70,75,80,90,100]}
}
let SELF_LEARNING_LEVELS={taal:null,rekenen:null};
const LEARNING_SUBJECTS=["taal","rekenen","creatief"];
let SELF_LEARNING_LEVELS={taal:null,rekenen:null,creatief:null};
async function refreshSelfLearningLevels(){
if(!currentUser||currentUser.role!=="pupil"||currentUser.schoolId!=null){SELF_LEARNING_LEVELS={taal:null,rekenen:null};return SELF_LEARNING_LEVELS}
const pairs=await Promise.all(["taal","rekenen"].map(async subject=>{try{return[subject,await api("/my/level?subject="+subject)]}catch(_){return[subject,null]}}));
if(!currentUser||currentUser.role!=="pupil"||currentUser.schoolId!=null){SELF_LEARNING_LEVELS={taal:null,rekenen:null,creatief:null};return SELF_LEARNING_LEVELS}
const pairs=await Promise.all(LEARNING_SUBJECTS.map(async subject=>{try{return[subject,await api("/my/level?subject="+subject)]}catch(_){return[subject,null]}}));
SELF_LEARNING_LEVELS=Object.fromEntries(pairs);return SELF_LEARNING_LEVELS
}
function applyLearningLevelToState(def,state,level,managed){
const next=state?Object.assign({},state):{};if(!def||!["taal","rekenen"].includes(def.cat)||!level||level.yearGroup==null)return next;
const next=state?Object.assign({},state):{};if(!def||!LEARNING_SUBJECTS.includes(def.cat)||!level||level.yearGroup==null)return next;
next.yearGroup=learningYearGroup(level.yearGroup);next.level=learningLevel(level.level);next.managedLearningLevel=managed!==false;return next
}
function learningStateForWidget(def,state){
@ -166,7 +182,7 @@ function learningStateForWidget(def,state){
in de systeem-, schoolbeheerder- en leerkrachtweergave. Ouder- en
leerlingvoorbeelden gebruiken een aparte, alleen-lezen voorbeeldweergave. */
function canConfigureBoardLearningLevel(def){
if(!def||!["taal","rekenen"].includes(def.cat)||!currentUser)return false;
if(!def||!LEARNING_SUBJECTS.includes(def.cat)||!currentUser)return false;
const role=typeof activeRole==="function"?activeRole():currentUser.role;
return currentUser.role==="teacher"||
(currentUser.role==="super"&&["super","admin","teacher"].includes(role))
@ -203,10 +219,10 @@ function appendLearningProfileBadge(root,subject,state,widgetId){
(function registerOwnLearningLevelSettings(){
const host=document.createElement("div");host.className="st-section";host.dataset.section="learning-level";host.style.display="none";host.id="learningLevelSettings";document.getElementById("stContent").appendChild(host);
const text=()=>LANG==="nl"?{title:"Leerniveau",sub:"Kies zelf het passende leerjaar voor taal en rekenen.",note:"Deze keuze bepaalt woorden, getalgrenzen en opdrachten. De profielen zijn een Teach-uitwerking op basis van SLO/TULE; geen officiele SLO-jaarnormen.",save:"Opslaan",saved:"Leerniveau opgeslagen.",year:"Groep",taal:"Taal",rekenen:"Rekenen"}:{title:"Learning level",sub:"Choose the suitable year for language and maths.",note:"This choice determines words, number ranges and tasks. The profiles are a Teach interpretation based on SLO/TULE; not official SLO year standards.",save:"Save",saved:"Learning level saved.",year:"Year",taal:"Language",rekenen:"Maths"};
const text=()=>LANG==="nl"?{title:"Leerniveau",sub:"Kies het passende leerjaar voor taal, rekenen en creatief.",note:"Deze keuze bepaalt woorden, getalgrenzen, kleurplaatdetail en opdrachten. De profielen zijn een praktische Teach-uitwerking; geen officiele jaarnormen.",save:"Opslaan",saved:"Leerniveau opgeslagen.",year:"Groep",taal:"Taal",rekenen:"Rekenen",creatief:"Creatief"}:{title:"Learning level",sub:"Choose the suitable year for language, maths and creative work.",note:"This choice determines words, number ranges, colouring-page detail and tasks. The profiles are a practical Teach interpretation; not official year standards.",save:"Save",saved:"Learning level saved.",year:"Year",taal:"Language",rekenen:"Maths",creatief:"Creative"};
const render=async()=>{
host.innerHTML="";const tx=text(),msg=document.createElement("div");msg.className="formMsg";host.appendChild(msg);await refreshSelfLearningLevels();
for(const subject of ["taal","rekenen"]){
for(const subject of LEARNING_SUBJECTS){
const current=SELF_LEARNING_LEVELS[subject]||{yearGroup:4,level:2},card=document.createElement("section");card.className="learning-level-card";
const h=document.createElement("h3");h.textContent=tx[subject];const summary=document.createElement("p");summary.className="guestnote";const controls=document.createElement("div");controls.className="am-inline";
const year=document.createElement("select"),level=document.createElement("select");year.className=level.className="am-sel";
@ -217,5 +233,5 @@ function appendLearningProfileBadge(root,subject,state,widgetId){
}
const note=document.createElement("p");note.className="guestnote learning-source-note";note.textContent=tx.note;host.appendChild(note)
};
registerSettingsSection({id:"learning-level",icon:"🎯",group:"personal",order:1.5,label:()=>text().title,sub:()=>text().sub,visible:()=>!!currentUser&&currentUser.role==="pupil"&&currentUser.schoolId==null,activate(){showSettingsDom("learning-level");return render()},keywords:()=>[text().title,text().year,"taal","rekenen","groep","niveau"]})
registerSettingsSection({id:"learning-level",icon:"🎯",group:"personal",order:1.5,label:()=>text().title,sub:()=>text().sub,visible:()=>!!currentUser&&currentUser.role==="pupil"&&currentUser.schoolId==null,activate(){showSettingsDom("learning-level");return render()},keywords:()=>[text().title,text().year,"taal","rekenen","creatief","groep","niveau"]})
})();

View file

@ -324,7 +324,7 @@ function renderLiveClassOverview(host,dashboard,live,onPupil,onAction){
?REGISTRY.find(def=>def.id===pupil.session?.widgetType)?.cat:null;
actionButton("↘ "+T("amActionEasier"),"easier",
()=>{if(window.confirm(T("amActionEasierConfirm")))trigger("easier",{subject});},
!pupil.session||!["taal","rekenen","world"].includes(subject));
!pupil.session||!["taal","rekenen","world","creatief"].includes(subject));
const pauseKind=pupil.session?.paused?"resume":"pause";
actionButton((pauseKind==="pause"?"⏸ ":"▶ ")+
T(pauseKind==="pause"?"amActionPause":"amActionResume"),pauseKind,

View file

@ -106,10 +106,11 @@ function parentChildRow(c){
levelHolder.innerHTML = "";
try{
const data = await api(`/parent/children/${c.id}/levels`);
["taal","rekenen"].forEach(subject=>{
["taal","rekenen","creatief"].forEach(subject=>{
const current = data.levels?.[subject] || {yearGroup:4,level:2};
const card = prH("section","learning-level-card");
card.appendChild(prH("h3",null,T(subject==="taal"?"parentLevelTaal":"parentLevelRekenen")));
const subjectKey={taal:"parentLevelTaal",rekenen:"parentLevelRekenen",creatief:"parentLevelCreative"}[subject];
card.appendChild(prH("h3",null,T(subjectKey)));
const summary = prH("p","guestnote");
const controls = prH("div","am-inline");
const year = prH("select","am-sel"), level = prH("select","am-sel");

View file

@ -7,7 +7,7 @@
voortgang wordt gelogd) of "kijken" (alleen meekijken: alle interactie
geblokkeerd via een overlay, snellere poll zodat wijzigingen van de
leerkracht vlot doorkomen). */
const PUPIL_WIDGET_CATS = ["taal", "rekenen"];
const PUPIL_WIDGET_CATS = ["taal", "rekenen", "creatief"];
const PUPIL_POLL_MS = { kijken: 5000, werken: 20000 };
const PUPIL_LIVE_HEARTBEAT_MS = 12000;
const pupilView = document.getElementById("pupilView");

View file

@ -0,0 +1,368 @@
/* teach - thematische kleurplaten met gekleurd potlood en vlakvulling */
"use strict";
const COLORING_THEME_KEYS=Object.freeze({
all:"colorThemeAll",animals:"colorThemeAnimals",seasons:"colorThemeSeasons",
nature:"colorThemeNature",science:"colorThemeScience",culture:"colorThemeCulture"
});
const COLORING_COLORS=Object.freeze([
"#26344a","#e0554d","#f28c3c","#f7c948","#3fae6a","#2fb7a8",
"#3b7dd8","#7557c8","#d85aa5","#8b5a3c","#f2b38f","#ffffff"
]);
function coloringPart(tag,id,attrs="",level=1){
return `<${tag} data-fillable="true" data-part="${id}"${level>1?` data-level="${level}"`:""} ${attrs}/>`;
}
function coloringLine(tag,attrs="",level=1){
return `<${tag}${level>1?` data-level="${level}"`:""} ${attrs}/>`;
}
function coloringScene(content){
return `<svg viewBox="0 0 800 600" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<g class="coloring-svg-lines" fill="#fff" stroke="#26344a" stroke-width="7" stroke-linecap="round" stroke-linejoin="round">
${content}
</g>
</svg>`;
}
function coloringPetals(cx,cy,prefix,level=2){
return [0,60,120,180,240,300].map((angle,index)=>{
const rad=angle*Math.PI/180,x=(cx+Math.cos(rad)*27).toFixed(1),y=(cy+Math.sin(rad)*27).toFixed(1);
return coloringPart("circle",`${prefix}-petal-${index}`,`cx="${x}" cy="${y}" r="20"`,level);
}).join("")+coloringPart("circle",`${prefix}-heart`,`cx="${cx}" cy="${cy}" r="17"`,level);
}
function coloringStars(points,prefix,level=2){
return points.map(([x,y,r],index)=>coloringPart("path",`${prefix}-${index}`,
`d="M ${x} ${y-r} L ${x+r*.24} ${y-r*.25} L ${x+r} ${y} L ${x+r*.24} ${y+r*.25} L ${x} ${y+r} L ${x-r*.24} ${y+r*.25} L ${x-r} ${y} L ${x-r*.24} ${y-r*.25} Z"`,level)).join("");
}
function sceneButterfly(){return coloringScene(
coloringPart("rect","sky",'x="4" y="4" width="792" height="592" rx="28"')+
coloringPart("path","grass",'d="M 4 430 Q 170 390 320 438 T 796 420 L 796 596 L 4 596 Z"')+
coloringPart("circle","sun",'cx="685" cy="105" r="62"')+
coloringPart("ellipse","wing-left-top",'cx="304" cy="248" rx="112" ry="135" transform="rotate(-28 304 248)"')+
coloringPart("ellipse","wing-right-top",'cx="496" cy="248" rx="112" ry="135" transform="rotate(28 496 248)"')+
coloringPart("ellipse","wing-left-bottom",'cx="323" cy="386" rx="92" ry="105" transform="rotate(22 323 386)"')+
coloringPart("ellipse","wing-right-bottom",'cx="477" cy="386" rx="92" ry="105" transform="rotate(-22 477 386)"')+
coloringPart("ellipse","body",'cx="400" cy="316" rx="35" ry="142"')+
coloringPart("circle","head",'cx="400" cy="155" r="42"')+
coloringLine("path",'d="M 380 122 Q 340 72 315 82 M 420 122 Q 460 72 485 82" fill="none"')+
[[293,220],[507,220],[325,382],[475,382]].map(([x,y],i)=>coloringPart("circle",`wing-dot-${i}`,`cx="${x}" cy="${y}" r="28"`,2)).join("")+
coloringLine("path",'d="M 385 155 Q 400 170 415 155" fill="none"',2)+
coloringPetals(125,475,"flower-a",2)+coloringPetals(665,492,"flower-b",3)
)}
function sceneAutumn(){return coloringScene(
coloringPart("rect","sky",'x="4" y="4" width="792" height="592" rx="28"')+
coloringPart("path","ground",'d="M 4 440 Q 190 405 380 448 T 796 430 L 796 596 L 4 596 Z"')+
coloringPart("path","trunk",'d="M 337 500 Q 365 380 358 250 Q 398 285 420 250 Q 415 390 463 500 Z"')+
[[390,162,115],[285,215,95],[505,225,104],[340,290,92],[460,315,88]].map(([x,y,r],i)=>
coloringPart("circle",`crown-${i}`,`cx="${x}" cy="${y}" r="${r}"`,i>2?2:1)).join("")+
[[115,175,-18],[660,165,20],[175,355,12],[610,390,-24],[90,505,15],[705,500,-8]].map(([x,y,a],i)=>
coloringPart("path",`leaf-${i}`,`d="M ${x-35} ${y} Q ${x} ${y-45} ${x+35} ${y} Q ${x} ${y+45} ${x-35} ${y} Z" transform="rotate(${a} ${x} ${y})"`,2)).join("")+
coloringStars([[155,90,20],[585,95,18],[730,300,16]],"wind-leaf",3)+
coloringLine("path",'d="M 85 120 Q 170 80 240 118 M 560 120 Q 645 80 720 118" fill="none"',3)
)}
function sceneCake(){return coloringScene(
coloringPart("rect","party-wall",'x="4" y="4" width="792" height="592" rx="28"')+
coloringPart("path","table",'d="M 4 445 L 796 445 L 796 596 L 4 596 Z"')+
coloringPart("rect","cake-bottom",'x="190" y="360" width="420" height="120" rx="24"')+
coloringPart("rect","cake-middle",'x="245" y="265" width="310" height="105" rx="22"')+
coloringPart("rect","cake-top",'x="305" y="180" width="190" height="95" rx="20"')+
coloringPart("path","icing-bottom",'d="M 190 390 Q 225 430 260 390 Q 300 430 340 390 Q 380 430 420 390 Q 460 430 500 390 Q 555 430 610 390 L 610 360 L 190 360 Z"',2)+
coloringPart("path","icing-middle",'d="M 245 295 Q 285 330 325 295 Q 365 330 405 295 Q 450 330 495 295 Q 525 325 555 295 L 555 265 L 245 265 Z"',2)+
coloringPart("rect","candle",'x="382" y="93" width="36" height="92" rx="8"')+
coloringPart("path","flame",'d="M 400 92 Q 355 55 400 20 Q 445 55 400 92 Z"')+
[[110,115],[690,120],[135,280],[665,275]].map(([x,y],i)=>coloringPart("path",`balloon-${i}`,`d="M ${x} ${y+55} C ${x-65} ${y+20} ${x-48} ${y-65} ${x} ${y-65} C ${x+48} ${y-65} ${x+65} ${y+20} ${x} ${y+55} Z"`,i>1?2:1)).join("")+
coloringLine("path",'d="M 110 170 Q 130 260 95 350 M 690 175 Q 670 260 705 350 M 135 335 Q 160 390 140 440 M 665 330 Q 640 390 660 440" fill="none"')+
coloringStars([[205,95,22],[595,72,22],[90,405,15],[720,410,15]],"confetti",3)
)}
function sceneOcean(){return coloringScene(
coloringPart("rect","water",'x="4" y="4" width="792" height="592" rx="28"')+
coloringPart("path","sand",'d="M 4 475 Q 140 430 275 482 T 535 470 T 796 458 L 796 596 L 4 596 Z"')+
coloringPart("ellipse","fish-body",'cx="325" cy="260" rx="145" ry="88"')+
coloringPart("path","fish-tail",'d="M 185 260 L 85 178 Q 110 260 85 342 Z"')+
coloringPart("circle","fish-eye",'cx="395" cy="230" r="18"')+
coloringPart("path","turtle-shell",'d="M 510 390 Q 585 285 665 390 Q 600 485 510 390 Z"')+
coloringPart("circle","turtle-head",'cx="690" cy="382" r="38"')+
[[530,330,-28],[550,450,28],[635,330,25],[625,450,-25]].map(([x,y,a],i)=>coloringPart("ellipse",`flipper-${i}`,`cx="${x}" cy="${y}" rx="48" ry="20" transform="rotate(${a} ${x} ${y})"`,2)).join("")+
coloringLine("path",'d="M 270 220 Q 325 265 270 310 M 335 190 Q 370 260 335 330" fill="none"',2)+
coloringLine("path",'d="M 542 380 L 648 400 M 570 335 L 610 450 M 545 390 L 590 330 L 645 390 L 590 450 Z" fill="none"',3)+
[[115,105,18],[150,75,11],[640,100,22],[675,65,13],[730,145,15]].map(([x,y,r],i)=>coloringPart("circle",`bubble-${i}`,`cx="${x}" cy="${y}" r="${r}"`,3)).join("")+
coloringPart("path","seaweed-a",'d="M 120 525 Q 70 455 120 385 Q 165 455 120 525 Z"',2)+
coloringPart("path","seaweed-b",'d="M 190 535 Q 145 470 205 410 Q 240 475 190 535 Z"',3)
)}
function sceneGarden(){return coloringScene(
coloringPart("rect","garden-sky",'x="4" y="4" width="792" height="592" rx="28"')+
coloringPart("path","garden-ground",'d="M 4 390 Q 160 350 320 400 T 796 385 L 796 596 L 4 596 Z"')+
coloringPart("circle","sun",'cx="690" cy="95" r="58"')+
coloringPart("path","snail-shell",'d="M 120 405 C 120 290 300 290 305 405 C 280 490 155 490 120 405 Z"')+
coloringPart("path","snail-body",'d="M 95 455 Q 250 470 365 430 Q 410 415 438 450 Q 400 500 300 500 L 110 500 Z"')+
coloringPart("circle","snail-head",'cx="410" cy="410" r="48"')+
coloringLine("path",'d="M 388 370 L 370 315 M 427 370 L 450 318" fill="none"')+
coloringPart("circle","ladybird",'cx="600" cy="385" r="82"')+
coloringPart("circle","ladybird-head",'cx="600" cy="298" r="43"')+
coloringLine("path",'d="M 600 305 L 600 465 M 540 328 Q 600 385 540 440 M 660 328 Q 600 385 660 440" fill="none"',2)+
[[565,360],[635,360],[560,415],[640,415]].map(([x,y],i)=>coloringPart("circle",`ladybird-dot-${i}`,`cx="${x}" cy="${y}" r="15"`,2)).join("")+
coloringPetals(165,165,"garden-flower-a",2)+coloringPetals(430,145,"garden-flower-b",3)+
coloringLine("path",'d="M 165 205 L 165 315 M 430 185 L 430 280" fill="none"',2)
)}
function sceneWindmill(){return coloringScene(
coloringPart("rect","dutch-sky",'x="4" y="4" width="792" height="592" rx="28"')+
coloringPart("path","polder",'d="M 4 410 Q 190 380 365 420 T 796 405 L 796 596 L 4 596 Z"')+
coloringPart("path","water",'d="M 4 505 Q 200 475 400 515 T 796 500 L 796 596 L 4 596 Z"',2)+
coloringPart("path","mill-body",'d="M 310 470 L 350 185 L 485 185 L 535 470 Z"')+
coloringPart("path","mill-roof",'d="M 335 185 Q 420 78 500 185 Z"')+
coloringPart("rect","mill-door",'x="392" y="375" width="65" height="95" rx="25"')+
coloringPart("circle","mill-hub",'cx="418" cy="220" r="35"')+
[["M 405 190 L 320 55 L 345 40 L 430 205 Z"],["M 447 208 L 630 120 L 645 150 L 448 235 Z"],["M 430 250 L 515 410 L 485 425 L 405 248 Z"],["M 389 232 L 205 325 L 190 295 L 388 205 Z"]].map((d,i)=>coloringPart("path",`sail-${i}`,`d="${d}"`,1)).join("")+
coloringPart("rect","house",'x="80" y="345" width="165" height="125" rx="8"',2)+
coloringPart("path","house-roof",'d="M 55 345 L 165 260 L 270 345 Z"',2)+
coloringPart("rect","house-window-a",'x="105" y="375" width="45" height="45"',3)+
coloringPart("rect","house-window-b",'x="175" y="375" width="45" height="45"',3)+
coloringLine("path",'d="M 35 185 Q 75 155 115 185 Q 155 155 195 185 M 595 75 Q 630 50 665 75 Q 700 50 735 75" fill="none"',3)
)}
function sceneRocket(){return coloringScene(
coloringPart("rect","space",'x="4" y="4" width="792" height="592" rx="28"')+
coloringPart("circle","planet-large",'cx="650" cy="150" r="100"')+
coloringPart("path","planet-ring",'d="M 535 120 Q 650 195 765 120 Q 650 255 535 120 Z"',2)+
coloringPart("path","rocket-body",'d="M 320 420 Q 315 185 400 75 Q 485 185 480 420 Z"')+
coloringPart("circle","rocket-window",'cx="400" cy="225" r="48"')+
coloringPart("path","rocket-left-fin",'d="M 325 335 Q 245 385 245 480 L 350 410 Z"')+
coloringPart("path","rocket-right-fin",'d="M 475 335 Q 555 385 555 480 L 450 410 Z"')+
coloringPart("path","flame-outer",'d="M 350 420 Q 330 525 400 575 Q 470 525 450 420 Z"')+
coloringPart("path","flame-inner",'d="M 380 420 Q 365 495 400 530 Q 435 495 420 420 Z"',2)+
coloringStars([[110,105,30],[210,225,22],[680,350,28],[120,430,20],[575,80,15]],"space-star",2)+
coloringPart("circle","moon",'cx="680" cy="475" r="62"',3)+
[[655,458,13],[700,490,17],[690,445,9]].map(([x,y,r],i)=>coloringPart("circle",`crater-${i}`,`cx="${x}" cy="${y}" r="${r}"`,3)).join("")
)}
function sceneRainforest(){return coloringScene(
coloringPart("rect","forest-sky",'x="4" y="4" width="792" height="592" rx="28"')+
coloringPart("path","forest-floor",'d="M 4 475 Q 190 430 360 490 T 796 455 L 796 596 L 4 596 Z"')+
coloringPart("path","tree-trunk-left",'d="M 40 500 Q 95 330 70 45 L 205 45 Q 165 330 235 500 Z"')+
coloringPart("path","tree-trunk-right",'d="M 585 500 Q 640 300 615 35 L 760 35 Q 720 300 785 500 Z"')+
coloringPart("path","vine",'d="M 170 55 Q 300 175 230 315 Q 170 430 300 485 Q 400 525 455 425 Q 505 330 430 265"',2)+
coloringPart("path","parrot-body",'d="M 340 270 Q 390 155 490 230 Q 520 330 430 405 Q 330 390 340 270 Z"')+
coloringPart("circle","parrot-head",'cx="450" cy="205" r="75"')+
coloringPart("path","parrot-beak",'d="M 505 205 Q 590 225 515 270 Z"')+
coloringPart("path","parrot-wing",'d="M 365 275 Q 455 245 465 365 Q 390 400 365 275 Z"',2)+
coloringPart("circle","parrot-eye",'cx="470" cy="185" r="14"')+
[[285,125,-25],[540,100,18],[265,405,15],[525,450,-20]].map(([x,y,a],i)=>coloringPart("path",`forest-leaf-${i}`,`d="M ${x-55} ${y} Q ${x} ${y-70} ${x+55} ${y} Q ${x} ${y+70} ${x-55} ${y} Z" transform="rotate(${a} ${x} ${y})"`,i<2?2:3)).join("")+
coloringLine("path",'d="M 285 70 L 285 180 M 540 30 L 540 170 M 265 335 L 265 475 M 525 375 L 525 525" fill="none"',3)
)}
function sceneCastle(){return coloringScene(
coloringPart("rect","castle-sky",'x="4" y="4" width="792" height="592" rx="28"')+
coloringPart("path","castle-hill",'d="M 4 445 Q 200 380 400 450 T 796 420 L 796 596 L 4 596 Z"')+
coloringPart("rect","castle-main",'x="225" y="245" width="350" height="250"')+
coloringPart("rect","tower-left",'x="115" y="195" width="155" height="300"')+
coloringPart("rect","tower-right",'x="530" y="195" width="155" height="300"')+
coloringPart("path","roof-left",'d="M 85 195 L 193 75 L 300 195 Z"')+
coloringPart("path","roof-right",'d="M 500 195 L 608 75 L 715 195 Z"')+
coloringPart("path","gate",'d="M 350 495 L 350 385 Q 400 325 450 385 L 450 495 Z"')+
coloringPart("rect","flag-left",'x="193" y="55" width="92" height="50"',2)+
coloringPart("rect","flag-right",'x="608" y="55" width="92" height="50"',2)+
[[160,255],[225,255],[575,255],[640,255],[280,300],[475,300]].map(([x,y],i)=>coloringPart("rect",`window-${i}`,`x="${x-20}" y="${y-25}" width="40" height="58" rx="18"`,i>3?3:2)).join("")+
coloringLine("path",'d="M 225 345 L 575 345 M 155 385 L 230 385 M 570 385 L 645 385" fill="none"',3)+
coloringPart("path","path",'d="M 350 596 Q 365 510 400 445 Q 435 510 470 596 Z"',2)
)}
function sceneFourSeasons(){return coloringScene(
coloringPart("rect","season-sky",'x="4" y="4" width="792" height="592" rx="28"')+
coloringPart("path","season-ground",'d="M 4 455 Q 190 420 390 465 T 796 440 L 796 596 L 4 596 Z"')+
coloringPart("path","season-trunk",'d="M 345 510 Q 380 350 375 210 Q 420 255 445 205 Q 425 365 475 510 Z"')+
coloringPart("path","spring-crown",'d="M 385 230 Q 245 250 210 165 Q 225 65 355 85 Q 430 125 385 230 Z"')+
coloringPart("path","summer-crown",'d="M 395 230 Q 430 80 545 75 Q 675 95 620 220 Q 520 285 395 230 Z"')+
coloringPart("path","autumn-crown",'d="M 405 240 Q 560 220 635 295 Q 670 415 535 425 Q 420 390 405 240 Z"')+
coloringPart("path","winter-crown",'d="M 380 235 Q 335 385 225 420 Q 105 390 165 280 Q 250 210 380 235 Z"')+
coloringPetals(280,145,"spring-flower",2)+
coloringPart("circle","summer-fruit-a",'cx="515" cy="165" r="24"',2)+coloringPart("circle","summer-fruit-b",'cx="570" cy="215" r="24"',3)+
[[500,310,-15],[575,355,20],[460,375,-30]].map(([x,y,a],i)=>coloringPart("path",`autumn-leaf-${i}`,`d="M ${x-28} ${y} Q ${x} ${y-38} ${x+28} ${y} Q ${x} ${y+38} ${x-28} ${y} Z" transform="rotate(${a} ${x} ${y})"`,i===2?3:2)).join("")+
coloringStars([[205,310,17],[285,345,15],[160,365,13]],"snowflake",3)
)}
function sceneEcoCity(){return coloringScene(
coloringPart("rect","city-sky",'x="4" y="4" width="792" height="592" rx="28"')+
coloringPart("path","city-ground",'d="M 4 455 L 796 455 L 796 596 L 4 596 Z"')+
coloringPart("rect","tower",'x="95" y="190" width="155" height="265" rx="8"')+
coloringPart("rect","school",'x="285" y="275" width="235" height="180" rx="8"')+
coloringPart("path","school-roof",'d="M 260 275 L 402 185 L 545 275 Z"')+
coloringPart("rect","apartments",'x="565" y="135" width="145" height="320" rx="8"')+
[[135,235],[205,235],[135,305],[205,305],[135,375],[205,375],[600,185],[670,185],[600,255],[670,255],[600,325],[670,325]].map(([x,y],i)=>coloringPart("rect",`city-window-${i}`,`x="${x-22}" y="${y-25}" width="44" height="50"`,i>5?2:1)).join("")+
coloringPart("rect","school-door",'x="375" y="365" width="55" height="90" rx="12"')+
coloringPart("circle","tree-crown",'cx="60" cy="410" r="55"',2)+coloringPart("rect","tree-trunk",'x="48" y="430" width="24" height="75"',2)+
coloringPart("path","bike-frame",'d="M 250 515 L 315 515 L 285 470 L 250 515 L 330 465"',3)+
coloringLine("circle",'cx="240" cy="520" r="34" fill="none"',3)+coloringLine("circle",'cx="330" cy="520" r="34" fill="none"',3)+
coloringPart("path","solar-a",'d="M 100 190 L 125 135 L 245 135 L 225 190 Z"',2)+coloringPart("path","solar-b",'d="M 580 135 L 600 80 L 705 80 L 690 135 Z"',2)+
coloringLine("path",'d="M 125 160 L 235 160 M 165 135 L 155 190 M 205 135 L 195 190 M 600 105 L 700 105 M 635 80 L 625 135 M 670 80 L 660 135" fill="none"',3)
)}
function sceneMandala(){
const rings=[];
for(let i=0;i<16;i++){
const angle=i*Math.PI/8,cx=400+Math.cos(angle)*155,cy=300+Math.sin(angle)*155;
rings.push(coloringPart("ellipse",`mandala-outer-${i}`,`cx="${cx.toFixed(1)}" cy="${cy.toFixed(1)}" rx="38" ry="82" transform="rotate(${i*22.5+90} ${cx.toFixed(1)} ${cy.toFixed(1)})"`,i%2?3:2));
}
for(let i=0;i<12;i++){
const angle=i*Math.PI/6,cx=400+Math.cos(angle)*92,cy=300+Math.sin(angle)*92;
rings.push(coloringPart("path",`mandala-inner-${i}`,`d="M 400 300 Q ${cx.toFixed(1)} ${cy.toFixed(1)} ${(400+Math.cos(angle+.28)*145).toFixed(1)} ${(300+Math.sin(angle+.28)*145).toFixed(1)} Q ${cx.toFixed(1)} ${cy.toFixed(1)} 400 300 Z"`,1));
}
return coloringScene(
coloringPart("rect","mandala-bg",'x="4" y="4" width="792" height="592" rx="28"')+
rings.join("")+coloringPart("circle","mandala-heart",'cx="400" cy="300" r="62"')+
coloringStars([[400,300,34]],"mandala-star",3)
);
}
function sceneEcosystem(){return coloringScene(
coloringPart("rect","pond-sky",'x="4" y="4" width="792" height="592" rx="28"')+
coloringPart("path","pond-bank",'d="M 4 390 Q 160 335 315 395 T 620 375 T 796 390 L 796 596 L 4 596 Z"')+
coloringPart("ellipse","pond-water",'cx="405" cy="485" rx="310" ry="90"')+
coloringPart("path","frog-body",'d="M 305 435 Q 345 350 410 430 Q 465 350 505 435 Q 460 500 405 470 Q 350 500 305 435 Z"')+
coloringPart("circle","frog-eye-a",'cx="350" cy="385" r="34"')+coloringPart("circle","frog-eye-b",'cx="460" cy="385" r="34"')+
coloringPart("path","lily-pad-a",'d="M 120 505 Q 205 430 280 510 Q 205 555 120 505 Z"',2)+
coloringPart("path","lily-pad-b",'d="M 525 515 Q 610 435 690 510 Q 610 560 525 515 Z"',2)+
coloringPart("path","dragonfly-body",'d="M 585 220 Q 600 300 585 365 Q 570 300 585 220 Z"',2)+
[[545,250,-25],[625,250,25],[550,310,25],[620,310,-25]].map(([x,y,a],i)=>coloringPart("ellipse",`dragonfly-wing-${i}`,`cx="${x}" cy="${y}" rx="55" ry="22" transform="rotate(${a} ${x} ${y})"`,2)).join("")+
coloringPart("circle","sun",'cx="115" cy="105" r="55"')+
coloringPart("path","reed-a",'d="M 105 440 Q 75 320 105 210 Q 135 320 105 440 Z"',3)+coloringPart("path","reed-b",'d="M 160 430 Q 130 300 165 185 Q 195 310 160 430 Z"',3)+
coloringLine("path",'d="M 105 310 L 65 270 M 160 325 L 205 270" fill="none"',3)
)}
const COLORING_PAGES=Object.freeze([
{id:"butterfly",theme:"animals",years:[1,2],title:{nl:"Blije vlinder",en:"Happy butterfly"},svg:sceneButterfly()},
{id:"autumn",theme:"seasons",years:[1,2],title:{nl:"Herfstboom",en:"Autumn tree"},svg:sceneAutumn()},
{id:"cake",theme:"culture",years:[1,2],title:{nl:"Feesttaart",en:"Party cake"},svg:sceneCake()},
{id:"ocean",theme:"animals",years:[3,4],title:{nl:"Vrienden in zee",en:"Ocean friends"},svg:sceneOcean()},
{id:"garden",theme:"nature",years:[3,4],title:{nl:"Kleine tuindieren",en:"Little garden animals"},svg:sceneGarden()},
{id:"windmill",theme:"culture",years:[3,4],title:{nl:"Molen in de polder",en:"Windmill in the polder"},svg:sceneWindmill()},
{id:"rocket",theme:"science",years:[5,6],title:{nl:"Raket door de ruimte",en:"Rocket through space"},svg:sceneRocket()},
{id:"rainforest",theme:"nature",years:[5,6],title:{nl:"Regenwoud",en:"Rainforest"},svg:sceneRainforest()},
{id:"castle",theme:"culture",years:[5,6],title:{nl:"Kasteel en dorp",en:"Castle and village"},svg:sceneCastle()},
{id:"four-seasons",theme:"seasons",years:[7,8],title:{nl:"Boom van vier seizoenen",en:"Four seasons tree"},svg:sceneFourSeasons()},
{id:"eco-city",theme:"science",years:[7,8],title:{nl:"Duurzame stad",en:"Sustainable city"},svg:sceneEcoCity()},
{id:"mandala",theme:"culture",years:[7,8],title:{nl:"Geometrische mandala",en:"Geometric mandala"},svg:sceneMandala()},
{id:"ecosystem",theme:"nature",years:[7,8],title:{nl:"Leven rond de vijver",en:"Life around the pond"},svg:sceneEcosystem()}
]);
function coloringPagesFor(yearGroup,theme="all"){
const year=Math.max(1,Math.min(8,Number(yearGroup)||4));
return COLORING_PAGES.filter(page=>year>=page.years[0]&&year<=page.years[1]&&(theme==="all"||page.theme===theme));
}
function coloringPencilWidth(size,pressure,pointerType){
const base=Math.max(2,Math.min(18,Number(size)||6));
return pointerType==="pen"&&pressure>0?base*(.45+pressure*1.2):base;
}
function coloringSafeColor(value,fallback=COLORING_COLORS[1]){
return /^#[0-9a-f]{6}$/i.test(String(value||""))?String(value).toLowerCase():fallback;
}
function mountColoring(root,initState,opts={}){
const yearGroup=Math.max(1,Math.min(8,Number(initState&&initState.yearGroup)||4));
const level=Math.max(1,Math.min(3,Number(initState&&initState.level)||2));
let tool=["pencil","bucket","eraser"].includes(initState&&initState.tool)?initState.tool:"pencil";
let color=coloringSafeColor(initState&&initState.color);
let size=[4,7,12].includes(Number(initState&&initState.size))?Number(initState.size):7;
let theme=COLORING_THEME_KEYS[initState&&initState.theme]?initState.theme:"all";
let pageId=String(initState&&initState.pageId||"");
let fills=initState&&initState.pageId===pageId&&initState.fills&&typeof initState.fills==="object"?{...initState.fills}:{};
let completed=!!(initState&&initState.completed),restoreInk=initState&&initState.ink;
root.innerHTML=`<div class="coloring-app">
<div class="coloring-choices">
<label><span class="coloring-theme-label"></span><select class="coloring-theme"></select></label>
<label><span class="coloring-page-label"></span><select class="coloring-page"></select></label>
<span class="coloring-fit"></span>
</div>
<div class="coloring-tools">
<div class="coloring-tool-buttons"></div>
<div class="coloring-palette"></div>
<label class="coloring-custom"><span></span><input type="color"></label>
<div class="coloring-sizes"></div>
<button class="coloring-reset" type="button"></button>
<button class="coloring-done tbtn" type="button"></button>
</div>
<div class="coloring-stage"><div class="coloring-paper" role="img"><div class="coloring-art"></div><canvas width="800" height="600"></canvas></div></div>
<div class="coloring-hint" aria-live="polite"></div>
</div>`;
const app=root.querySelector(".coloring-app"),themeSel=root.querySelector(".coloring-theme"),pageSel=root.querySelector(".coloring-page");
const art=root.querySelector(".coloring-art"),paper=root.querySelector(".coloring-paper"),canvas=root.querySelector("canvas"),ctx=canvas.getContext("2d");
const toolHost=root.querySelector(".coloring-tool-buttons"),palette=root.querySelector(".coloring-palette"),sizes=root.querySelector(".coloring-sizes");
const custom=root.querySelector(".coloring-custom input"),reset=root.querySelector(".coloring-reset"),done=root.querySelector(".coloring-done"),hint=root.querySelector(".coloring-hint");
const TOOLS=[["pencil","✏️","colorToolPencil"],["bucket","🪣","colorToolBucket"],["eraser","🧽","eraser"]];
TOOLS.forEach(([id,icon])=>{const button=document.createElement("button");button.type="button";button.dataset.tool=id;button.textContent=icon;button.onclick=()=>{tool=id;refreshTools()};toolHost.appendChild(button)});
COLORING_COLORS.forEach(value=>{const button=document.createElement("button");button.type="button";button.className="coloring-color";button.dataset.color=value;const dot=document.createElement("i");dot.style.background=value;button.appendChild(dot);button.onclick=()=>{color=value;custom.value=value;if(tool==="eraser")tool="pencil";refreshTools()};palette.appendChild(button)});
[4,7,12].forEach(value=>{const button=document.createElement("button");button.type="button";button.className="coloring-size";button.dataset.size=value;const dot=document.createElement("i");dot.style.width=dot.style.height=(value+5)+"px";button.appendChild(dot);button.onclick=()=>{size=value;refreshTools()};sizes.appendChild(button)});
custom.value=color;custom.oninput=()=>{color=coloringSafeColor(custom.value,color);if(tool==="eraser")tool="pencil";refreshTools()};
function pageTitle(page){return page.title[LANG]||page.title.nl}
function eligibleThemes(){return [...new Set(coloringPagesFor(yearGroup).map(page=>page.theme))]}
function currentPages(){return coloringPagesFor(yearGroup,theme)}
function rebuildThemeOptions(){
const previous=theme;themeSel.replaceChildren();
const all=new Option(T("colorThemeAll"),"all");themeSel.add(all);
eligibleThemes().forEach(id=>themeSel.add(new Option(T(COLORING_THEME_KEYS[id]),id)));
theme=themeSel.querySelector(`option[value="${previous}"]`)?previous:"all";themeSel.value=theme;
}
function rebuildPageOptions(prefer=pageId){
const pages=currentPages();pageSel.replaceChildren();
pages.forEach(page=>pageSel.add(new Option(pageTitle(page),page.id)));
pageId=pages.some(page=>page.id===prefer)?prefer:(pages[0]&&pages[0].id)||"";pageSel.value=pageId;
}
function clearInk(){ctx.clearRect(0,0,canvas.width,canvas.height)}
function loadInk(url){
clearInk();if(!url)return;
const image=new Image();image.onload=()=>{clearInk();ctx.drawImage(image,0,0,canvas.width,canvas.height)};image.src=url;
}
function renderPage(keepState){
const page=COLORING_PAGES.find(item=>item.id===pageId)||currentPages()[0];if(!page)return;
pageId=page.id;art.innerHTML=page.svg;
const svg=art.querySelector("svg");paper.setAttribute("aria-label",pageTitle(page));
svg.querySelectorAll("[data-level]").forEach(node=>node.toggleAttribute("hidden",Number(node.dataset.level)>level));
if(!keepState){fills={};completed=false;restoreInk=null;clearInk()}
svg.querySelectorAll("[data-fillable][data-part]").forEach(node=>{
const saved=coloringSafeColor(fills[node.dataset.part],"");if(saved)node.setAttribute("fill",saved);
});
if(keepState&&restoreInk){loadInk(restoreInk);restoreInk=null}
refreshTools();updateTexts();
}
function updateTexts(){
root.querySelector(".coloring-theme-label").textContent=T("colorTheme");
root.querySelector(".coloring-page-label").textContent=T("colorPage");
root.querySelector(".coloring-fit").textContent=T("colorProfile").replace("{year}",yearGroup).replace("{level}",level);
TOOLS.forEach(([id,,key])=>{const button=toolHost.querySelector(`[data-tool="${id}"]`);button.title=T(key);button.setAttribute("aria-label",T(key))});
custom.parentElement.querySelector("span").textContent=T("customColor");
reset.title=T("colorReset");reset.setAttribute("aria-label",T("colorReset"));
done.textContent=completed?"✓ "+T("colorFinished"):T("colorDone");
hint.textContent=T(tool==="bucket"?"colorBucketHint":tool==="eraser"?"colorEraserHint":"colorPencilHint");
const selected=pageId;rebuildPageOptions(selected);
}
function refreshTools(){
toolHost.querySelectorAll("button").forEach(button=>{const on=button.dataset.tool===tool;button.classList.toggle("on",on);button.setAttribute("aria-pressed",String(on))});
palette.querySelectorAll("button").forEach(button=>button.classList.toggle("on",button.dataset.color===color&&tool!=="eraser"));
sizes.querySelectorAll("button").forEach(button=>{button.classList.toggle("on",Number(button.dataset.size)===size);button.title=T("penSize")});
app.dataset.tool=tool;canvas.style.pointerEvents=tool==="bucket"?"none":"auto";art.style.pointerEvents=tool==="bucket"?"auto":"none";
hint.textContent=T(tool==="bucket"?"colorBucketHint":tool==="eraser"?"colorEraserHint":"colorPencilHint");
}
themeSel.onchange=()=>{theme=themeSel.value;rebuildPageOptions("");renderPage(false)};
pageSel.onchange=()=>{pageId=pageSel.value;renderPage(false)};
art.addEventListener("click",event=>{
if(tool!=="bucket")return;const part=event.target.closest("[data-fillable][data-part]");if(!part||part.hasAttribute("hidden"))return;
part.setAttribute("fill",color);fills[part.dataset.part]=color;completed=false;updateTexts();
});
const point=event=>{const rect=canvas.getBoundingClientRect();return{x:(event.clientX-rect.left)*canvas.width/rect.width,y:(event.clientY-rect.top)*canvas.height/rect.height}};
let drawing=false,last=null,mid=null;
function pencilSegment(next,event){
const width=coloringPencilWidth(size,event.pressure,event.pointerType),erase=tool==="eraser";
ctx.save();ctx.globalCompositeOperation=erase?"destination-out":"source-over";ctx.strokeStyle=color;ctx.globalAlpha=erase?1:.27;ctx.lineCap="round";ctx.lineJoin="round";ctx.lineWidth=erase?width*3.5:width;
const nextMid={x:(last.x+next.x)/2,y:(last.y+next.y)/2};ctx.beginPath();ctx.moveTo(mid?mid.x:last.x,mid?mid.y:last.y);ctx.quadraticCurveTo(last.x,last.y,nextMid.x,nextMid.y);ctx.stroke();
if(!erase){for(let n=0;n<2;n++){const jitter=(Math.random()-.5)*width*.65;ctx.globalAlpha=.07;ctx.lineWidth=Math.max(1,width*.28);ctx.beginPath();ctx.moveTo(last.x+jitter,last.y-jitter);ctx.lineTo(next.x-jitter,next.y+jitter);ctx.stroke()}}
ctx.restore();last=next;mid=nextMid;
}
canvas.addEventListener("pointerdown",event=>{if(tool==="bucket"||(event.pointerType==="mouse"&&event.button!==0))return;event.preventDefault();canvas.setPointerCapture(event.pointerId);drawing=true;last=point(event);mid=null;pencilSegment({x:last.x+.01,y:last.y+.01},event);completed=false;updateTexts()});
canvas.addEventListener("pointermove",event=>{if(!drawing)return;(event.getCoalescedEvents?event.getCoalescedEvents():[event]).forEach(sample=>pencilSegment(point(sample),sample))});
const stop=()=>{drawing=false;last=mid=null};canvas.addEventListener("pointerup",stop);canvas.addEventListener("pointercancel",stop);
reset.onclick=()=>{fills={};completed=false;clearInk();renderPage(false)};
done.onclick=()=>{completed=true;updateTexts();if(typeof opts.onProgress==="function")opts.onProgress({attempts:1,correct:1,stars:1,detail:{question:pageTitle(COLORING_PAGES.find(page=>page.id===pageId)),answer:T("colorFinished"),correct:true}})};
document.addEventListener("langchange",()=>{rebuildThemeOptions();updateTexts();renderPage(true)});
rebuildThemeOptions();rebuildPageOptions(pageId);renderPage(true);
return{getState:()=>{
let ink=null;try{ink=canvas.toDataURL("image/png")}catch(_){/* een leeg canvas blijft gewoon herstelbaar */}
return{theme,pageId,fills:{...fills},ink,tool,color,size,completed,yearGroup,level,managedLearningLevel:!!(initState&&initState.managedLearningLevel)};
}};
}

View file

@ -130,9 +130,10 @@
profiles.appendChild(node("summary",null,T("wikiYearProfiles")));
profiles.appendChild(node("p",null,T("wikiYearProfilesText")));
const profileGrid=node("div","wiki-profile-grid");
[["taal","📖"],["rekenen","🔢"]].forEach(([subject,icon])=>{
[["taal","📖"],["rekenen","🔢"],["creatief","🎨"]].forEach(([subject,icon])=>{
const column=node("section","wiki-profile-column");
column.appendChild(node("h3",null,icon+" "+(subject==="taal"?T("parentLevelTaal"):T("parentLevelRekenen"))));
const subjectKey={taal:"parentLevelTaal",rekenen:"parentLevelRekenen",creatief:"parentLevelCreative"}[subject];
column.appendChild(node("h3",null,icon+" "+T(subjectKey)));
LEARNING_YEAR_PROFILES[subject].slice(1).forEach((profile,index)=>{
const row=node("article","wiki-profile-row");
row.appendChild(node("strong",null,T("wikiYear").replace("{n}",index+1)));

View file

@ -1167,12 +1167,12 @@ export default async function api(app) {
// ---- klassenmanagement: niveaugroep + niveau per leerling per vakgebied -------
// Zelfde klas-standaard-met-leerling-override-vorm als assignments hierboven,
// nu met vakgebied als extra dimensie (subject: taal/rekenen/world - dezelfde
// nu met vakgebied als extra dimensie (subject: taal/rekenen/world/creatief - dezelfde
// waarden als REGISTRY's cat in board.js). "groep" (year_group, 1-8) is de
// niveaugroep gebaseerd op de leerjaren van de basisschool waaraan de
// leerdoelen gekoppeld zijn; "level" (1-3, niet geklemd in het datamodel) is
// de fijnmazige verfijning daarbinnen die widgets nu al kennen.
const LEVEL_SUBJECTS = ['taal', 'rekenen', 'world'];
const LEVEL_SUBJECTS = ['taal', 'rekenen', 'world', 'creatief'];
const describePupilLevel = (row) => ({ yearGroup: Number(row.year_group), level: Number(row.level) });
const parseLevelBody = (req, reply) => {
const { subject, yearGroup, level } = req.body ?? {};
@ -1291,7 +1291,7 @@ export default async function api(app) {
if (req.user.school_id != null) return fail(reply, 403, 'het leerniveau wordt door de school beheerd');
const body = parseLevelBody(req, reply);
if (body.error) return body;
if (!['taal', 'rekenen'].includes(body.subject)) return fail(reply, 400, 'ongeldig vakgebied');
if (!['taal', 'rekenen', 'creatief'].includes(body.subject)) return fail(reply, 400, 'ongeldig vakgebied');
await pool.query(
`INSERT INTO pupil_levels (pupil_id, subject, year_group, level) VALUES ($1,$2,$3,$4)
ON CONFLICT (pupil_id, subject) WHERE pupil_id IS NOT NULL
@ -1525,7 +1525,7 @@ export default async function api(app) {
if (!child || child.role !== 'pupil') return fail(reply, 404, 'kind onbekend');
if (child.school_id != null) return { schoolManaged: true, levels: {} };
const rows = (await pool.query(
"SELECT * FROM pupil_levels WHERE pupil_id = $1 AND subject IN ('taal','rekenen')",
"SELECT * FROM pupil_levels WHERE pupil_id = $1 AND subject IN ('taal','rekenen','creatief')",
[child.id])).rows;
return {
schoolManaged: false,
@ -1541,7 +1541,7 @@ export default async function api(app) {
if (child.school_id != null) return fail(reply, 403, 'het leerniveau wordt door de school beheerd');
const body = parseLevelBody(req, reply);
if (body.error) return body;
if (!['taal', 'rekenen'].includes(body.subject)) return fail(reply, 400, 'ongeldig vakgebied');
if (!['taal', 'rekenen', 'creatief'].includes(body.subject)) return fail(reply, 400, 'ongeldig vakgebied');
await pool.query(
`INSERT INTO pupil_levels (pupil_id, subject, year_group, level) VALUES ($1,$2,$3,$4)
ON CONFLICT (pupil_id, subject) WHERE pupil_id IS NOT NULL

View file

@ -5,7 +5,7 @@
const LIVE_WINDOW_MS = 45_000;
const HELP_IDLE_MS = 3 * 60_000;
const ACTION_KINDS = new Set(['hint', 'encourage', 'pause', 'resume', 'easier']);
const LEVEL_SUBJECTS = new Set(['taal', 'rekenen', 'world']);
const LEVEL_SUBJECTS = new Set(['taal', 'rekenen', 'world', 'creatief']);
export default function registerLiveClassroom(app, options) {
const {

96
test/coloring.test.js Normal file
View file

@ -0,0 +1,96 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import {runInNewContext} from 'node:vm';
async function coloringContext(){
const source=await readFile('public/js/widgets/coloring.js','utf8');
const context={};
runInNewContext(source+`;
globalThis.__coloring={
pages:COLORING_PAGES,
themes:COLORING_THEME_KEYS,
pagesFor:coloringPagesFor,
pencilWidth:coloringPencilWidth,
safeColor:coloringSafeColor
}`,context);
return{source,api:context.__coloring};
}
test('kleurplatenwidget laadt vóór het bord en is als creatieve leerlingwidget geregistreerd',async()=>{
const [html,board,pupil,cast,core,css]=await Promise.all([
readFile('public/index.html','utf8'),readFile('public/js/board.js','utf8'),
readFile('public/js/pupil.js','utf8'),readFile('public/js/cast.js','utf8'),
readFile('public/js/core.js','utf8'),readFile('public/css/coloring.css','utf8')
]);
assert.ok(html.indexOf('js/widgets/coloring.js')>0);
assert.ok(html.indexOf('js/widgets/coloring.js')<html.indexOf('js/board.js'));
assert.match(html,/css\/coloring\.css\?v=__V__/);
assert.match(board,/id:"coloring"[^\n]*cat:"creatief"[^\n]*mount:mountColoring/);
assert.match(pupil,/PUPIL_WIDGET_CATS\s*=\s*\[[^\]]*"creatief"/);
assert.match(cast,/CAST_WIDGET_CATEGORIES=new Set\(\[[^\]]*"creatief"/);
assert.match(css,/\.coloring-paper[^}]*aspect-ratio:4\/3/);
for(const key of ['wg_coloring','wg_coloring_d','catCreative','colorToolPencil','colorToolBucket','colorBucketHint','colorPencilHint']){
assert.equal((core.match(new RegExp(key+':','g'))||[]).length,2,key);
}
});
test('elk leerjaar heeft meerdere passende kleurplaten met thema en drie detailniveaus',async()=>{
const {api}=await coloringContext();
const pages=JSON.parse(JSON.stringify(api.pages));
assert.equal(pages.length,13);
assert.equal(new Set(pages.map(page=>page.id)).size,pages.length);
for(let year=1;year<=8;year++){
const suitable=JSON.parse(JSON.stringify(api.pagesFor(year)));
assert.ok(suitable.length>=3,`groep ${year} heeft minimaal drie kleurplaten`);
assert.ok(suitable.every(page=>year>=page.years[0]&&year<=page.years[1]));
assert.ok(new Set(suitable.map(page=>page.theme)).size>=3,`groep ${year} heeft diverse thema's`);
}
for(const page of pages){
assert.ok(api.themes[page.theme],page.id+' heeft een bekend thema');
assert.ok(page.title.nl&&page.title.en,page.id+' heeft een tweetalige titel');
assert.match(page.svg,/data-fillable="true"/);
assert.match(page.svg,/data-level="2"/);
assert.match(page.svg,/data-level="3"/);
assert.doesNotMatch(page.svg,/<script|on\w+=/i);
}
assert.equal(api.pagesFor(5,'science').length,1);
assert.equal(api.pagesFor(3,'science').length,0);
});
test('potlood reageert op echte pendruk en bouwt kleur transparant op',async()=>{
const {source,api}=await coloringContext();
assert.ok(api.pencilWidth(7,.9,'pen')>api.pencilWidth(7,.2,'pen'));
assert.equal(api.pencilWidth(7,0,'mouse'),7);
assert.equal(api.safeColor('#AABBCC'),'#aabbcc');
assert.equal(api.safeColor('red'),'#e0554d');
for(const feature of ['getCoalescedEvents','event.pressure','erase?1:.27','quadraticCurveTo','Math.random()'])assert.ok(source.includes(feature),feature);
const css=await readFile('public/css/coloring.css','utf8');
assert.match(css,/mix-blend-mode:multiply/);
});
test('verfemmer vult een SVG-vak in één keer en state bewaart potlood én vlakkleuren',async()=>{
const {source}=await coloringContext();
assert.match(source,/event\.target\.closest\("\[data-fillable\]\[data-part\]\"\)/);
assert.match(source,/part\.setAttribute\("fill",color\)/);
assert.match(source,/fills\[part\.dataset\.part\]=color/);
assert.match(source,/canvas\.toDataURL\("image\/png"\)/);
assert.match(source,/return\{theme,pageId,fills:\{\.\.\.fills\},ink,tool,color,size,completed,yearGroup,level/);
assert.match(source,/opts\.onProgress/);
});
test('creatief niveau is beschikbaar per klas, leerling en thuiskind',async()=>{
const [api,admin,progressions,parent,wiki,migration,live]=await Promise.all([
readFile('src/api.js','utf8'),readFile('public/js/admin.js','utf8'),
readFile('public/js/learning-progressions.js','utf8'),readFile('public/js/parent.js','utf8'),
readFile('public/js/wiki.js','utf8'),
readFile('db/024_creative_pupil_levels.sql','utf8'),readFile('src/live-classroom.js','utf8')
]);
assert.match(api,/LEVEL_SUBJECTS = \[[^\]]*'creatief'/);
assert.match(admin,/KM_SUBJECTS = \[[^\]]*"creatief"/);
assert.match(progressions,/LEARNING_SUBJECTS=\[[^\]]*"creatief"/);
assert.match(parent,/\["taal","rekenen","creatief"\]\.forEach/);
assert.match(wiki,/creatief:"parentLevelCreative"/);
assert.match(migration,/CHECK \(subject IN \('taal','rekenen','world','creatief'\)\)/);
assert.match(live,/LEVEL_SUBJECTS = new Set\(\[[^\]]*'creatief'/);
});

View file

@ -17,7 +17,7 @@ async function progressionContext(){
return context;
}
test('taal en rekenen hebben een compleet inhoudelijk profiel voor groep 1 tot en met 8',async()=>{
test('taal, rekenen en creatief hebben een compleet inhoudelijk profiel voor groep 1 tot en met 8',async()=>{
const {__profiles:profiles}=await progressionContext();
for(const subject of ['taal','rekenen']){
assert.equal(profiles[subject].length,9);

View file

@ -89,15 +89,15 @@ test('ouder leest en wijzigt het leerniveau van een gekoppeld thuiskind', async
const respond = (sql) => {
if (sql.includes('FROM parent_children WHERE parent_id')) return { rows: [{ 1: 1 }] };
if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [homeChild] };
if (sql.includes("subject IN ('taal','rekenen')")) return { rows: [
{ subject:'taal',year_group:3,level:2 },{ subject:'rekenen',year_group:4,level:1 },
if (sql.includes("subject IN ('taal','rekenen','creatief')")) return { rows: [
{ subject:'taal',year_group:3,level:2 },{ subject:'rekenen',year_group:4,level:1 },{ subject:'creatief',year_group:5,level:3 },
] };
return undefined;
};
const {app,calls}=await makeApp({user:parent,respond});
const get=await app.inject({method:'GET',url:'/api/parent/children/30/levels',cookies});
assert.equal(get.statusCode,200,get.body);
assert.deepEqual(get.json().levels,{taal:{yearGroup:3,level:2},rekenen:{yearGroup:4,level:1}});
assert.deepEqual(get.json().levels,{taal:{yearGroup:3,level:2},rekenen:{yearGroup:4,level:1},creatief:{yearGroup:5,level:3}});
const put=await app.inject({method:'PUT',url:'/api/parent/children/30/level',cookies,
payload:{subject:'rekenen',yearGroup:5,level:3}});
assert.equal(put.statusCode,200,put.body);

View file

@ -49,12 +49,12 @@ test('klassenmanagement: klas-standaard opslaan doet de juiste upsert-query', as
const { app, calls } = await makeApp(admin);
const res = await app.inject({
method: 'PUT', url: '/api/levels/class/100', cookies,
payload: { subject: 'rekenen', yearGroup: 4, level: 2 },
payload: { subject: 'creatief', yearGroup: 4, level: 2 },
});
assert.equal(res.statusCode, 200, res.body);
const ins = calls.find(c => c.sql.startsWith('INSERT INTO pupil_levels'));
assert.ok(ins);
assert.deepEqual(ins.params, [100, 'rekenen', 4, 2]);
assert.deepEqual(ins.params, [100, 'creatief', 4, 2]);
});
test('klassenmanagement: ongeldig vakgebied geeft 400', async () => {
@ -123,17 +123,17 @@ test('klassenmanagement: leerkracht-rol wordt geweigerd op /my/level (alleen lee
assert.equal(res.statusCode, 403, res.body);
});
test('thuiskind kan eigen taalniveau opslaan, schoolleerling niet', async () => {
test('thuiskind kan eigen creatief niveau opslaan, schoolleerling niet', async () => {
const { app, calls } = await makeApp(homePupil);
const res = await app.inject({ method: 'PUT', url: '/api/my/level', cookies,
payload: { subject: 'taal', yearGroup: 5, level: 2 } });
payload: { subject: 'creatief', yearGroup: 5, level: 2 } });
assert.equal(res.statusCode, 200, res.body);
const insert = calls.find(call=>call.sql.startsWith('INSERT INTO pupil_levels'));
assert.deepEqual(insert.params,[10,'taal',5,2]);
assert.deepEqual(insert.params,[10,'creatief',5,2]);
const { app: schoolApp } = await makeApp(pupil);
const denied = await schoolApp.inject({ method: 'PUT', url: '/api/my/level', cookies,
payload: { subject: 'taal', yearGroup: 5, level: 2 } });
payload: { subject: 'creatief', yearGroup: 5, level: 2 } });
assert.equal(denied.statusCode,403,denied.body);
await app.close();await schoolApp.close();
});

View file

@ -324,11 +324,13 @@ test('letter- en cijferdoelvakken blijven gecentreerd en lange woorden blijven b
});
test('alle geregistreerde widgets hebben mobiele dekking en passen op een 320px-bord',async()=>{
const [board,pupil,css]=await Promise.all([
const [board,pupil,css,coloringCss]=await Promise.all([
readFile('public/js/board.js','utf8'),
readFile('public/js/pupil.js','utf8'),
readFile('public/css/teach.css','utf8'),
readFile('public/css/coloring.css','utf8'),
]);
const styles=css+coloringCss;
const registrySource=board.slice(board.indexOf('const REGISTRY'),board.indexOf('const WIDGET_CATS'));
const registered=[...registrySource.matchAll(/id:"([^"]+)"/g)].map(match=>match[1]).sort();
const coverage={
@ -353,6 +355,7 @@ test('alle geregistreerde widgets hebben mobiele dekking en passen op een 320px-
placevalue:'.place-grid',
money:'.money-task-card',
wordtypes:'.wordtypes',
coloring:'.coloring-paper',
write:'.wp-bar',
mind:'.mm-top',
notes:'.notes{',
@ -373,7 +376,7 @@ test('alle geregistreerde widgets hebben mobiele dekking en passen op een 320px-
};
assert.deepEqual(registered,Object.keys(coverage).sort(),'ieder geregistreerd widgettype staat in de mobiele audit');
for(const [id,selector] of Object.entries(coverage)){
assert.ok(css.includes(selector),id+' mist responsive/breedte-onafhankelijke opmaak');
assert.ok(styles.includes(selector),id+' mist responsive/breedte-onafhankelijke opmaak');
}
assert.match(css,/\.widget-body\{[^}]*container-type:inline-size[^}]*container-name:widget/);