1import { getThemeVars } from './theme_vars.js'; 2 3// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4// EXPERTLE â Gear Check Template 5// PPE selection game with timer. Task cards flash, pick the right gear. 6// JSON in â HTML out. Dark course theme. 7// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 8 9export function wrapGearCheckData(sectionJson, options = {}) { 10 const theme = options.theme || 'dark'; 11 const d = sectionJson; 12 const roundsJson = JSON.stringify(d.rounds || []).replace(/</g, '\\x3c'); 13 const ppeJson = JSON.stringify(d.ppe_items || []).replace(/</g, '\\x3c'); 14 15 return `<!DOCTYPE html> 16<html lang="en-AU"> 17<head> 18 <meta charset="UTF-8"> 19 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 20 <title>${esc(d.title)} â ${esc(d.unitCode || '')}</title> 21 <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"> 22 <style>${GAME_CSS(theme)} 23.ppe-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 10px; margin: 20px 0; } 24.ppe-item { background: var(--bg-card); border: 2px solid var(--border-default); border-radius: 10px; padding: 14px 10px; text-align: center; cursor: pointer; transition: all 0.2s; user-select: none; } 25.ppe-item:hover { border-color: var(--accent-primary); background: var(--bg-card-hover); } 26.ppe-item.selected { border-color: var(--accent-secondary); background: rgba(59,130,246,0.15); } 27.ppe-item.correct { border-color: var(--accent-success); background: rgba(34,197,94,0.15); } 28.ppe-item.missed { border-color: var(--accent-warning); background: rgba(249,115,22,0.15); } 29.ppe-item.extra { border-color: var(--text-muted); background: rgba(107,115,148,0.1); } 30.ppe-icon { font-size: 2rem; margin-bottom: 6px; } 31.ppe-label { font-size: 0.8rem; font-weight: 600; } 32.task-card { background: var(--bg-card); border: 1px solid var(--accent-primary); border-radius: 12px; padding: 20px; text-align: center; margin-bottom: 16px; } 33.task-card h3 { color: var(--accent-primary); margin-bottom: 8px; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 1px; } 34.task-card p { font-size: 1.1rem; font-weight: 600; } 35.timer-bar { width: 100%; height: 8px; background: var(--bg-card); border-radius: 4px; margin-bottom: 20px; overflow: hidden; } 36.timer-fill { height: 100%; background: var(--accent-primary); border-radius: 4px; transition: width 1s linear; } 37.timer-fill.urgent { background: var(--accent-error); } 38.round-feedback { margin-top: 16px; } 39.feedback-item { background: var(--bg-card); border-radius: 8px; padding: 10px 14px; margin-bottom: 8px; font-size: 0.85rem; } 40.feedback-item.missed-fb { border-left: 3px solid var(--accent-warning); } 41.feedback-item.extra-fb { border-left: 3px solid var(--text-muted); } 42 </style> 43</head> 44<body> 45 <div class="section-wrapper"> 46 <header class="section-header"> 47 <div class="section-badge">Gear Check</div> 48 <h1 class="section-title">${esc(d.title)}</h1> 49 <p class="section-instruction">${esc(d.description || 'Select the correct PPE for each task')}</p> 50 </header> 51 <div class="timer-bar"><div class="timer-fill" id="timer-fill" style="width:100%"></div></div> 52 <main class="game-area"> 53 <div class="task-card" id="task-card"><h3>Round <span id="round-num">1</span> of <span id="round-total">?</span></h3><p id="task-text">Loading...</p></div> 54 <div class="ppe-grid" id="ppe-grid"></div> 55 <div style="text-align:center;"><button class="btn btn-primary" id="lock-in-btn" disabled>Lock In</button></div> 56 <div class="round-feedback hidden" id="round-feedback"></div> 57 <div class="hidden" id="next-round-wrap" style="text-align:center; margin-top:16px;"><button class="btn btn-primary" id="next-round-btn">Next Round â</button></div> 58 </main> 59 <footer class="section-footer"> 60 <div class="score-display"><span class="score-label">Score</span><span class="score-value" id="score-value">0</span><span class="score-max">/ 100</span></div>
61 <button class="btn-complete" id="btn-complete" disabled>Complete Section</button> 62 </footer> 63 </div> 64 <script> 65const PPE_ITEMS = ${ppeJson}; 66const ROUNDS = ${roundsJson}; 67const TIME_PER_ROUND = ${d.time_per_round_seconds || 15}; 68const SECTION_DATA = { sectionId: "${esc(d.sectionId || '')}", score: 0, maxScore: 100, completed: false, startTime: Date.now() }; 69setInterval(() => { window.parent.postMessage({ type:'progressSnapshot', xp:0, score:SECTION_DATA.score||0 }, '*'); }, 10000); 70 71const PPE_ICONS = { helmet:'ðª', glasses:'ð¥½', earmuffs:'ð§', boots:'ð¥¾', vest:'ð¦º', gloves:'ð§¤', gloves_chem:'ð§¤', mask:'ð·', shield:'ð¡ï¸', harness:'ðª¢' }; 72 73let currentRound = 0; 74let selected = new Set(); 75let timerInterval = null; 76 77document.getElementById('round-total').textContent = ROUNDS.length; 78 79function startRound() { 80 if (currentRound >= ROUNDS.length) { endGame(); return; } 81 const round = ROUNDS[currentRound]; 82 selected = new Set(); 83 document.getElementById('round-num').textContent = currentRound + 1; 84 document.getElementById('task-text').textContent = round.task; 85 document.getElementById('round-feedback').classList.add('hidden'); 86 document.getElementById('round-feedback').innerHTML = ''; 87 document.getElementById('next-round-wrap').classList.add('hidden'); 88 document.getElementById('lock-in-btn').disabled = false; 89 90 const grid = document.getElementById('ppe-grid'); 91 grid.innerHTML = PPE_ITEMS.map(p => '<div class="ppe-item" data-id="' + p.id + '" tabindex="0" role="button" aria-label="' + p.label + '">' + (p.image_url ? '<img src="' + p.image_url + '" style="width:40px;height:40px;object-fit:cover;border-radius:6px;" alt="' + p.label + '">' : '<div class="ppe-icon">' + (PPE_ICONS[p.icon] || 'âï¸') + '</div>') + '<div class="ppe-label">' + p.label + '</div></div>').join(''); 92 grid.querySelectorAll('.ppe-item').forEach(el => { 93 el.addEventListener('click', () => togglePpe(el)); 94 el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePpe(el); } }); 95 }); 96 97 startTimer(); 98} 99 100function togglePpe(el) { 101 const id = el.dataset.id; 102 if (selected.has(id)) { selected.delete(id); el.classList.remove('selected'); } 103 else { selected.add(id); el.classList.add('selected'); } 104} 105 106function startTimer() { 107 const fill = document.getElementById('timer-fill'); 108 fill.style.width = '100%'; 109 fill.classList.remove('urgent'); 110 let remaining = TIME_PER_ROUND; 111 clearInterval(timerInterval); 112 timerInterval = setInterval(() => { 113 remaining--; 114 fill.style.width = (remaining / TIME_PER_ROUND * 100) + '%'; 115 if (remaining <= 3) fill.classList.add('urgent'); 116 if (remaining <= 0) { clearInterval(timerInterval); checkRound(); } 117 }, 1000); 118} 119 120document.getElementById('lock-in-btn').addEventListener('click', () => { clearInterval(timerInterval); checkRound(); }); 121 122function checkRound() { 123 document.getElementById('lock-in-btn').disabled = true; 124 const round = ROUNDS[currentRound]; 125 const required = new Set(round.required_ppe); 126 let correct = 0, missed = [], extras = []; 127 128 document.querySelectorAll('.ppe-item').forEach(el => { 129 const id = el.dataset.id; 130 const wasSelected = selected.has(id); 131 const isRequired = required.has(id); 132 if (wasSelected && isRequired) { el.classList.add('correct'); correct++; window.parent.postMessage({ type:'answerResult', id:id, label:(PPE_ITEMS.find(p=>p.id===id)?.label||id), correct:true }, '*'); } 133 else if (!wasSelected && isRequired) { el.classList.add('missed'); missed.push(id); window.parent.postMessage({ type:'answerResult', id:id, label:(PPE_ITEMS.find(p=>p.id===id)?.label||id), correct:false }, '*'); } 134 else if (wasSelected && !isRequired) { el.classList.add('extra'); extras.push(id); window.parent.postMessage({ type:'answerResult', id:id, label:(PPE_ITEMS.find(p=>p.id===id)?.label||id), correct:false }, '*'); } 135 el.style.pointerEvents = 'none'; 136 }); 137 138 const perfect = missed.length === 0 && extras.length === 0; 139 const xp = perfect ? (${d.xp_per_perfect_round || 25}) : (correct > 0 ? ${d.xp_per_partial_round || 10} : 0); 140 updateScore(xp); 141 142 let fbHtml = ''; 143 if (perfect) fbHtml = '<div class="feedback-item" style="border-left:3px solid var(--accent-success);">â Perfect! All correct PPE selected.</div>'; 144 missed.forEach(id => { 145 const fb = round.feedback_missed?.[id] || 'This item was required.';
146 const label = PPE_ITEMS.find(p => p.id === id)?.label || id; 147 fbHtml += '<div class="feedback-item missed-fb">â ï¸ <strong>Missed: ' + label + '</strong> â ' + fb + '</div>'; 148 }); 149 extras.forEach(id => { 150 const fb = round.feedback_extra?.[id] || 'This item was not needed.'; 151 const label = PPE_ITEMS.find(p => p.id === id)?.label || id; 152 fbHtml += '<div class="feedback-item extra-fb">â¹ï¸ <strong>Extra: ' + label + '</strong> â ' + fb + '</div>'; 153 }); 154 155 document.getElementById('round-feedback').innerHTML = fbHtml; 156 document.getElementById('round-feedback').classList.remove('hidden'); 157 document.getElementById('next-round-wrap').classList.remove('hidden'); 158} 159 160document.getElementById('next-round-btn').addEventListener('click', () => { currentRound++; startRound(); }); 161 162function updateScore(points) { 163 SECTION_DATA.score = Math.min(SECTION_DATA.score + points, 100); 164 document.getElementById('score-value').textContent = SECTION_DATA.score; 165} 166 167function endGame() { 168 document.getElementById('task-card').innerHTML = '<h3>Complete!</h3><p>You scored ' + SECTION_DATA.score + ' / 100</p>'; 169 document.getElementById('ppe-grid').innerHTML = ''; 170 document.getElementById('next-round-wrap').classList.add('hidden'); 171 SECTION_DATA.completed = true; 172 SECTION_DATA.timeSpent = Math.round((Date.now() - SECTION_DATA.startTime) / 1000); 173 document.getElementById('btn-complete').disabled = false; 174} 175 176document.getElementById('btn-complete').addEventListener('click', () => { 177 window.parent.postMessage({ type: 'sectionComplete', sectionId: SECTION_DATA.sectionId, score: SECTION_DATA.score, maxScore: 100 }, '*'); 178 showSectionCompleteOverlay(SECTION_DATA.score); 179}); 180 181startRound(); 182 183// ââ Course context (received from hub world) ââ 184var COURSE_CTX = { sectionNumber: 0, totalSections: 0 }; 185window.addEventListener('message', function(e) { 186 if (e.data && e.data.type === 'courseContext') { 187 COURSE_CTX.sectionNumber = e.data.sectionNumber || 0; 188 COURSE_CTX.totalSections = e.data.totalSections || 0; 189 } 190}); 191 192function showSectionCompleteOverlay(score) { 193 var ov = document.createElement('div'); 194 ov.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(15,17,23,0.95);display:flex;align-items:center;justify-content:center;z-index:9999;'; 195 var inner = document.createElement('div'); 196 inner.style.cssText = 'text-align:center;color:#fff;max-width:400px;padding:32px;'; 197 inner.innerHTML = '<h2 style="color:#8b5cf6;margin:0 0 12px;">Section Complete!</h2><p style="font-size:48px;font-weight:700;color:' + (score >= 80 ? '#22c55e' : '#f59e0b') + ';margin:0 0 8px;">' + (score || 0) + '%</p>'; 198 var btnWrap = document.createElement('div'); 199 btnWrap.style.cssText = 'display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin-top:24px;'; 200 if (COURSE_CTX.sectionNumber > 0 && COURSE_CTX.sectionNumber < COURSE_CTX.totalSections) { 201 var nextBtn = document.createElement('button'); 202 nextBtn.textContent = 'Next Section \u2192'; 203 nextBtn.style.cssText = 'background:#8b5cf6;color:#fff;border:none;padding:12px 24px;border-radius:8px;font-size:15px;cursor:pointer;'; 204 nextBtn.addEventListener('click', function() { window.parent.postMessage({type:'loadNextSection'}, '*'); }); 205 btnWrap.appendChild(nextBtn); 206 } 207 var backBtn = document.createElement('button'); 208 backBtn.textContent = 'Back to Hub'; 209 backBtn.style.cssText = 'background:#2a2a3e;color:#fff;border:1px solid #444;padding:12px 24px;border-radius:8px;font-size:15px;cursor:pointer;'; 210 backBtn.addEventListener('click', function() { window.parent.postMessage({type:'returnToHub'}, '*'); }); 211 btnWrap.appendChild(backBtn); 212 inner.appendChild(btnWrap); 213 ov.appendChild(inner); 214 document.body.appendChild(ov); 215} 216 </script> 217</body> 218</html>`; 219} 220 221function esc(s) { return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); } 222 223const GAME_CSS = (theme) => ` 224${getThemeVars(theme)} 225* { box-sizing:border-box; margin:0; padding:0; } 226body { font-family:'DM Sans',sans-serif; font-size:16px; line-height:1.6; color:var(--text-primary); background:var(--bg-primary); } 227.section-wrapper { max-width:900px; margin:0 auto; padding:24px 16px; min-height:100vh; display:flex; flex-direction:column; } 228.section-header { text-align:center; margin-bottom:24px; } 229.section-badge { display:inline-block; background:var(--bg-card); border:1px solid var(--border-default); border-radius:20px; padding:4px 16px; font-size:0.8rem; color:var(--text-secondary); margin-bottom:12px; } 230.section-title { font-size:1.75rem; font-weight:700; } 231.section-instruction { color:var(--text-secondary); font-size:1rem; max-width:600px; margin:0 auto; } 232.game-area { flex:1; } 233.section-footer { display:flex; justify-content:space-between; align-items:center; margin-top:32px; padding-top:24px; border-top:1px solid var(--border-default); } 234.score-display { display:flex; align-items:baseline; gap:6px; } 235.score-label { font-size:0.85rem; color:var(--text-secondary); } 236.score-value { font-size:1.5rem; font-weight:700; color:var(--accent-primary); } 237.score-max { font-size:0.85rem; color:var(--text-muted); } 238.btn { font-family:'DM Sans',sans-serif; font-size:1rem; font-weight:600; padding:12px 24px; border-radius:8px; border:none; cursor:pointer; transition:all 0.2s; } 239.btn-primary { background:var(--accent-primary); color:#000; } 240.btn-primary:hover { background:#d97706; } 241.btn-primary:disabled { background:var(--bg-card); color:var(--text-muted); cursor:not-allowed; } 242.btn-complete { background:var(--accent-success); color:#fff; font-size:1rem; font-weight:600; padding:12px 24px; border-radius:8px; border:none; cursor:pointer; } 243.btn-complete:disabled { background:var(--bg-card); color:var(--text-muted); cursor:not-allowed; } 244.hidden { display:none!important; } 245*:focus-visible { outline:2px solid var(--accent-primary); outline-offset:2px; } 246@media (prefers-reduced-motion:reduce) { *,*::before,*::after { animation-duration:0.01ms!important; transition-duration:0.01ms!important; } } 247`;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.