e76485cadc
Полная инфраструктура: hub, 5 ch-скелетов, lab-скелет, миграция 039, расширение phys.js на 11 хелперов + 2 класса симуляций для новых тем 7-го класса. ФАЙЛЫ: - backend/src/db/migrations/039_physics_7_hub.sql — self-sufficient миграция (parent physics-7 + 6 children: ch1..ch5 + lab). Palette: sky/blue для hub, глав: indigo/violet/red/amber/emerald/cyan. - frontend/textbooks/physics_7_hub.html (862 строки) — hub с прогресс-картами 6 разделов, шпаргалкой курса в 5 mini-карточках, 10 интегрированных боссов финала курса (через ачивку «Магистр физики 7», +150 XP), темой/lang storage через ключи physics7_*. Sidebar-фикс на десктопе встроен. - frontend/textbooks/physics_7_ch1..ch5.html (350-390 строк каждый) — скелеты глав с header, paragraph selector, sidebar, прогресс/XP, goTo, search-модалом, KaTeX с delimiters, sidebar-фиксом, cache-busting ?v=20260530. Каждая глава имеет правильное число параграфов (7/6/14/8/7) + sec-finalN. - frontend/textbooks/physics_7_lab.html (306 строк) — скелет лаб. практикума на 6 ЛР с teal/cyan палитрой и ачивкой «Лаборант 7 класса» (+80 XP). - backend/scripts/gen_phys7_ch.js / gen_phys7_lab.js — генераторы из единого шаблона (для регенерации при правках инфраструктуры). PHYS.JS НОВЫЕ ХЕЛПЕРЫ (всё работает, smoke-test пройден): - forceVector(x,y,F,angle,color,label) — стрелка силы с подписью - dynamometer(x,y,h,Fmax,F) — динамометр с пружиной и шкалой - blockOnSurface(x,y,w,h,label,weights) — брусок со стопкой гирь - connectedVessels(x,y,kindA,kindB,levelY) — сообщающиеся сосуды - hydraulicPress(x,y,sSmall,sLarge,fSmall) — гидравлический пресс - mercuryBarometer(x,y,hMm) — ртутный барометр Торричелли - aneroidBarometer(cx,cy,r,p) — стрелочный барометр-анероид - uManometer(x,y,w,h,deltaH) — U-образный жидкостный манометр - rulerWithError(x,y,lenCm,mmPerDiv) — линейка со шкалой и ценой деления - bimetal(x,y,w,h,deltaT) — биметаллическая пластина (гнётся от ΔT) - expandingRod(x,y,l0,alpha,deltaT) — стержень с тепловым расширением - class HillSlideSim — тележка на горке (§42, закон сохранения; графики Ek/Ep/Etot) - class PendulumSim — математический маятник (§42, осцилляции) Все 13 экспортированы в window.PHYS, smoke-test показал физически разумные значения энергий. Parse-check + node --check проходят. Уроки phys 9 учтены сразу: cache-busting на phys.js, sidebar-фикс @media min-width:981px, delimiters для renderMathInElement. PHASE 0 DONE. Дальше: Phase 1 Wave 1 — §§1-2 (Физика как наука + Тело/явление/величина).
292 lines
18 KiB
JavaScript
292 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
||
// Генератор скелета лабораторного практикума Физики 7. Phase 0: только инфраструктура.
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const VER = '20260530';
|
||
const OUT = path.join(__dirname, '..', '..', 'frontend', 'textbooks', 'physics_7_lab.html');
|
||
|
||
const LABS = [
|
||
{ id:'lr1', num:'ЛР 1', title:'Определение цены деления шкалы измерительного прибора', wm:'1', tag:'§ 7' },
|
||
{ id:'lr2', num:'ЛР 2', title:'Измерение длины', wm:'2', tag:'§ 4 · § 7' },
|
||
{ id:'lr3', num:'ЛР 3', title:'Измерение объёма', wm:'3', tag:'§ 4' },
|
||
{ id:'lr4', num:'ЛР 4', title:'Изучение неравномерного движения', wm:'4', tag:'§ 18' },
|
||
{ id:'lr5', num:'ЛР 5', title:'Измерение плотности вещества', wm:'5', tag:'§ 20' },
|
||
{ id:'lr6', num:'ЛР 6', title:'Изучение силы трения', wm:'6', tag:'§ 27' },
|
||
];
|
||
|
||
const labsJs = LABS.map(l => `{id:'${l.id}',num:'${l.num}',title:${JSON.stringify(l.title)},wm:'${l.wm}',tag:'${l.tag}'}`).join(',');
|
||
const sections = LABS.map(l =>
|
||
` <section id="sec-${l.id}" class="sec" data-watermark="${l.wm}">
|
||
<div class="sec-header">
|
||
<span class="sec-num">${l.num}</span>
|
||
<h2 class="sec-h">${l.title}</h2>
|
||
<span class="sec-tag">${l.tag}</span>
|
||
</div>
|
||
<div id="${l.id}-body"><div class="placeholder">Виртуальная лабораторная работа появится в Phase 7 (после контента глав).</div></div>
|
||
</section>`).join('\n');
|
||
|
||
const html = `<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||
<meta http-equiv="Pragma" content="no-cache">
|
||
<meta http-equiv="Expires" content="0">
|
||
<title>Физика 7 · Лабораторный практикум</title>
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
||
<link rel="stylesheet" href="/css/phys-textbook-widgets.css">
|
||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
|
||
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false}],throwOnError:false})"></script>
|
||
<script src="/js/api.js" defer></script>
|
||
<script src="/js/xp.js" defer></script>
|
||
<script src="/js/phys.js?v=${VER}" defer></script>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@600;700;800;900&family=Unbounded:wght@700;800;900&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root{
|
||
--bg:#ecfeff; --card:#fff; --card-soft:#f8fafc; --text:#0f172a; --muted:#475569;
|
||
--border:#a5f3fc; --pri:#0891b2; --pri2:#0e7490; --pri-soft:#cffafe;
|
||
--acc:#06b6d4; --acc-d:#0e7490; --acc-soft:#cffafe;
|
||
--ok:#10b981; --ok-bg:#d1fae5; --fail:#dc2626; --fail-bg:#fee2e2; --warn:#f59e0b; --warn-bg:#fef3c7;
|
||
--sh:0 4px 16px rgba(8,145,178,.08); --sh-h:0 12px 36px rgba(8,145,178,.16);
|
||
}
|
||
html.dark{--bg:#0c2030;--card:#0e2436;--card-soft:#0b1a28;--text:#cffafe;--muted:#67e8f9;--border:#155e75;--pri-soft:rgba(8,145,178,.18)}
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
html,body{min-height:100vh}
|
||
body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;transition:background .25s,color .25s}
|
||
|
||
.hdr{position:relative;background:linear-gradient(135deg,#164e63,#0891b2 60%,#22d3ee);color:#fff;padding:24px 22px 22px;overflow:hidden;border-bottom:2px solid rgba(255,255,255,.18)}
|
||
.hdr-inner{position:relative;z-index:1;max-width:1240px;margin:0 auto;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
|
||
.hdr h1{font-family:'Unbounded',sans-serif;font-size:1.55rem;font-weight:800;letter-spacing:-.01em}
|
||
.hdr-sub{font-size:.88rem;opacity:.9;margin-top:3px}
|
||
.hdr-side{margin-left:auto;display:flex;gap:8px;flex-wrap:wrap}
|
||
.hdr-btn{padding:8px 12px;background:rgba(255,255,255,.16);border:none;color:#fff;border-radius:9px;cursor:pointer;font-weight:600;font-size:.82rem;display:inline-flex;align-items:center;gap:6px;transition:background .15s;font-family:inherit;text-decoration:none}
|
||
.hdr-btn:hover{background:rgba(255,255,255,.26)}
|
||
.ic{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
||
|
||
.main{max-width:1240px;margin:0 auto;padding:22px;width:100%;display:grid;grid-template-columns:1fr 280px;gap:24px}
|
||
@media(max-width:980px){.main{grid-template-columns:1fr;padding:14px}}
|
||
|
||
.col-main{min-width:0}
|
||
.psel{background:var(--card);border:1.5px solid var(--border);border-radius:14px;padding:16px;margin-bottom:18px;box-shadow:var(--sh)}
|
||
.psel-head{font-family:'Unbounded',sans-serif;font-size:.78rem;font-weight:800;color:var(--pri2);text-transform:uppercase;letter-spacing:.07em;margin-bottom:10px}
|
||
.psel-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:10px}
|
||
.psel-card{padding:12px;background:var(--card-soft);border:1.5px solid var(--border);border-radius:10px;cursor:pointer;transition:transform .15s,border-color .15s,box-shadow .15s;text-align:left}
|
||
.psel-card:hover{border-color:var(--acc);transform:translateY(-2px);box-shadow:0 4px 14px rgba(0,0,0,.06)}
|
||
.psel-card.active{border-color:var(--acc);background:var(--acc-soft)}
|
||
.psel-num{font-size:.7rem;font-weight:800;color:var(--acc-d);letter-spacing:.04em;text-transform:uppercase;margin-bottom:3px}
|
||
.psel-title{font-size:.86rem;font-weight:700;line-height:1.35}
|
||
.psel-prog{height:4px;background:rgba(0,0,0,.07);border-radius:3px;overflow:hidden;margin-top:7px}
|
||
.psel-prog-fill{height:100%;background:linear-gradient(90deg,var(--acc),var(--acc-d));border-radius:3px;transition:width .4s}
|
||
|
||
.sec{display:none;background:var(--card);border:1.5px solid var(--border);border-radius:14px;padding:22px;box-shadow:var(--sh);position:relative}
|
||
.sec.active{display:block}
|
||
.sec[data-watermark]::before{content:attr(data-watermark);position:absolute;right:18px;top:-8px;font-family:'Unbounded',sans-serif;font-size:5.6rem;font-weight:900;color:var(--acc-soft);pointer-events:none;line-height:1;user-select:none}
|
||
.sec-header{display:flex;align-items:baseline;gap:14px;margin-bottom:18px;padding-bottom:14px;border-bottom:1.5px solid var(--border);position:relative;z-index:1;flex-wrap:wrap}
|
||
.sec-num{background:linear-gradient(135deg,var(--acc),var(--acc-d));color:#fff;padding:5px 12px;border-radius:9px;font-family:'Unbounded',sans-serif;font-weight:800;font-size:.86rem;letter-spacing:.04em}
|
||
.sec-h{font-family:'Unbounded',sans-serif;font-size:1.3rem;font-weight:800;color:var(--text);flex:1;min-width:0}
|
||
.sec-tag{font-size:.74rem;font-weight:700;color:var(--pri2);background:var(--pri-soft);padding:3px 9px;border-radius:99px;text-transform:uppercase;letter-spacing:.04em}
|
||
.placeholder{padding:32px 20px;text-align:center;color:var(--muted);font-size:.95rem;background:var(--card-soft);border:1.5px dashed var(--border);border-radius:10px}
|
||
|
||
.col-side{position:sticky;top:14px;align-self:start;height:fit-content;max-height:calc(100vh - 28px);overflow-y:auto}
|
||
.sidecard{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:16px;margin-bottom:14px;box-shadow:var(--sh)}
|
||
.sidecard h4{font-family:'Unbounded',sans-serif;font-size:.74rem;font-weight:800;color:var(--pri2);text-transform:uppercase;letter-spacing:.07em;margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid var(--border)}
|
||
.sidecard-row{margin-bottom:8px;font-size:.86rem;line-height:1.6}
|
||
.sidecard-row b{color:var(--pri);font-weight:700}
|
||
@media(max-width:980px){.col-side{position:static;max-height:none}}
|
||
|
||
.col-side-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.42);z-index:9990;display:none}
|
||
.col-side-backdrop.show{display:block}
|
||
@media(min-width:981px){#sidebar-btn{display:none}.col-side-backdrop.show{display:none}}
|
||
@media(max-width:980px){
|
||
.col-side{position:fixed;top:0;right:0;height:100vh;width:300px;max-width:88vw;background:var(--bg);box-shadow:-12px 0 24px rgba(0,0,0,.18);padding:18px 16px;overflow-y:auto;transform:translateX(100%);transition:transform .25s ease;z-index:9991;max-height:none}
|
||
.col-side.open{transform:none}
|
||
}
|
||
|
||
.ach-popup{position:fixed;top:80px;right:18px;background:linear-gradient(135deg,var(--acc-d),var(--acc));color:#fff;padding:12px 18px;border-radius:11px;font-weight:700;font-size:.9rem;box-shadow:0 8px 28px rgba(0,0,0,.25);z-index:1002;display:none;align-items:center;gap:8px;max-width:340px}
|
||
.ach-popup.show{display:flex}
|
||
|
||
.foot{text-align:center;padding:30px 16px;color:var(--muted);font-size:.78rem;border-top:1px solid var(--border);margin-top:30px}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header class="hdr">
|
||
<div class="hdr-inner">
|
||
<div>
|
||
<a href="/textbook/physics-7" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg> К физике 7</a>
|
||
</div>
|
||
<div>
|
||
<h1>Физика 7 · Лабораторный практикум</h1>
|
||
<div class="hdr-sub">6 виртуальных лабораторных работ</div>
|
||
</div>
|
||
<div class="hdr-side">
|
||
<button id="sidebar-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="14" y2="18"/></svg> Шпаргалка</button>
|
||
<button id="theme-btn" class="hdr-btn"><svg class="ic" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg><span id="theme-lab">Тёмная</span></button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="main">
|
||
<div class="col-main">
|
||
<div class="psel">
|
||
<div class="psel-head">Лабораторные работы</div>
|
||
<div class="psel-grid" id="psel-grid"></div>
|
||
</div>
|
||
|
||
${sections}
|
||
</div>
|
||
|
||
<aside class="col-side" id="col-side"><div id="sidebar-content"></div></aside>
|
||
<div class="col-side-backdrop" id="col-side-backdrop"></div>
|
||
</main>
|
||
|
||
<div class="ach-popup" id="ach-popup"><svg class="ic" viewBox="0 0 24 24"><polygon points="12,2 15,9 22,9.3 17,14 18.5,21 12,17 5.5,21 7,14 2,9.3 9,9"/></svg><span id="ach-text"></span></div>
|
||
|
||
<footer class="foot">Интерактивный учебник «Физика 7 класс» · Лабораторный практикум · LearnSpace</footer>
|
||
|
||
<script>
|
||
'use strict';
|
||
|
||
const LS_PREFIX = 'physics7_lab';
|
||
const _TB_SLUG = 'physics-7-lab';
|
||
|
||
const LABS = [${labsJs}];
|
||
const TOTAL_LABS = LABS.length;
|
||
|
||
const SIDEBARS = {};
|
||
LABS.forEach(l => { SIDEBARS[l.id] = { title: 'Шпаргалка ' + l.num, rows: [['В разработке','симуляция и таблицы измерений появятся в Phase 7']] }; });
|
||
const ACH_LABELS = { start: 'Начало практикума', all_labs: 'Лаборант 7 класса' };
|
||
|
||
const STATE = { current: null, progress: {}, xp: 0, level: 1, achievements: new Map() };
|
||
|
||
function _xpForLevel(lv){ return Math.round(100 * Math.pow(lv-1, 1.6)); }
|
||
function calcLevel(xp){ let lv = 1; while(_xpForLevel(lv+1) <= xp) lv++; return lv; }
|
||
|
||
function loadProgress(){
|
||
try{
|
||
const s = localStorage.getItem(LS_PREFIX + '_progress'); if(s) Object.assign(STATE.progress, JSON.parse(s));
|
||
const a = localStorage.getItem(LS_PREFIX + '_achievements');
|
||
if(a){ const p = JSON.parse(a); if(p && typeof p === 'object') for(const [id,t] of Object.entries(p)) STATE.achievements.set(id, (t && t !== id) ? t : (ACH_LABELS[id] || id)); }
|
||
STATE.xp = +(localStorage.getItem('physics7_xp') || 0); STATE.level = calcLevel(STATE.xp);
|
||
}catch(e){}
|
||
}
|
||
function saveProgress(){
|
||
try{
|
||
localStorage.setItem(LS_PREFIX + '_progress', JSON.stringify(STATE.progress));
|
||
localStorage.setItem(LS_PREFIX + '_achievements', JSON.stringify(Object.fromEntries(STATE.achievements)));
|
||
localStorage.setItem('physics7_xp', String(STATE.xp));
|
||
}catch(e){}
|
||
}
|
||
function bumpProgress(key, delta){
|
||
STATE.progress[key] = Math.max(0, Math.min(100, (STATE.progress[key]||0) + delta));
|
||
saveProgress(); refreshProgressUI();
|
||
const done = LABS.every(l => (STATE.progress[l.id]||0) >= 100);
|
||
if(done && !STATE.achievements.has('all_labs')) achievement('all_labs');
|
||
}
|
||
function addXp(n, src){
|
||
if(!n) return;
|
||
STATE.xp = Math.max(0, (STATE.xp||0) + n); STATE.level = calcLevel(STATE.xp);
|
||
saveProgress(); refreshProgressUI();
|
||
if(window.LS && window.LS.xp) window.LS.xp.add(n, 'physics7-lab-' + (src||'misc'));
|
||
}
|
||
function achievement(id, label){
|
||
if(STATE.achievements.has(id)) return;
|
||
STATE.achievements.set(id, label || ACH_LABELS[id] || id);
|
||
saveProgress();
|
||
const pop = document.getElementById('ach-popup');
|
||
if(pop){ document.getElementById('ach-text').textContent = 'Ачивка: ' + (label || ACH_LABELS[id] || id); pop.classList.add('show'); setTimeout(()=>pop.classList.remove('show'), 3000); }
|
||
addXp(id === 'all_labs' ? 80 : 20, 'ach-' + id);
|
||
}
|
||
|
||
function refreshProgressUI(){
|
||
document.querySelectorAll('[data-prog-card]').forEach(el => {
|
||
const k = el.dataset.progCard;
|
||
const fl = el.querySelector('.psel-prog-fill');
|
||
if(fl) fl.style.width = (STATE.progress[k]||0) + '%';
|
||
});
|
||
if(STATE.current && document.getElementById('sidebar-content')){ try{ buildSidebar(STATE.current); }catch(e){} }
|
||
}
|
||
|
||
function buildSelector(){
|
||
const grid = document.getElementById('psel-grid');
|
||
if(!grid) return;
|
||
grid.innerHTML = LABS.map(l =>
|
||
'<button class="psel-card" data-id="' + l.id + '" data-prog-card="' + l.id + '">'
|
||
+ '<div class="psel-num">' + l.num + ' · ' + l.tag + '</div>'
|
||
+ '<div class="psel-title">' + l.title + '</div>'
|
||
+ '<div class="psel-prog"><div class="psel-prog-fill" style="width:' + (STATE.progress[l.id]||0) + '%"></div></div>'
|
||
+ '</button>'
|
||
).join('');
|
||
grid.querySelectorAll('.psel-card').forEach(c => c.addEventListener('click', () => goTo(c.dataset.id)));
|
||
}
|
||
|
||
function goTo(id){
|
||
STATE.current = id;
|
||
document.querySelectorAll('.sec').forEach(s => s.classList.remove('active'));
|
||
const el = document.getElementById('sec-' + id); if(el) el.classList.add('active');
|
||
document.querySelectorAll('.psel-card').forEach(c => c.classList.toggle('active', c.dataset.id === id));
|
||
buildSidebar(id);
|
||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||
if((STATE.progress[id]||0) < 10) bumpProgress(id, 10);
|
||
}
|
||
|
||
function buildSidebar(id){
|
||
const box = document.getElementById('sidebar-content');
|
||
if(!box) return;
|
||
const sb = SIDEBARS[id] || SIDEBARS[LABS[0].id];
|
||
const xpForLv = _xpForLevel(STATE.level), xpNext = _xpForLevel(STATE.level+1);
|
||
const xpPct = (xpNext - xpForLv) > 0 ? Math.round((STATE.xp - xpForLv) / (xpNext - xpForLv) * 100) : 100;
|
||
let html = '';
|
||
html += '<div class="sidecard" style="background:linear-gradient(135deg,var(--pri-soft),var(--acc-soft));border-color:var(--acc)"><h4>XP-прогресс <span style="float:right">Ур. ' + STATE.level + '</span></h4><div class="sidecard-row"><div style="height:8px;background:rgba(0,0,0,.07);border-radius:5px;overflow:hidden"><div style="height:100%;background:linear-gradient(90deg,var(--acc),var(--pri));width:' + xpPct + '%"></div></div><div style="display:flex;justify-content:space-between;font-size:.78rem;color:var(--muted);margin-top:5px"><span>' + STATE.xp + ' XP</span><span>' + xpNext + ' XP</span></div></div></div>';
|
||
html += '<div class="sidecard"><h4>' + sb.title + '</h4>';
|
||
sb.rows.forEach(([k,v]) => { html += '<div class="sidecard-row"><b>' + k + '</b>' + (v ? ' — ' + v : '') + '</div>'; });
|
||
html += '</div>';
|
||
if(STATE.achievements.size > 0){
|
||
html += '<div class="sidecard"><h4>Достижения <span style="color:var(--warn);float:right">' + STATE.achievements.size + '</span></h4>';
|
||
[...STATE.achievements.values()].slice(-4).forEach(t => { html += '<div class="sidecard-row" style="font-size:.78rem;color:var(--ok)">✓ ' + t + '</div>'; });
|
||
html += '</div>';
|
||
}
|
||
box.innerHTML = html;
|
||
}
|
||
|
||
function initTheme(){
|
||
const t = localStorage.getItem(LS_PREFIX + '_theme') || localStorage.getItem('physics7_theme') || 'light';
|
||
if(t === 'dark') document.documentElement.classList.add('dark');
|
||
document.getElementById('theme-lab').textContent = t === 'dark' ? 'Светлая' : 'Тёмная';
|
||
document.getElementById('theme-btn').addEventListener('click', () => {
|
||
document.documentElement.classList.toggle('dark');
|
||
const dark = document.documentElement.classList.contains('dark');
|
||
localStorage.setItem(LS_PREFIX + '_theme', dark ? 'dark' : 'light');
|
||
localStorage.setItem('physics7_theme', dark ? 'dark' : 'light');
|
||
document.getElementById('theme-lab').textContent = dark ? 'Светлая' : 'Тёмная';
|
||
});
|
||
}
|
||
|
||
function initSidebarToggle(){
|
||
const side = document.getElementById('col-side'), back = document.getElementById('col-side-backdrop'), btn = document.getElementById('sidebar-btn');
|
||
if(!side || !btn) return;
|
||
function open(){ side.classList.add('open'); back.classList.add('show'); }
|
||
function close(){ side.classList.remove('open'); back.classList.remove('show'); }
|
||
btn.addEventListener('click', () => { if(side.classList.contains('open')) close(); else open(); });
|
||
back.addEventListener('click', close);
|
||
document.addEventListener('keydown', e => { if(e.key === 'Escape') close(); });
|
||
}
|
||
|
||
function init(){
|
||
loadProgress(); initTheme(); initSidebarToggle();
|
||
buildSelector(); refreshProgressUI(); goTo(LABS[0].id);
|
||
setTimeout(() => achievement('start'), 600);
|
||
}
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
</script>
|
||
</body>
|
||
</html>
|
||
`;
|
||
|
||
fs.writeFileSync(OUT, html, 'utf8');
|
||
console.log(`[gen_phys7_lab] ${OUT} — ${html.split('\n').length} lines`);
|