feat(stereo): сворачиваемый аккордеон панели управления (UX)

Панель за фазы A–C разрослась до ~14 всегда-раскрытых секций (длинный
скролл, тяжело ориентироваться). Сделал её удобнее:

- _stereoInitPanel() (вызов из _openStereo, идемпотентно) оборачивает
  контролы каждой секции в .st-acc-body; заголовки .gp-section-title →
  кликабельные .st-acc-hdr с шевроном; состояние секций в localStorage.
- Тройку фигурных секций (Многогранники/Правильные/Тела вращения) слил в
  одну «Фигуры» (под-метки .st-sublabel). По умолчанию открыты «Фигуры» и
  «Параметры», остальное свёрнуто.
- Кнопки «Развернуть всё / Свернуть всё» (stereoAccAll), клавиатура
  (Enter/Space на заголовке), role=button/tabindex.
- Только раскладка: ни один контрол/обработчик не изменён (узлы лишь
  перемещены в тело секции). Затронуты stereo.js + lab.css.

Верификация: node --check OK; headless DOM-смоук (мини-DOM + реальный
stereo.js в vm) 22/22: 12 сворачиваемых секций, тройка фигур слита (2
под-метки внутри «Фигуры»), пары заголовок→тело, дефолт-открытие,
тоггл+персист, развернуть/свернуть всё, идемпотентная переинициализация,
ни одна строка контролов не потеряна. Эмодзи/eval/new Function — 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-17 17:48:08 +03:00
parent 9547a20875
commit 601f584181
3 changed files with 112 additions and 0 deletions
+25
View File
@@ -388,6 +388,31 @@
border-color: var(--violet) !important; color: var(--violet) !important; background: rgba(155,93,229,.12) !important;
}
/* ── stereo panel: collapsible accordion (UX) ── */
.stereo-panel .st-acc-toolbar { display: flex; gap: 6px; margin: 0 0 8px; }
.stereo-panel .st-acc-toolbar button {
flex: 1; padding: 5px 6px; border-radius: 8px; border: 1px solid var(--border);
background: transparent; color: var(--text-3);
font-family: 'Manrope', sans-serif; font-size: .62rem; font-weight: 700;
cursor: pointer; transition: all .12s;
}
.stereo-panel .st-acc-toolbar button:hover {
color: var(--violet); border-color: rgba(155,93,229,.4); background: rgba(155,93,229,.06);
}
.stereo-panel .st-acc-hdr {
cursor: pointer; justify-content: space-between; user-select: none;
margin: 3px 0; padding: 8px 8px; border-radius: 9px;
background: rgba(255,255,255,.025); transition: background .12s, color .12s;
}
.stereo-panel .st-acc-hdr::after { display: none; } /* drop the divider line */
.stereo-panel .st-acc-hdr:hover { background: rgba(155,93,229,.09); color: var(--violet); }
.stereo-panel .st-acc-hdr.open { color: var(--text-2); background: rgba(155,93,229,.06); }
.stereo-panel .st-acc-chev { display: flex; align-items: center; opacity: .6; transition: transform .18s; }
.stereo-panel .st-acc-chev svg { width: 13px; height: 13px; stroke: currentColor; stroke-width: 2.5; fill: none; }
.stereo-panel .st-acc-hdr.open .st-acc-chev { transform: rotate(180deg); }
.stereo-panel .st-acc-body { margin: 0 0 8px; padding: 0 1px; }
.stereo-panel .st-sublabel { opacity: .8; margin: 8px 0 6px; }
.gp-preset-group { margin-bottom: 8px; }
.gp-preset-label {
font-size: 0.68rem; font-weight: 700; text-transform: uppercase;
+77
View File
@@ -4643,9 +4643,86 @@ class StereoSim {
dodecahedron: ['a'],
};
/* ── Collapsible accordion for the (long) stereo control panel ──
Wraps each section's controls into a toggle body. Only the panel layout
changes; every control/handler is preserved verbatim. */
function _stereoInitPanel() {
const panel = document.querySelector('#sim-stereo .stereo-panel');
if (!panel || panel._accInit) return;
panel._accInit = true;
const MERGE = { 'Правильные многогранники': 1, 'Тела вращения': 1 }; // folded into «Фигуры»
const RENAME = { 'Многогранники': 'Фигуры' };
const DEFAULT_OPEN = { 'Фигуры': 1, 'Параметры': 1 };
const CHEV = '<span class="st-acc-chev"><svg viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"></polyline></svg></span>';
const kids = Array.prototype.slice.call(panel.children);
const isTitle = (n) => n.classList && n.classList.contains('gp-section-title');
const txt = (n) => (n.textContent || '').trim();
for (let i = 0; i < kids.length; i++) {
const head = kids[i];
if (!isTitle(head) || MERGE[txt(head)]) continue; // act only on real section heads
const body = document.createElement('div');
body.className = 'st-acc-body';
let j = i + 1;
while (j < kids.length) {
const nx = kids[j];
if (isTitle(nx) && !MERGE[txt(nx)]) break; // next section begins
if (isTitle(nx)) nx.classList.add('st-sublabel'); // merged sub-section label
body.appendChild(nx); // move contiguous controls into body
j++;
}
const name = RENAME[txt(head)] || txt(head);
head.classList.add('st-acc-hdr');
head.style.marginTop = ''; head.style.marginBottom = '';
head.setAttribute('role', 'button');
head.tabIndex = 0;
if (RENAME[txt(head)]) head.textContent = name;
head.insertAdjacentHTML('beforeend', CHEV);
head.after(body);
_stereoBindAcc(head, body, name, !!DEFAULT_OPEN[name]);
}
const bar = document.createElement('div');
bar.className = 'st-acc-toolbar';
bar.innerHTML = '<button type="button" onclick="stereoAccAll(true)">Развернуть всё</button>'
+ '<button type="button" onclick="stereoAccAll(false)">Свернуть всё</button>';
panel.insertBefore(bar, panel.firstChild);
}
function _stereoBindAcc(head, body, name, defOpen) {
const key = 'stereo-acc-' + name;
let stored = null; try { stored = localStorage.getItem(key); } catch (_) {}
const open = stored === null ? defOpen : stored === '1';
head.classList.toggle('open', open);
body.style.display = open ? '' : 'none';
const toggle = () => {
const nowOpen = body.style.display === 'none';
body.style.display = nowOpen ? '' : 'none';
head.classList.toggle('open', nowOpen);
try { localStorage.setItem(key, nowOpen ? '1' : '0'); } catch (_) {}
};
head.addEventListener('click', toggle);
head.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } });
}
function stereoAccAll(open) {
const panel = document.querySelector('#sim-stereo .stereo-panel');
if (!panel) return;
Array.prototype.forEach.call(panel.querySelectorAll('.st-acc-hdr'), (h) => {
const body = h.nextElementSibling;
if (!body || !body.classList.contains('st-acc-body')) return;
body.style.display = open ? '' : 'none';
h.classList.toggle('open', open);
try { localStorage.setItem('stereo-acc-' + (h.textContent || '').trim(), open ? '1' : '0'); } catch (_) {}
});
}
function _openStereo(figureType) {
document.getElementById('sim-topbar-title').textContent = 'Стереометрия 3D';
_simShow('sim-stereo');
try { _stereoInitPanel(); } catch (_) {}
document.getElementById('stereo-stats').style.display = '';
// Deep-link from a textbook: openSim('stereo:pyramid') or /lab?stereofig=pyramid
if (!figureType) {
+10
View File
@@ -92,6 +92,16 @@
снапшот `_undoStack`/`_redoStack`, кап 60; хуки в create/remove/clear; Ctrl+Z / Ctrl+Shift+Z /
Ctrl+Y + кнопки «Отменить»/«Вернуть»). Видимость — не шаг истории (намеренно).
### UX панели управления (2026-06-17)
- [x] Панель `.stereo-panel` разрослась за фазы A–C до ~14 всегда-раскрытых секций (длинный скролл) →
**сворачиваемый аккордеон**: `_stereoInitPanel()` (вызывается из `_openStereo`, идемпотентно) оборачивает
контролы каждой секции в `.st-acc-body`, заголовки `.gp-section-title` → кликабельные `.st-acc-hdr`
с шевроном; состояние каждой секции в localStorage (`stereo-acc-<имя>`). Тройка фигурных секций
(Многогранники/Правильные/Тела вращения) слита в одну «Фигуры» (под-метки `.st-sublabel`). По
умолчанию открыты «Фигуры» и «Параметры». Кнопки «Развернуть всё / Свернуть всё» (`stereoAccAll`).
Сами контролы/обработчики не тронуты — только раскладка. Только `stereo.js` + `lab.css`.
### Фаза B — Умные точки
- [x] B1 — **Деление отрезка m:n** (`setDivideMode`/`setDivideRatio`): задаёшь m,n → кликаешь 2 точки A,B