diff --git a/backend/tests/chemistry8-page.test.js b/backend/tests/chemistry8-page.test.js
index 2d0e30e..34a4e0c 100644
--- a/backend/tests/chemistry8-page.test.js
+++ b/backend/tests/chemistry8-page.test.js
@@ -142,6 +142,25 @@ test('ch5: SPA без ошибок, 5 карточек, §42 активен, с.
assert.ok(doc.querySelector('#c-redox-pick option'), 'электронный баланс §44');
});
+/* ── Глоссарий (U2/Phase 8) ── */
+test('glossary: кнопка, модалка, авто-подсветка терминов', async () => {
+ const src = readF('frontend/js/chem8_glossary.js');
+ const dom = new JSDOM('
Оксид — это сложное вещество. Кислота реагирует с основанием в реакции нейтрализации.
',
+ { runScripts: 'outside-only', pretendToBeVisual: true, url: 'http://localhost/' });
+ new Function('window', src)(dom.window);
+ await wait(20);
+ const doc = dom.window.document;
+ assert.ok(dom.window.Chem8Glossary, 'window.Chem8Glossary определён');
+ assert.ok(Object.keys(dom.window.Chem8Glossary.terms).length > 40, '>40 терминов');
+ assert.ok(doc.querySelector('.gl-fab'), 'плавающая кнопка глоссария');
+ // авто-подсветка терминов в .card-body
+ assert.ok(doc.querySelectorAll('.card-body .gloss').length >= 2, 'термины подсвечены: ' + doc.querySelectorAll('.gloss').length);
+ // открытие модалки
+ dom.window.Chem8Glossary.open();
+ assert.ok(doc.querySelector('.gl-modal.show'), 'модалка открыта');
+ assert.ok(doc.querySelectorAll('.gl-modal .gl-item').length > 40, 'список терминов в модалке');
+});
+
/* ── Хаб: финал курса (Phase 7) ── */
function buildHub() {
let html = readF('frontend/textbooks/chemistry_8_hub.html');
diff --git a/frontend/js/chem8_glossary.js b/frontend/js/chem8_glossary.js
new file mode 100644
index 0000000..fff3dc7
--- /dev/null
+++ b/frontend/js/chem8_glossary.js
@@ -0,0 +1,183 @@
+/* chem8_glossary.js — глоссарий учебника «Химия 8».
+ * Самодостаточный drop-in: словарь терминов + плавающая кнопка + модалка с поиском
+ * + авто-подсветка терминов в .card-body (tooltip с определением). Стили инжектятся.
+ * Подключается одним тегом .
+ */
+(function (W) {
+ 'use strict';
+ var D = W.document;
+
+ /* словарь: термин → {d: определение, see: [связанные]} */
+ var G = {
+ 'атом': { d: 'Мельчайшая химически неделимая частица вещества: ядро (протоны и нейтроны) + электроны.', see: ['химический элемент', 'нуклид'] },
+ 'химический элемент': { d: 'Вид атомов с одинаковым зарядом ядра (числом протонов).', see: ['атом'] },
+ 'относительная атомная масса': { d: 'Безразмерная величина $A_r$ — во сколько раз масса атома больше 1/12 массы атома углерода-12.', see: ['относительная молекулярная масса'] },
+ 'относительная молекулярная масса': { d: 'Сумма относительных атомных масс всех атомов в формуле ($M_r$).', see: ['молярная масса'] },
+ 'простое вещество': { d: 'Вещество из атомов одного элемента (O₂, Fe).', see: ['сложное вещество'] },
+ 'сложное вещество': { d: 'Вещество из атомов разных элементов (H₂O, CaCO₃).', see: ['простое вещество'] },
+ 'химическая формула': { d: 'Запись состава вещества символами элементов с индексами.', see: [] },
+ 'химическое количество': { d: 'Физическая величина $n$ (порция вещества), измеряется в молях.', see: ['моль', 'постоянная Авогадро'] },
+ 'моль': { d: 'Единица химического количества: содержит $6{,}02\\cdot10^{23}$ частиц (число Авогадро).', see: ['постоянная Авогадро'] },
+ 'постоянная Авогадро': { d: '$N_A = 6{,}02\\cdot10^{23}$ частиц/моль — число частиц в 1 моль.', see: ['моль'] },
+ 'молярная масса': { d: 'Масса 1 моль вещества $M$ (г/моль); численно равна $M_r$.', see: ['относительная молекулярная масса'] },
+ 'молярный объём': { d: 'Объём 1 моль газа; при н.у. $V_m = 22{,}4$ л/моль.', see: [] },
+ 'оксид': { d: 'Сложное вещество из элемента и кислорода (с.о. −2): основный, кислотный, амфотерный, несолеобразующий.', see: ['основный оксид', 'кислотный оксид'] },
+ 'основный оксид': { d: 'Оксид металла, реагирует с кислотами (CaO, Na₂O).', see: ['оксид'] },
+ 'кислотный оксид': { d: 'Оксид неметалла, реагирует со щелочами (CO₂, SO₃).', see: ['оксид'] },
+ 'амфотерность': { d: 'Способность вещества проявлять и кислотные, и основные свойства (Zn(OH)₂, Al(OH)₃).', see: ['оксид', 'основание'] },
+ 'кислота': { d: 'Вещество с атомами водорода, способными замещаться металлом, и кислотным остатком.', see: ['основность'] },
+ 'основность': { d: 'Число атомов водорода в кислоте, способных замещаться металлом.', see: ['кислота'] },
+ 'основание': { d: 'Вещество из металла и гидроксогрупп OH; растворимые — щёлочи.', see: ['щёлочь', 'нейтрализация'] },
+ 'щёлочь': { d: 'Растворимое в воде основание (NaOH, KOH, Ba(OH)₂).', see: ['основание'] },
+ 'соль': { d: 'Вещество из катионов металла и анионов кислотного остатка (NaCl, CaCO₃).', see: ['реакция ионного обмена'] },
+ 'нейтрализация': { d: 'Реакция кислоты с основанием: соль + вода.', see: ['кислота', 'основание'] },
+ 'индикатор': { d: 'Вещество, меняющее окраску в зависимости от среды (лакмус, фенолфталеин, метилоранж).', see: [] },
+ 'реакция ионного обмена': { d: 'Реакция между растворами, идущая до конца при образовании осадка ↓, газа ↑ или воды.', see: ['соль', 'растворимость'] },
+ 'ряд активности металлов': { d: 'Ряд металлов по убыванию химической активности; металл вытесняет менее активные.', see: [] },
+ 'генетическая связь': { d: 'Связь между классами веществ через цепочки превращений (металл→оксид→основание→соль).', see: [] },
+ 'периодический закон': { d: 'Свойства элементов периодически зависят от заряда ядра их атомов (Д. И. Менделеев, 1869).', see: ['периодическая система'] },
+ 'периодическая система': { d: 'Таблица элементов: периоды (строки) и группы (столбцы).', see: ['период', 'группа'] },
+ 'период': { d: 'Горизонтальный ряд в ПСХЭ; номер = число электронных слоёв.', see: ['периодическая система'] },
+ 'группа': { d: 'Вертикальный столбец ПСХЭ; номер = число внешних электронов.', see: ['периодическая система'] },
+ 'нуклид': { d: 'Вид атомов с определёнными Z (протоны) и N (нейтроны).', see: ['изотопы', 'массовое число'] },
+ 'массовое число': { d: 'Число протонов и нейтронов в ядре: $A = Z + N$.', see: ['нуклид'] },
+ 'изотопы': { d: 'Атомы одного элемента с разным числом нейтронов (одинаковый Z, разный A).', see: ['нуклид'] },
+ 'электронное облако': { d: 'Область вокруг ядра, где электрон бывает чаще всего.', see: ['орбиталь'] },
+ 'орбиталь': { d: 'Форма электронного облака: s — сфера, p — гантель.', see: ['электронное облако'] },
+ 'электроотрицательность': { d: 'Способность атома притягивать к себе общие электроны.', see: ['ковалентная связь'] },
+ 'ковалентная связь': { d: 'Связь за счёт общих электронных пар (между неметаллами).', see: ['электроотрицательность', 'ионная связь'] },
+ 'ионная связь': { d: 'Связь за счёт полной передачи электронов от металла к неметаллу; образуются ионы.', see: ['ковалентная связь'] },
+ 'металлическая связь': { d: 'Связь ион-остовов металла «электронным газом» из общих электронов.', see: [] },
+ 'кристаллическая решётка': { d: 'Упорядоченное расположение частиц в кристалле: ионная, атомная, молекулярная, металлическая.', see: [] },
+ 'степень окисления': { d: 'Условный заряд атома в соединении (H +1, O −2, сумма = 0).', see: ['окисление', 'восстановление'] },
+ 'окисление': { d: 'Процесс отдачи электронов (степень окисления повышается).', see: ['восстановление', 'степень окисления'] },
+ 'восстановление': { d: 'Процесс приёма электронов (степень окисления понижается).', see: ['окисление'] },
+ 'окислитель': { d: 'Частица, принимающая электроны (сама восстанавливается).', see: ['восстановитель'] },
+ 'восстановитель': { d: 'Частица, отдающая электроны (сама окисляется).', see: ['окислитель'] },
+ 'окислительно-восстановительная реакция': { d: 'Реакция с изменением степеней окисления (переход электронов).', see: ['степень окисления'] },
+ 'смесь': { d: 'Несколько веществ вместе: однородная (раствор) или неоднородная.', see: ['раствор'] },
+ 'раствор': { d: 'Однородная смесь растворителя и растворённого вещества.', see: ['растворимость', 'массовая доля'] },
+ 'растворимость': { d: 'Масса вещества, растворяющаяся в 100 г воды при данной температуре.', see: ['раствор'] },
+ 'насыщенный раствор': { d: 'Раствор, в котором вещество больше не растворяется при данной температуре.', see: ['раствор'] },
+ 'массовая доля': { d: 'Отношение массы растворённого вещества к массе раствора: $w = m_{в-ва}/m_{р-ра}$.', see: ['раствор'] },
+ 'молярная концентрация': { d: 'Химическое количество вещества в 1 л раствора: $c = n/V$ (моль/л).', see: ['раствор'] }
+ };
+
+ var TERMS = Object.keys(G).sort(function (a, b) { return b.length - a.length; }); // длинные раньше
+
+ function injectCSS() {
+ if (D.getElementById('chem8-gloss-css')) return;
+ var s = D.createElement('style'); s.id = 'chem8-gloss-css';
+ s.textContent =
+ '.gloss{border-bottom:1.5px dotted var(--pri,#d97706);cursor:help;text-decoration:none}'
+ + '.gl-fab{position:fixed;left:16px;bottom:16px;z-index:55;display:inline-flex;align-items:center;gap:7px;padding:9px 14px;border:none;border-radius:99px;background:var(--pri,#d97706);color:#fff;font-weight:700;font-size:.84rem;cursor:pointer;box-shadow:0 6px 18px rgba(0,0,0,.18);font-family:inherit}'
+ + '.gl-fab:hover{filter:brightness(1.08)}.gl-fab svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}'
+ + '.gl-modal{position:fixed;inset:0;z-index:80;background:rgba(0,0,0,.45);display:none;align-items:flex-start;justify-content:center;padding:40px 16px;overflow:auto}'
+ + '.gl-modal.show{display:flex}'
+ + '.gl-box{background:var(--card,#fff);color:var(--text,#1c1917);border-radius:16px;max-width:600px;width:100%;padding:20px;box-shadow:0 20px 60px rgba(0,0,0,.3)}'
+ + '.gl-h{display:flex;align-items:center;gap:10px;margin-bottom:12px}.gl-h h3{font-family:Outfit,sans-serif;font-size:1.15rem;font-weight:800;flex:1}'
+ + '.gl-close{border:none;background:transparent;font-size:1.4rem;cursor:pointer;color:var(--muted,#888);line-height:1}'
+ + '.gl-search{width:100%;padding:10px 13px;border:1.5px solid var(--border,#ddd);border-radius:10px;background:var(--card,#fff);color:var(--text,#1c1917);font-family:inherit;font-size:.95rem;margin-bottom:12px}'
+ + '.gl-list{max-height:60vh;overflow:auto}'
+ + '.gl-item{padding:10px 12px;border-bottom:1px solid var(--border,#eee)}.gl-item:last-child{border-bottom:0}'
+ + '.gl-term{font-weight:800;color:var(--pri-d,#b45309);text-transform:capitalize}'
+ + '.gl-def{font-size:.9rem;margin-top:3px;line-height:1.5}'
+ + '.gl-see{font-size:.8rem;color:var(--muted,#888);margin-top:4px}'
+ + '.gl-pop{position:absolute;z-index:90;max-width:280px;background:var(--card,#fff);color:var(--text,#1c1917);border:1.5px solid var(--pri,#d97706);border-radius:10px;padding:10px 13px;font-size:.86rem;line-height:1.5;box-shadow:0 8px 24px rgba(0,0,0,.2);display:none}'
+ + '.gl-pop.show{display:block}.gl-pop b{color:var(--pri-d,#b45309);text-transform:capitalize}';
+ D.head.appendChild(s);
+ }
+
+ function esc(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
+
+ /* авто-подсветка терминов в .card-body (первое вхождение каждого, в текстовых узлах) */
+ function decorate(root) {
+ if (!root) return;
+ var bodies = root.matches && root.matches('.card-body') ? [root] : root.querySelectorAll ? root.querySelectorAll('.card-body') : [];
+ Array.prototype.forEach.call(bodies, function (body) {
+ if (body._glossed) return; body._glossed = 1;
+ var used = {};
+ TERMS.forEach(function (term) {
+ if (used[term]) return;
+ var walker = D.createTreeWalker(body, W.NodeFilter.SHOW_TEXT, null);
+ var node, re = new RegExp('(^|[^а-яёА-ЯЁ-])(' + esc(term) + ')(?![а-яёА-ЯЁ])', 'i');
+ while ((node = walker.nextNode())) {
+ if (node.parentNode && (node.parentNode.classList && (node.parentNode.classList.contains('gloss') || node.parentNode.closest('.gloss,abbr,a,.ph-formula,.main-f,code')))) continue;
+ var m = node.nodeValue.match(re);
+ if (m) {
+ var idx = m.index + m[1].length;
+ var before = node.nodeValue.slice(0, idx), word = node.nodeValue.slice(idx, idx + term.length), after = node.nodeValue.slice(idx + term.length);
+ var ab = D.createElement('abbr'); ab.className = 'gloss'; ab.setAttribute('data-term', term.toLowerCase()); ab.textContent = word;
+ var frag = D.createDocumentFragment();
+ frag.appendChild(D.createTextNode(before)); frag.appendChild(ab); frag.appendChild(D.createTextNode(after));
+ node.parentNode.replaceChild(frag, node);
+ used[term] = 1; break;
+ }
+ }
+ });
+ });
+ }
+
+ /* popover при наведении/клике на .gloss */
+ var pop;
+ function showPop(ab) {
+ var term = ab.getAttribute('data-term'); var g = G[term]; if (!g) return;
+ if (!pop) { pop = D.createElement('div'); pop.className = 'gl-pop'; D.body.appendChild(pop); }
+ pop.innerHTML = '' + t + '
' + G[t].d + '
' + (G[t].see && G[t].see.length ? '
См.: ' + G[t].see.join(', ') + '
' : '') + '
'; }).join('') || 'Ничего не найдено.
';
+ renderMath(list);
+ }
+ search.addEventListener('input', function () { render(search.value); });
+ modal.querySelector('.gl-close').addEventListener('click', close);
+ modal.addEventListener('click', function (e) { if (e.target === modal) close(); });
+ render('');
+ }
+ function open() { if (!modal) buildModal(); modal.classList.add('show'); var s = modal.querySelector('.gl-search'); if (s) setTimeout(function () { s.focus(); }, 50); }
+ function close() { if (modal) modal.classList.remove('show'); }
+
+ function init() {
+ injectCSS();
+ var fab = D.createElement('button'); fab.className = 'gl-fab';
+ fab.innerHTML = '