diff --git a/static/app.js b/static/app.js
index 16595a7..a49ea5a 100644
--- a/static/app.js
+++ b/static/app.js
@@ -40,6 +40,36 @@ import {
importSettings,
} from './js/settings-io.js';
import { audio } from './js/audio-el.js';
+import {
+ _buildSectionParents,
+ _ensureSectionPracticeBar,
+ _hideSectionPracticeBar,
+ _installSectionPracticeDrawHook,
+ _maybeRefreshSectionPracticeDuration,
+ _placeSectionPracticeControlForChrome,
+ _resetSectionPracticeLog,
+ _scheduleSectionPracticeRetries,
+ _sectionPracticeBarContains,
+ _sectionPracticeBarIsReady,
+ _sectionPracticePopoverOpen,
+ _sectionPracticeSourceSections,
+ _sectionPracticeStartTime,
+ _setSectionPracticeMode,
+ _syncSectionPracticeFromLoop,
+ _updateSectionPracticeHighlight,
+ invalidateParentCount,
+ onPhraseNext,
+ onPhrasePrev,
+ onSectionParentClick,
+ onSectionPracticeModeChange,
+ onSectionPracticeWholeChange,
+ practiceSection,
+ renderSectionPracticeBar,
+ resetSelection,
+ toggleSectionPracticePopover,
+} from './js/section-practice.js';
+import { configureHost } from './js/host.js';
+
// Demo analytics — real impl set by demo.js; no-op in normal builds
window.feedBackDemoTrack = window.feedBackDemoTrack ?? null;
@@ -460,11 +490,6 @@ function _isSpaceKey(e) {
return e.key === ' ' || e.key === 'Spacebar';
}
-function _sectionPracticeBarContains(el) {
- if (!el) return false;
- const bar = document.getElementById('section-practice-bar');
- return !!(bar && bar.contains(el));
-}
function _shortcutDispatchBlocked(e) {
if (_isTextInput(e.target)) return true;
@@ -5832,7 +5857,7 @@ async function changeArrangement(index) {
// count. The A-B loop itself is left intact (time-based, song-global).
_hideSectionPracticeBar();
_resetSectionPracticeLog();
- _sectionPracticeLastParentCount = -1;
+ invalidateParentCount();
highway.reconnect(currentFilename, index);
window.feedBack.emit('arrangement:changed', { index, filename: currentFilename });
@@ -6405,9 +6430,7 @@ function clearLoop(options) {
document.getElementById('btn-loop-save').classList.add('hidden');
document.getElementById('loop-label').textContent = '';
document.getElementById('saved-loops').value = '';
- _sectionPracticeSelected = -1;
- _sectionPracticeWholeSection = false;
- _sectionPracticeSavedPartIndex = 0;
+ resetSelection();
_updateSectionPracticeHighlight(_audioTime());
if (hadLoop && emitTransportEvent && typeof window !== 'undefined') {
window.feedBack?.playback?.transportEvent?.('loop-cleared', {
@@ -6620,1168 +6643,74 @@ function returnToEditorFromHighway() {
}
window.returnToEditorFromHighway = returnToEditorFromHighway;
-// ── Section Practice Bar ────────────────────────────────────────────────
-// One-click looping over song section markers (highway.getSections —
-// same array as 3D highway bundle.sections / "Now / Up Next").
-// Reuses setLoop() so manual A/B controls and saved loops stay canonical.
-let _sectionPracticeRanges = [];
-let _sectionPracticeSelected = -1;
-let _sectionPracticeFollowParent = -1;
-let _sectionPracticeDurSynced = false;
-let _sectionPracticeLogged = false;
-let _sectionPracticeHooked = false;
-let _sectionPracticeRetryTimer = null;
-let _sectionPracticeLastPlayableCount = 0;
-let _sectionPracticePlayablePopulateRerendered = false;
-// Last-rendered parent count, so the bar can re-render when the parent layout
-// changes after the initial render — notably when the synthetic "Start" section
-// appears as notes-before-the-first-marker stream in late.
-let _sectionPracticeLastParentCount = -1;
-// Start-time identity of the active parent, tracked so it can be remapped to the
-// correct index when the parent layout shifts (a late "Start" prepend moves every
-// real parent by one) instead of leaving the raw index pointing at the wrong one.
-let _sectionPracticeActiveParentStart = NaN;
-let _sectionPracticeMode = false;
-let _sectionPracticeActiveParent = -1;
-let _sectionPracticeWholeSection = false;
-let _sectionPracticeSavedPartIndex = 0;
-// Monotonic token to cancel stale practiceSection() retries: a newer click
-// (or a song/arrangement change, which also bumps _audioSeekGen) supersedes
-// any in-flight retry loop so it can't re-arm the wrong loop/count-in.
-let _sectionPracticeRequestGen = 0;
-// >0 while a practiceSection() request is awaiting its loop. While set,
-// _syncSectionPracticeFromLoop() (e.g. from a mid-await bar re-render) must not
-// reconcile against the half-applied / previous loop — practiceSection owns the
-// section state and applies it once its own gen check passes.
-let _sectionPracticeRequestInFlight = 0;
-function _setSectionPracticeMode(on, opts = {}) {
- const next = !!on;
- if (next === _sectionPracticeMode && !opts.force) return;
- _sectionPracticeMode = next;
- const cb = document.getElementById('section-practice-mode');
- if (cb) cb.checked = _sectionPracticeMode;
- // Surface the "looping" state on the collapsed pill so the user can tell
- // Section Practice is armed without opening the popover.
- const pill = document.getElementById('section-practice-pill');
- if (pill) pill.classList.toggle('section-practice-pill--active', _sectionPracticeMode);
- _sectionPracticeFollowParent = -1;
- if (_sectionPracticeMode) {
- if (opts.defaultWholeOn) {
- _sectionPracticeWholeSection = true;
- }
- _updateSectionPracticeHighlight(_audioTime());
- if (opts.defaultWholeOn) {
- _syncSectionPracticePieceUi();
- }
- } else {
- // Turning the feature off must cancel any in-flight practiceSection()
- // retry: otherwise a stale setLoop() that lands after the user unchecks
- // Section Practice would re-arm the loop, flip the mode back on via
- // _syncSectionPracticeFromLoop(), and restart playback through
- // startCountIn(). Bumping the request gen makes the pending retry bail.
- _sectionPracticeRequestGen++;
- // Cancel any pending count-in: every section-practice teardown routes
- // through here (mode toggle off, clearLoop, and _hideSectionPracticeBar
- // on song/arrangement change), so a countdown started by a prior section
- // click must not resume playback after the user has turned practice off.
- _cancelCountIn();
- _sectionPracticeSelected = -1;
- _sectionPracticeWholeSection = false;
- _sectionPracticeSavedPartIndex = 0;
- _updateSectionPracticeHighlight(_audioTime());
- if (!opts.skipClearLoop && (loopA !== null || loopB !== null)) {
- clearLoop();
- }
- }
-}
-function onSectionPracticeModeChange() {
- const cb = document.getElementById('section-practice-mode');
- if (!cb) return;
- const turningOn = cb.checked && !_sectionPracticeMode;
- _setSectionPracticeMode(cb.checked, { defaultWholeOn: turningOn });
-}
-function _resetSectionPracticeLog() {
- _sectionPracticeLogged = false;
- _sectionPracticeLastPlayableCount = 0;
- _sectionPracticePlayablePopulateRerendered = false;
-}
-function _sectionPracticeHighway() {
- return window.highway || (typeof highway !== 'undefined' ? highway : null);
-}
-function _sectionPracticeDuration() {
- const d = _audioDuration();
- if (d && Number.isFinite(d) && d > 0) return d;
- const cd = window.feedBack?.currentSong?.duration;
- return (cd && Number.isFinite(cd) && cd > 0) ? cd : 0;
-}
-function _sectionPracticeSourceSections() {
- const hw = _sectionPracticeHighway();
- if (!hw || typeof hw.getSections !== 'function') return [];
- const raw = hw.getSections();
- return Array.isArray(raw) ? raw : [];
-}
-function _sectionPracticeStartTime(s) {
- const t = s.time ?? s.startTime ?? s.start_time ?? s.start;
- const n = Number(t);
- return Number.isFinite(n) ? n : NaN;
-}
-function _sectionPracticeBaseName(rawName, fallbackIndex) {
- let s = (typeof rawName === 'string' ? rawName : '').trim();
- if (!s) s = `Section ${fallbackIndex + 1}`;
- // Normalise separators and strip common trailing digits like "Chorus 2"
- s = s.replace(/_/g, ' ');
- s = s.replace(/\s*\d+$/u, '');
- const lower = s.toLowerCase();
- const canonical = {
- intro: 'Intro',
- verse: 'Verse',
- chorus: 'Chorus',
- bridge: 'Bridge',
- solo: 'Solo',
- riff: 'Riff',
- outro: 'Outro',
- }[lower];
- if (canonical) return canonical;
- // Fallback: title-case words
- return lower.split(/\s+/).filter(Boolean).map(w => w[0].toUpperCase() + w.slice(1)).join(' ') || `Section ${fallbackIndex + 1}`;
-}
-const _SECTION_PRACTICE_START_GAP_SEC = 0.05;
-function _sectionPracticeNoteTime(note) {
- const t = note?.t ?? note?.time ?? note?.start_time ?? note?.start;
- const n = Number(t);
- return Number.isFinite(n) ? n : NaN;
-}
-function _sectionPracticePlayableCount() {
- const hw = _sectionPracticeHighway();
- if (!hw) return 0;
- let count = 0;
- if (typeof hw.getNotes === 'function') {
- const notes = hw.getNotes();
- if (notes?.length) count += notes.length;
- }
- if (typeof hw.getChords === 'function') {
- const chords = hw.getChords();
- if (chords?.length) count += chords.length;
- }
- return count;
-}
-function _sectionPracticeHasNotesBefore(beforeTime) {
- const hw = _sectionPracticeHighway();
- if (!hw) return false;
- const cutoff = Number(beforeTime);
- if (!Number.isFinite(cutoff)) return false;
- const sources = [];
- if (typeof hw.getNotes === 'function') {
- const notes = hw.getNotes();
- if (notes?.length) sources.push(notes);
- }
- if (typeof hw.getChords === 'function') {
- const chords = hw.getChords();
- if (chords?.length) sources.push(chords);
- }
- for (let s = 0; s < sources.length; s++) {
- const items = sources[s];
- for (let i = 0; i < items.length; i++) {
- const t = _sectionPracticeNoteTime(items[i]);
- if (Number.isFinite(t) && t < cutoff) return true;
- }
- }
- return false;
-}
-function _maybeRerenderSectionPracticeOnPlayableLoad() {
- const count = _sectionPracticePlayableCount();
- const prev = _sectionPracticeLastPlayableCount;
- _sectionPracticeLastPlayableCount = count;
- if (!_sectionPracticeSourceSections().length || !_sectionPracticeBarIsReady()) return;
- // Re-render whenever the parent layout changes after the bar is up — the
- // synthetic "Start" section can appear (±1 parent) once a note before the
- // first marker streams in, which would otherwise leave the DOM chip indices
- // out of sync with _buildSectionParents() (clicks/highlights hitting the
- // wrong section). _buildSectionParents() is memoized, so this is cheap.
- const parents = _buildSectionParents();
- const parentCount = parents.length;
- if (parentCount !== _sectionPracticeLastParentCount) {
- // Remap the active parent by start-time identity before re-rendering: a
- // late "Start" prepend shifts every real parent's index, so the raw
- // index would otherwise point at the wrong section (mis-highlighting and
- // breaking whole/prev/next). Selected/part indices are within-parent and
- // unaffected. Skip when no active parent or no prior snapshot.
- if (_sectionPracticeActiveParent >= 0 && Number.isFinite(_sectionPracticeActiveParentStart)) {
- const remapped = parents.findIndex(
- (p) => Math.abs(p.start - _sectionPracticeActiveParentStart) < 0.001,
- );
- if (remapped >= 0) _sectionPracticeActiveParent = remapped;
- }
- _sectionPracticeLastParentCount = parentCount;
- renderSectionPracticeBar();
- _sectionPracticeActiveParentStart =
- (_sectionPracticeActiveParent >= 0 && parents[_sectionPracticeActiveParent])
- ? parents[_sectionPracticeActiveParent].start : NaN;
- return;
- }
- // Keep the active-parent start snapshot fresh while the layout is stable, so
- // it holds the correct pre-change value when the layout next shifts.
- _sectionPracticeActiveParentStart =
- (_sectionPracticeActiveParent >= 0 && parents[_sectionPracticeActiveParent])
- ? parents[_sectionPracticeActiveParent].start : NaN;
- if (_sectionPracticePlayablePopulateRerendered) return;
- if (prev !== 0 || count === 0) return;
- _sectionPracticePlayablePopulateRerendered = true;
- renderSectionPracticeBar();
-}
-// _buildSectionParents() runs on the 60 Hz highlight path, so memoize it.
-// The parent layout is a pure function of the highway's section list (a
-// stable array reference per song), the song duration, and whether any
-// notes/chords precede the first marker (the synthetic "Start" section).
-// That last input can flip while WS note chunks are still streaming in, so
-// the note/chord counts are part of the key; once a song is fully loaded
-// all four inputs stabilize and the per-frame call becomes a cache hit.
-// Every call site uses the result read-only, so returning the cached array
-// reference is safe.
-let _sectionParentsCache = null;
-let _sectionParentsCacheRaw = null;
-let _sectionParentsCacheDur = -1;
-let _sectionParentsCacheNoteLen = -1;
-let _sectionParentsCacheChordLen = -1;
-function _buildSectionParents() {
- const raw = _sectionPracticeSourceSections();
- if (!raw.length) return [];
- const dur = _sectionPracticeDuration();
- const hw = _sectionPracticeHighway();
- const noteLen = (hw && typeof hw.getNotes === 'function' && hw.getNotes()?.length) || 0;
- const chordLen = (hw && typeof hw.getChords === 'function' && hw.getChords()?.length) || 0;
- if (_sectionParentsCache !== null
- && _sectionParentsCacheRaw === raw
- && _sectionParentsCacheDur === dur
- && _sectionParentsCacheNoteLen === noteLen
- && _sectionParentsCacheChordLen === chordLen) {
- return _sectionParentsCache;
- }
- const sorted = [...raw].sort((a, b) => _sectionPracticeStartTime(a) - _sectionPracticeStartTime(b));
- // Step 1: collapse consecutive same-name markers into logical groups.
- const groups = [];
- for (let i = 0; i < sorted.length; i++) {
- const start = _sectionPracticeStartTime(sorted[i]);
- if (!Number.isFinite(start)) continue;
- const baseName = _sectionPracticeBaseName(sorted[i].name, groups.length);
- const prev = groups[groups.length - 1];
- if (prev && prev.baseName === baseName) {
- prev.lastIndex = i;
- } else {
- groups.push({ baseName, firstIndex: i, lastIndex: i });
- }
- }
- if (!groups.length) return [];
- // Step 2: assign musician-friendly labels with counters (Verse 1, Verse 2, …).
- const counters = Object.create(null);
- const ranges = [];
- for (let gi = 0; gi < groups.length; gi++) {
- const g = groups[gi];
- const base = g.baseName;
- const count = (counters[base] || 0) + 1;
- counters[base] = count;
- const label = `${base} ${count}`;
- const firstSec = sorted[g.firstIndex];
- const start = _sectionPracticeStartTime(firstSec);
- if (!Number.isFinite(start)) continue;
- let end;
- if (gi + 1 < groups.length) {
- const nextFirst = sorted[groups[gi + 1].firstIndex];
- end = _sectionPracticeStartTime(nextFirst);
- } else {
- end = dur;
- }
- if (!Number.isFinite(end) || end <= start) {
- end = dur > start ? dur : start + 4;
- }
- ranges.push({ name: label, start, end });
- }
- if (ranges.length > 0) {
- const firstStart = Number(ranges[0].start);
- if (Number.isFinite(firstStart) && firstStart > _SECTION_PRACTICE_START_GAP_SEC
- && _sectionPracticeHasNotesBefore(firstStart)) {
- ranges.unshift({ name: 'Start', start: 0, end: firstStart });
- }
- }
- _sectionParentsCache = ranges;
- _sectionParentsCacheRaw = raw;
- _sectionParentsCacheDur = dur;
- _sectionParentsCacheNoteLen = noteLen;
- _sectionParentsCacheChordLen = chordLen;
- return ranges;
-}
-function _sectionPracticeResetSelectionUi() {
- _sectionPracticeActiveParent = -1;
- _sectionPracticeSelected = -1;
- _sectionPracticeWholeSection = false;
- _sectionPracticeSavedPartIndex = 0;
- _sectionPracticeRanges = [];
-}
-function _sectionPracticeSourcePhrases() {
- const hw = _sectionPracticeHighway();
- if (!hw || typeof hw.getPracticePhrases !== 'function') return null;
- const raw = hw.getPracticePhrases();
- return (raw && raw.length) ? raw : null;
-}
-function _buildPhrasePartsForParent(parent) {
- if (!parent) return [];
- const dur = _sectionPracticeDuration();
- const windowStart = parent.start;
- const windowEnd = parent.end;
- const phrases = _sectionPracticeSourcePhrases();
- const parts = [];
- if (phrases) {
- const inWindow = phrases.filter(
- (ph) => ph.start_time >= windowStart - 0.001 && ph.start_time < windowEnd - 0.001,
- );
- if (inWindow.length) {
- for (let i = 0; i < inWindow.length; i++) {
- const ph = inWindow[i];
- let start = ph.start_time;
- let end = ph.end_time;
- if (!Number.isFinite(end) || end > windowEnd) end = windowEnd;
- if (!Number.isFinite(start) || end <= start) continue;
- if (dur && Number.isFinite(dur) && end > dur) end = dur;
- parts.push({ name: parent.name, start, end });
- }
- // Snap first part to section start so the loop aligns with the selected marker
- // when the first in-window phrase iteration begins later (e.g. Chorus 2).
- if (parts.length > 0 && parts[0].start > windowStart) {
- parts[0].start = windowStart;
- }
- return parts;
- }
- }
- let start = windowStart;
- let end = windowEnd;
- if (dur && Number.isFinite(dur) && end > dur) end = dur;
- if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
- parts.push({ name: parent.name, start, end });
- }
- return parts;
-}
-function _buildSectionPracticeRanges() {
- if (_sectionPracticeActiveParent < 0) return [];
- const parents = _buildSectionParents();
- const parent = parents[_sectionPracticeActiveParent];
- if (!parent) return [];
- return _buildPhrasePartsForParent(parent);
-}
-function _sectionPracticeActiveParentRange() {
- if (_sectionPracticeActiveParent < 0) return null;
- const parents = _buildSectionParents();
- const parent = parents[_sectionPracticeActiveParent];
- if (!parent) return null;
- const dur = _sectionPracticeDuration();
- let end = Number(parent.end);
- const start = Number(parent.start);
- if (dur && Number.isFinite(dur) && end > dur) end = dur;
- if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return null;
- return { name: parent.name, start, end };
-}
-function _sectionPracticeResolveLoopTarget(index, opts = {}) {
- if (opts.whole) {
- return _sectionPracticeActiveParentRange();
- }
- return _sectionPracticeRanges[index] ?? null;
-}
-function _formatSectionPracticeName(name) {
- return name.replace(/_/g, ' ');
-}
-const _SECTION_PRACTICE_CHIP_KINDS = new Set([
- 'intro', 'verse', 'chorus', 'bridge', 'solo', 'riff', 'outro',
-]);
-function _sectionPracticeChipKindClass(name, index) {
- const base = _sectionPracticeBaseName(name, index);
- const kind = base.toLowerCase();
- if (!_SECTION_PRACTICE_CHIP_KINDS.has(kind)) return '';
- return ` section-practice-chip--${kind}`;
-}
-function _sectionPracticeWholeCheckboxHtml() {
- return '';
-}
-function _sectionPracticePieceRowHtml() {
- return '
'
- + 'Part — of —'
- + ''
- + ''
- + '
';
-}
-function _sectionPracticeMainRow() {
- const bar = document.getElementById('section-practice-bar');
- if (!bar) return null;
- return bar.querySelector('.section-practice-controls-row')
- || bar.querySelector('.section-practice-primary-row')
- || bar.querySelector('.section-practice-row:not(.section-practice-piece-row):not(.section-practice-chips-row)');
-}
-function _migrateSectionPracticeDomLayout(bar) {
- if (!bar || bar.querySelector('.section-practice-controls-row')) return;
- const pieceRow = document.getElementById('section-practice-piece-row');
- const scroll = document.getElementById('section-practice-scroll');
- const modeWrap = bar.querySelector('.section-practice-mode-wrap');
- const wholeWrap = bar.querySelector('.section-practice-whole-wrap');
- let label = bar.querySelector('.section-practice-label');
- const controlsRow = document.createElement('div');
- controlsRow.className = 'section-practice-row section-practice-controls-row';
- if (modeWrap) controlsRow.appendChild(modeWrap);
- if (wholeWrap) controlsRow.appendChild(wholeWrap);
- if (pieceRow) controlsRow.appendChild(pieceRow);
- const chipsRow = document.createElement('div');
- chipsRow.className = 'section-practice-row section-practice-chips-row';
- if (label) {
- chipsRow.appendChild(label);
- } else {
- label = document.createElement('span');
- label.className = 'section-practice-label';
- label.textContent = 'Sections:';
- chipsRow.appendChild(label);
- }
- if (scroll) chipsRow.appendChild(scroll);
- bar.replaceChildren(controlsRow, chipsRow);
-}
-function _sectionPracticeBarInnerHtml() {
- return ''
- + ''
- + _sectionPracticeWholeCheckboxHtml()
- + _sectionPracticePieceRowHtml()
- + '
'
- + ''
- + '
Sections:'
- + '
'
- + '
';
-}
-function _ensureSectionPracticeWholeCheckbox() {
- const existing = document.getElementById('section-practice-whole');
- const mainRow = _sectionPracticeMainRow();
- if (!mainRow) return;
- if (existing) {
- const wrap = existing.closest('.section-practice-whole-wrap');
- if (wrap && !mainRow.contains(wrap)) {
- const modeWrap = mainRow.querySelector('.section-practice-mode-wrap');
- if (modeWrap) modeWrap.insertAdjacentElement('afterend', wrap);
- else mainRow.insertBefore(wrap, mainRow.firstChild);
- }
- return;
- }
- const modeWrap = mainRow.querySelector('.section-practice-mode-wrap');
- if (modeWrap) {
- modeWrap.insertAdjacentHTML('afterend', _sectionPracticeWholeCheckboxHtml());
- } else {
- mainRow.insertAdjacentHTML('afterbegin', _sectionPracticeWholeCheckboxHtml());
- }
-}
-function _sectionPracticeCurrentPartIndex() {
- const total = _sectionPracticeRanges.length;
- if (!total) return 0;
- if (!_sectionPracticeWholeSection && _sectionPracticeSelected >= 0) {
- return Math.min(_sectionPracticeSelected, total - 1);
- }
- if (_sectionPracticeSavedPartIndex >= 0) {
- return Math.min(_sectionPracticeSavedPartIndex, total - 1);
- }
- return 0;
-}
-function _sectionPracticePillHtml() {
- return '';
-}
-function _syncSectionPracticePillV3Chrome(isV3) {
- const pill = document.getElementById('section-practice-pill');
- if (!pill) return;
- pill.classList.toggle('v3-rail-icon', isV3);
- let ring = pill.querySelector('.v3-rail-border');
- if (isV3) {
- if (!ring) {
- ring = document.createElement('span');
- ring.className = 'v3-rail-border';
- ring.setAttribute('aria-hidden', 'true');
- pill.insertBefore(ring, pill.firstChild);
- }
- pill.setAttribute('title', 'Practice');
- pill.setAttribute('aria-label', 'Practice');
- } else {
- if (ring) ring.remove();
- pill.setAttribute('title', 'Section practice');
- pill.setAttribute('aria-label', 'Section practice');
- }
-}
-// Wrap an existing #section-practice-bar in the pill control (creating the
-// wrapper + pill if missing). Defensive: works whether the bar came from the
-// static markup (already wrapped) or a chrome whose index.html predates the
-// pill (e.g. a not-yet-rebased v3 build) — the bar is always reachable as a
-// closed popover behind the pill afterward.
-function _ensureSectionPracticeControlWrap(bar) {
- if (!bar) return null;
- let ctrl = (bar.closest && bar.closest('.section-practice-control'))
- || document.getElementById('section-practice-control');
- if (ctrl) {
- if (!ctrl.contains(bar)) ctrl.appendChild(bar);
- } else {
- ctrl = document.createElement('div');
- ctrl.id = 'section-practice-control';
- ctrl.className = 'section-practice-control section-practice-control--hidden';
- if (bar.parentNode) bar.parentNode.insertBefore(ctrl, bar);
- ctrl.appendChild(bar);
- }
- if (!ctrl.querySelector('#section-practice-pill')) {
- ctrl.insertAdjacentHTML('afterbegin', _sectionPracticePillHtml());
- }
- // Popover visibility is driven by --open now; clear any legacy hidden class.
- bar.classList.remove('section-practice-bar--hidden');
- _mountSectionPracticeControlSafe(ctrl);
- return ctrl;
-}
-// Mount the pill control so its popover — whose chip