From b6629dc4dff6581307117c68c398a17cfd7a76c4 Mon Sep 17 00:00:00 2001
From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com>
Date: Mon, 12 Oct 2026 16:30:51 +0000
Subject: [PATCH 1/4] feat(sync): per-section sync results UI with confidence
badges (#26)
Add a Tempo/Grid menu item that opens a review list of the #24
per-section analysis: one row per section with a confidence badge, the
scale factor/shift it would bake, and an Apply/Skip toggle. Low-confidence
sections default to skip and are never applied unless explicitly selected;
failed sections are not selectable. Apply all high-confidence re-selects
just the trusted sections, and Apply bakes the accepted map through the
same applyPerSectionSync path as #25, so preview and commit are one map.
A single-section song collapses to the uniform model.
The pure decision layer (_sectionSyncPlanPure, _proposalAcceptedPure
selected override, SECTION_SYNC_CONFIDENCE_FLOOR) lives in section-sync.js;
section-sync-ui.js only gathers onsets, renders and hands the plan over.
---
CHANGELOG.md | 11 ++
screen.html | 27 +++++
src/main.js | 10 ++
src/menu-bar.js | 1 +
src/section-sync-ui.js | 181 ++++++++++++++++++++++++++++++
src/section-sync.js | 118 +++++++++++++++++++-
tests/section_sync_ui.test.mjs | 198 +++++++++++++++++++++++++++++++++
7 files changed, 544 insertions(+), 2 deletions(-)
create mode 100644 src/section-sync-ui.js
create mode 100644 tests/section_sync_ui.test.mjs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 92016f3..e00e03c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,6 +34,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
identity at both ends by skipped neighbours is still reported as a conflict.
API only — no UI wiring yet (#26).
+- **Per-section sync review UI (#26): apply or skip each section.** A new
+ "Per-section sync (review sections)…" item in the Tempo/Grid menu opens a
+ review list: one row per section with its confidence badge, the scale
+ factor/shift it would apply, and an Apply/Skip toggle. Low-confidence sections
+ default to skip and are never applied unless the user explicitly selects them;
+ failed sections are not selectable. "Apply all high-confidence" re-selects just
+ the trusted sections and bakes the accepted map through the same
+ `applyPerSectionSync` path as #25, so the preview and the committed edit are
+ one map. A single-section song collapses to the uniform model (one full-domain
+ row).
+
- **The undo stack is no longer invisible: a last-action label and a Revert
button sit beside Undo/Redo (#43).** Every committed edit now names itself —
the label is derived from the command's own class (or an explicit `label` on
diff --git a/screen.html b/screen.html
index 27abca7..2de7994 100644
--- a/screen.html
+++ b/screen.html
@@ -881,6 +881,33 @@
+
+
+
+
+ Per-section sync
+
+
+
+ Review each section's alignment. Low-confidence sections are skipped by
+ default — select one to apply it anyway.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main.js b/src/main.js
index 5cf1ad2..5d1fdad 100644
--- a/src/main.js
+++ b/src/main.js
@@ -88,6 +88,10 @@ import {
} from './sync-tempo.js';
import { applyPerSectionSync, _applyPerSectionSyncPure } from './tempo.js';
import { _buildPiecewiseMapPure } from './section-sync.js';
+import {
+ editorApplySectionSync, editorHideSectionSync, editorShowSectionSync,
+ editorToggleSectionSync
+} from './section-sync-ui.js';
import {
getMousePos, onDblClick, onMouseDown, onMouseMove, onMouseUp, onWheel
} from './mouse.js';
@@ -743,6 +747,12 @@ window.applyPerSectionSync = applyPerSectionSync;
window._applyPerSectionSyncPure = _applyPerSectionSyncPure;
window._buildPiecewiseMapPure = _buildPiecewiseMapPure;
+// Per-section sync review (#26) — section-sync-ui.js owns the logic.
+window.editorShowSectionSync = editorShowSectionSync;
+window.editorHideSectionSync = editorHideSectionSync;
+window.editorToggleSectionSync = editorToggleSectionSync;
+window.editorApplySectionSync = editorApplySectionSync;
+
// Tab preview (tab-preview.js owns the logic; HTML calls these by name).
window.editorShowTabPreview = _editorShowTabPreview;
window.editorRefreshTabPreview = editorRefreshTabPreview;
diff --git a/src/menu-bar.js b/src/menu-bar.js
index 896613c..90bec6e 100644
--- a/src/menu-bar.js
+++ b/src/menu-bar.js
@@ -276,6 +276,7 @@ export const EDITOR_MENUS = Object.freeze([
{ sep: true },
{ cmd: 'songFit' },
{ label: 'Sync tempo to audio', fn: 'editorSyncTempo', audioOnly: true },
+ { label: 'Per-section sync (review sections)…', fn: 'editorShowSectionSync', audioOnly: true },
{ label: 'Scan for tempo zones…', fn: 'editorScanTempoZones', audioOnly: true },
{ label: 'Apply rough map from tempo zones', fn: 'editorApplyTempoZones', audioOnly: true },
{ label: 'Map Health (grid-vs-recording drift)', fn: 'editorToggleMapHealth', audioOnly: true },
diff --git a/src/section-sync-ui.js b/src/section-sync-ui.js
new file mode 100644
index 0000000..67aa248
--- /dev/null
+++ b/src/section-sync-ui.js
@@ -0,0 +1,181 @@
+// Per-section sync results UI (#26) — sub-issue 5/5 of per-section syncing (#8).
+//
+// Presents the #24 analysis one section at a time: a row with the section name,
+// its confidence badge, and whether the section will be applied or skipped.
+// Low-confidence sections default to SKIP and are never applied unless the user
+// explicitly selects them; failed sections are not selectable at all. "Apply all
+// high-confidence" re-selects just the trusted sections; "Apply" bakes the
+// current selection through the same `applyPerSectionSync` path as #25, so a
+// preview and the committed edit are one map. A single-section song collapses to
+// the uniform model — one full-domain row.
+//
+// Pure decisions live in `section-sync.js` (`_sectionSyncPlanPure`); this module
+// only gathers onsets, renders, and hands the plan to the apply path. The
+// window.editor* entry points are attached by main.js (the repo convention).
+
+import { S } from './state.js';
+import { _ensureOnsetsShifted } from './audio.js';
+import { applyPerSectionSync } from './tempo.js';
+import { _alignSectionsPure, _sectionSyncPlanPure } from './section-sync.js';
+import { _editorEscHtml, _installModalKeyboard, setStatus } from './ui.js';
+import { host } from './host.js';
+
+let _analysis = null;
+let _decisions = null; // section index -> boolean (true = apply, false = skip)
+let _prevFocus = null;
+let _kbInstalled = false;
+
+const MODAL_ID = 'editor-section-sync-modal';
+
+function _modal() {
+ return typeof document === 'undefined' ? null : document.getElementById(MODAL_ID);
+}
+
+export function editorShowSectionSync() {
+ if (typeof document === 'undefined') return;
+ if (S.beats.length < 2) { setStatus('Need at least two beats to sync.'); return; }
+ if (!S.audioBuffer && !S.waveformPeaks) {
+ setStatus('Load audio before per-section sync.');
+ return;
+ }
+ const onsets = _ensureOnsetsShifted() || [];
+ const analysis = _alignSectionsPure({
+ sections: S.sections || [],
+ beats: S.beats,
+ source: { arrangements: S.arrangements, drumTab: S.drumTab },
+ audioOnsets: onsets,
+ });
+ if (!analysis.ok) {
+ setStatus(`Per-section sync unavailable (${analysis.reason}).`);
+ return;
+ }
+ _analysis = analysis;
+ _decisions = new Map();
+ const modal = _modal();
+ if (!modal) return;
+ _renderSectionSync();
+ _prevFocus = document.activeElement;
+ modal.classList.remove('hidden');
+ if (!_kbInstalled) {
+ _kbInstalled = true;
+ // The panel (firstElementChild) owns the focusables; the overlay root
+ // only needs to stay focusable so a backdrop click can be redirected in.
+ _installModalKeyboard(modal, modal.firstElementChild, editorHideSectionSync);
+ }
+ const applyBtn = document.getElementById('editor-section-sync-apply');
+ if (applyBtn && applyBtn.focus) applyBtn.focus();
+ setStatus('Review per-section sync results.');
+}
+
+export function editorHideSectionSync() {
+ const modal = _modal();
+ if (modal) modal.classList.add('hidden');
+ const prev = _prevFocus;
+ _prevFocus = null;
+ if (prev && prev.isConnected && prev.focus) prev.focus();
+}
+
+export function editorToggleSectionSync(index) {
+ if (!_analysis || !_decisions) return;
+ const row = _sectionSyncPlanPure(_analysis, _decisions)
+ .rows.find((r) => r.index === index);
+ if (!row) return;
+ if (!row.forceable) {
+ setStatus('That section has no usable alignment to apply.');
+ return;
+ }
+ _decisions.set(index, !row.accepted);
+ _renderSectionSync();
+}
+
+export function editorApplySectionSync(allHighConfidence) {
+ if (!_analysis) return;
+ // "All high-confidence" is the default decision set: the confidence floor
+ // alone decides, i.e. low-confidence sections are skipped.
+ const decisions = allHighConfidence ? new Map() : _decisions;
+ const plan = _sectionSyncPlanPure(_analysis, decisions);
+ if (!plan.ok) { setStatus('Per-section sync analysis is unavailable.'); return; }
+ if (plan.conflicts.length) {
+ setStatus('Cannot apply: a skipped section conflicts with the fit.');
+ return;
+ }
+ if (!plan.summary.applied) { setStatus('No sections selected to apply.'); return; }
+ const res = applyPerSectionSync({
+ proposals: plan.proposals,
+ confidenceFloor: plan.confidenceFloor,
+ domain: plan.domain,
+ });
+ if (!res || !res.applied) {
+ setStatus(`Per-section sync refused (${(res && res.reason) || 'unknown'}).`);
+ return;
+ }
+ const n = plan.summary.applied;
+ editorHideSectionSync();
+ host.draw();
+ setStatus(`Applied per-section sync to ${n} section${n === 1 ? '' : 's'}.`);
+}
+
+// ── Rendering ──────────────────────────────────────────────────────────────
+
+function _tierBadge(row) {
+ const cls = row.tier === 'high'
+ ? 'bg-emerald-500/20 text-emerald-300'
+ : row.tier === 'low'
+ ? 'bg-amber-500/20 text-amber-300'
+ : 'bg-red-500/20 text-red-300';
+ return ``
+ + `${row.confidencePct}%`;
+}
+
+function _rowDetail(row) {
+ if (!row.accepted) {
+ return row.tier === 'high' ? 'skipped'
+ : row.tier === 'low' ? 'below threshold' : 'no match';
+ }
+ const shift = row.shift >= 0 ? `+${row.shift.toFixed(2)}` : row.shift.toFixed(2);
+ return `×${row.factor.toFixed(3)} · ${shift}s`;
+}
+
+function _sectionRowHtml(row) {
+ const toggle = row.forceable
+ ? ``
+ : '—';
+ return '
`;
+}
+
+function _summaryText(plan) {
+ const s = plan.summary;
+ if (!s.sections) return 'No sections to sync.';
+ const parts = [`${s.applied}/${s.sections} selected`];
+ if (s.low) parts.push(`${s.low} low-confidence skipped`);
+ if (s.failed) parts.push(`${s.failed} unmatched`);
+ return parts.join(' · ');
+}
+
+function _renderSectionSync() {
+ if (typeof document === 'undefined') return;
+ const plan = _sectionSyncPlanPure(_analysis, _decisions);
+ const rowsEl = document.getElementById('editor-section-sync-rows');
+ if (rowsEl) rowsEl.innerHTML = plan.rows.map(_sectionRowHtml).join('');
+ const summaryEl = document.getElementById('editor-section-sync-summary');
+ if (summaryEl) summaryEl.textContent = _summaryText(plan);
+ const applyBtn = document.getElementById('editor-section-sync-apply');
+ if (applyBtn) {
+ const n = plan.summary.applied;
+ applyBtn.disabled = n === 0;
+ applyBtn.textContent = n > 0
+ ? `Apply ${n} section${n === 1 ? '' : 's'}`
+ : 'Apply selected';
+ }
+ const highBtn = document.getElementById('editor-section-sync-apply-high');
+ if (highBtn) highBtn.disabled = plan.summary.high === 0;
+}
diff --git a/src/section-sync.js b/src/section-sync.js
index 8287412..1eeeac0 100644
--- a/src/section-sync.js
+++ b/src/section-sync.js
@@ -35,6 +35,14 @@ import { _detectTempoFromOnsetsPure } from './sync-tempo.js';
const EPS = 1e-9;
+// The confidence a section must clear before it is trusted (applied) WITHOUT an
+// explicit user choice (#26). The engine's own analysis floor is looser (0.5,
+// so a low-confidence section still surfaces for review); applying/baking uses
+// this stricter default so a "review and apply" run never silently carries a
+// marginal section. The review UI may override it per section (see
+// `_proposalAcceptedPure`'s `selected: true`), never for the whole run at once.
+export const SECTION_SYNC_CONFIDENCE_FLOOR = 0.7;
+
function _clamp01(v) { return v < 0 ? 0 : (v > 1 ? 1 : v); }
function _round4(v) { return Math.round(v * 1e4) / 1e4; }
function _median(sortedOrRaw) {
@@ -609,10 +617,18 @@ export function _alignSectionsPure(params) {
* slope. A nonpositive slope would make the map non-monotone — event order
* could reverse — so it is treated as inapplicable and its section skipped,
* matching the design's rejection rule (PER-SECTION-SYNC-DESIGN.md L78-80).
+ *
+ * An explicit `selected` override from the #26 review UI wins over the
+ * confidence floor: `selected: false` is a hard skip (never applied), and
+ * `selected: true` forces a low-confidence (but otherwise usable) section
+ * through. The affine-validity checks are NOT overridable — a section with no
+ * finite, positive-slope line can never be applied, forced or not.
*/
-function _proposalAcceptedPure(p, confidenceFloor) {
+export function _proposalAcceptedPure(p, confidenceFloor) {
+ if (!p || p.selected === false) return false;
+ const forced = p.selected === true;
return Boolean(p.ok)
- && p.confidence >= confidenceFloor
+ && (forced || p.confidence >= confidenceFloor)
&& Number.isFinite(p.slope) && p.slope > 0
&& Number.isFinite(p.intercept);
}
@@ -719,6 +735,7 @@ export function _buildPiecewiseMapPure(proposals, domain, confidenceFloor = 0.7)
const intervals = sorted.map((p) => {
const accepted = _proposalAcceptedPure(p, confidenceFloor);
return {
+ index: p.index,
start: p.start,
end: p.end,
slope: accepted ? p.slope : 1,
@@ -736,4 +753,101 @@ export function _buildPiecewiseMapPure(proposals, domain, confidenceFloor = 0.7)
map: (t) => _piecewiseAtPure(intervals, t),
};
}
+
+/**
+ * A human label for a review row: the section's authored name, else its number,
+ * else a positional fallback. An unnamed region in a multi-region song is the
+ * explicit lead-in before the first marker; an unnamed single region is the
+ * whole song (the uniform case).
+ */
+function _sectionLabelPure(p, count) {
+ if (p.name) return p.name;
+ if (Number.isFinite(p.number) && p.number) return `Section ${p.number}`;
+ if (count > 1) return p.index === 0 ? 'Lead-in' : `Section ${p.index + 1}`;
+ return 'Full song';
+}
+
+/**
+ * The #26 review model: given an `_alignSectionsPure` result and the user's
+ * per-section decisions, build the map "Apply" would commit plus one row per
+ * section for the UI. `decisions` maps a section `index` to `true` (apply) or
+ * `false` (skip); an ABSENT index follows the confidence default, so a
+ * low-confidence section is never applied unless the user explicitly selects
+ * it — the "default never applies low confidence" acceptance rule. A section
+ * with no usable fit (`ok:false`) is not selectable at all.
+ *
+ * Returns `{ok, reason, domain, confidenceFloor, proposals, map, rows,
+ * conflicts, summary}`. `proposals` are the decisions folded in and are
+ * exactly what the apply path (`applyPerSectionSync`) takes, so the preview and
+ * the committed edit are the same map.
+ */
+export function _sectionSyncPlanPure(analysis, decisions, options = {}) {
+ const confidenceFloor = options.confidenceFloor != null
+ ? options.confidenceFloor : SECTION_SYNC_CONFIDENCE_FLOOR;
+ if (!analysis || !analysis.ok) {
+ return {
+ ok: false,
+ reason: analysis && analysis.reason ? analysis.reason : 'no-analysis',
+ domain: analysis ? analysis.domain : null,
+ confidenceFloor,
+ proposals: [],
+ map: null,
+ rows: [],
+ conflicts: [],
+ summary: { sections: 0, applied: 0, high: 0, low: 0, failed: 0 },
+ };
+ }
+ const overrides = decisions || {};
+ const proposals = (analysis.proposals || []).map((p) => {
+ const over = overrides instanceof Map ? overrides.get(p.index) : overrides[p.index];
+ return over === undefined ? p : { ...p, selected: !!over };
+ });
+ const map = _buildPiecewiseMapPure(proposals, analysis.domain, confidenceFloor);
+ const intervalByIndex = new Map((map.intervals || []).map((iv) => [iv.index, iv]));
+ const rows = proposals.map((p) => {
+ const iv = intervalByIndex.get(p.index);
+ const trusted = _proposalAcceptedPure({ ...p, selected: undefined }, confidenceFloor);
+ const accepted = Boolean(iv && iv.accepted);
+ const slope = accepted && Number.isFinite(iv.slope) ? iv.slope : 1;
+ const intercept = accepted && Number.isFinite(iv.intercept) ? iv.intercept : 0;
+ const confidence = _clamp01(p.confidence || 0);
+ return {
+ index: p.index,
+ name: p.name || '',
+ number: Number.isFinite(p.number) ? p.number : 0,
+ label: _sectionLabelPure(p, proposals.length),
+ start: p.start,
+ end: p.end,
+ confidence,
+ confidencePct: Math.round(confidence * 100),
+ reason: p.reason || null,
+ // high = trusted by the confidence floor, low = analysed but below
+ // it, failed = no usable fit. A low row can still be forced on (see
+ // _proposalAcceptedPure); a failed row is not selectable.
+ tier: trusted ? 'high' : (p.ok ? 'low' : 'failed'),
+ forceable: _proposalAcceptedPure({ ...p, selected: true }, confidenceFloor),
+ selected: accepted,
+ accepted,
+ factor: _round4(Number.isFinite(p.factor) ? p.factor : 1),
+ shift: _round4(slope * p.start + intercept - p.start),
+ };
+ });
+ return {
+ ok: true,
+ reason: null,
+ domain: analysis.domain,
+ confidenceFloor,
+ proposals,
+ map,
+ conflicts: map.conflicts || [],
+ rows,
+ summary: {
+ sections: rows.length,
+ applied: rows.filter((r) => r.accepted).length,
+ high: rows.filter((r) => r.tier === 'high').length,
+ low: rows.filter((r) => r.tier === 'low').length,
+ failed: rows.filter((r) => r.tier === 'failed').length,
+ },
+ };
+}
/* @pure:section-sync:end */
diff --git a/tests/section_sync_ui.test.mjs b/tests/section_sync_ui.test.mjs
new file mode 100644
index 0000000..7e20b09
--- /dev/null
+++ b/tests/section_sync_ui.test.mjs
@@ -0,0 +1,198 @@
+/*
+ * Per-section sync REVIEW model (#26): the pure decision layer the review UI
+ * renders and applies.
+ *
+ * Pins the acceptance rules from PER-SECTION-SYNC-DESIGN.md L150-158 that a
+ * builder test alone can't reach:
+ * • a low-confidence section is skipped by DEFAULT — the default action never
+ * applies it (design's do-no-harm rule, #22);
+ * • explicitly selecting a low-confidence section applies it; deselecting a
+ * trusted one skips it;
+ * • a section with no usable fit is never selectable;
+ * • a single-section song collapses to one full-domain row;
+ * • the review plan is the SAME map `applyPerSectionSync` commits, so a
+ * skipped section's notes keep their exact seconds while selected ones bake.
+ *
+ * Run: node tests/section_sync_ui.test.mjs
+ */
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { EditHistory } from '../src/history.js';
+import { seedState } from './_history_env.mjs';
+import { applyPerSectionSync } from '../src/tempo.js';
+import {
+ SECTION_SYNC_CONFIDENCE_FLOOR, _proposalAcceptedPure, _sectionSyncPlanPure,
+} from '../src/section-sync.js';
+
+function prop(index, start, end, opts = {}) {
+ const ok = opts.ok !== false;
+ const confidence = opts.confidence != null ? opts.confidence : 0.9;
+ return {
+ ok,
+ index,
+ name: opts.name !== undefined ? opts.name : `Part ${index}`,
+ number: opts.number !== undefined ? opts.number : index + 1,
+ start,
+ end,
+ reason: opts.reason || (ok && confidence >= SECTION_SYNC_CONFIDENCE_FLOOR ? null : 'low-confidence'),
+ confidence,
+ factor: opts.factor != null ? opts.factor : 2,
+ slope: opts.slope != null ? opts.slope : 0.5,
+ intercept: opts.intercept != null ? opts.intercept : 0,
+ };
+}
+
+function analysis(props) {
+ return { ok: true, reason: null, domain: { start: 0, end: 30 }, proposals: props };
+}
+
+test('the default decision applies trusted sections and skips low-confidence ones', () => {
+ const a = analysis([
+ prop(0, 0, 10, { confidence: 0.9, name: 'Intro' }),
+ prop(1, 10, 20, { confidence: 0.3, name: 'Solo' }),
+ prop(2, 20, 30, { confidence: 0.8, name: 'Outro' }),
+ ]);
+ const plan = _sectionSyncPlanPure(a);
+ assert.strictEqual(plan.ok, true);
+ assert.strictEqual(plan.confidenceFloor, SECTION_SYNC_CONFIDENCE_FLOOR);
+ const [intro, solo, outro] = plan.rows;
+ assert.strictEqual(intro.tier, 'high');
+ assert.strictEqual(intro.accepted, true, 'trusted section applies by default');
+ assert.strictEqual(solo.tier, 'low');
+ assert.strictEqual(solo.accepted, false, 'low-confidence section is skipped by default');
+ assert.strictEqual(solo.selected, false);
+ assert.strictEqual(solo.forceable, true, 'a low-confidence section can be forced on');
+ assert.strictEqual(outro.accepted, true);
+ assert.deepStrictEqual(plan.conflicts, []);
+});
+
+test('an explicit decision overrides the confidence default both ways', () => {
+ const a = analysis([
+ prop(0, 0, 10, { confidence: 0.9 }),
+ prop(1, 10, 20, { confidence: 0.3 }),
+ ]);
+ // Force the low-confidence section on.
+ const forced = _sectionSyncPlanPure(a, new Map([[1, true]]));
+ assert.strictEqual(forced.rows[1].accepted, true, 'selected low-confidence section applies');
+ assert.ok(forced.rows[1].tier === 'low', 'it stays labelled low even when forced');
+ // Skip the trusted section.
+ const skipped = _sectionSyncPlanPure(a, new Map([[0, false]]));
+ assert.strictEqual(skipped.rows[0].accepted, false, 'deselected trusted section is skipped');
+});
+
+test('a section with no usable fit is never selectable, even when forced', () => {
+ const a = analysis([
+ prop(0, 0, 10, { ok: false, confidence: 0, name: 'Broken' }),
+ ]);
+ const plan = _sectionSyncPlanPure(a, new Map([[0, true]]));
+ const row = plan.rows[0];
+ assert.strictEqual(row.tier, 'failed');
+ assert.strictEqual(row.forceable, false);
+ assert.strictEqual(row.accepted, false, 'forcing a failed section does nothing');
+ assert.strictEqual(plan.summary.applied, 0);
+});
+
+test('a single-section song collapses to one full-domain row', () => {
+ const a = analysis([prop(0, 0, 30, { confidence: 0.85, name: '', number: 0 })]);
+ const plan = _sectionSyncPlanPure(a);
+ assert.strictEqual(plan.rows.length, 1);
+ assert.strictEqual(plan.rows[0].label, 'Full song');
+ assert.strictEqual(plan.rows[0].accepted, true);
+ assert.strictEqual(plan.summary.applied, 1);
+ // Uniform flow: the lone accepted section keeps its affine map (no pins).
+ assert.strictEqual(plan.map.map(10), 5);
+ assert.strictEqual(plan.map.map(20), 10);
+});
+
+test('a run pinned to identity at both ends surfaces a conflict the UI can block on', () => {
+ const a = analysis([
+ prop(0, 0, 10, { confidence: 0.3 }),
+ prop(1, 10, 20, { confidence: 0.9, slope: 2 }),
+ prop(2, 20, 30, { confidence: 0.3 }),
+ ]);
+ const plan = _sectionSyncPlanPure(a);
+ assert.strictEqual(plan.conflicts.length, 1);
+ assert.strictEqual(plan.conflicts[0].reason, 'identity-conflict');
+ assert.strictEqual(plan.summary.applied, 0);
+});
+
+test('_proposalAcceptedPure honours selected overrides but never affine validity', () => {
+ assert.strictEqual(_proposalAcceptedPure(prop(0, 0, 10, { confidence: 0.1 }), 0.7), false);
+ assert.strictEqual(_proposalAcceptedPure({ ...prop(0, 0, 10, { confidence: 0.1 }), selected: true }, 0.7), true);
+ assert.strictEqual(_proposalAcceptedPure({ ...prop(0, 0, 10), selected: false }, 0.7), false);
+ // A nonpositive slope stays inapplicable even when forced.
+ assert.strictEqual(_proposalAcceptedPure({ ...prop(0, 0, 10, { slope: 0 }), selected: true }, 0.7), false);
+});
+
+// ── Integration: the review plan drives the same apply path as #25 ──────────
+
+function scenario() {
+ const s = seedState();
+ s.beats = [];
+ for (let t = 0; t <= 30; t += 5) s.beats.push({ time: t, measure: -1, den: 4 });
+ s.sections = [
+ { name: 'Intro', number: 1, start_time: 0 },
+ { name: 'Solo', number: 2, start_time: 10 },
+ ];
+ s.arrangements = [{
+ notes: [
+ { time: 5, sustain: 1, midi: 60 }, // Intro [0,10) — low confidence
+ { time: 12, sustain: 1, midi: 62 }, // Solo [10,30) — trusted
+ { time: 22, sustain: 1, midi: 64 }, // Solo [10,30) — trusted
+ ],
+ chords: [], anchors: [], anchors_user: [], handshapes: [], phrases: [],
+ }];
+ s.drumTab = null;
+ s.history = new EditHistory();
+ return s;
+}
+
+test('a skipped low-confidence section keeps its notes while neighbours bake', () => {
+ const s = scenario();
+ const a = {
+ ok: true,
+ domain: { start: 0, end: 30 },
+ proposals: [
+ prop(0, 0, 10, { confidence: 0.2, slope: 2, name: 'Intro' }),
+ prop(1, 10, 30, { confidence: 0.9, slope: 2, name: 'Solo' }),
+ ],
+ };
+ const plan = _sectionSyncPlanPure(a);
+ assert.strictEqual(plan.rows[0].accepted, false, 'Intro defaults to skip');
+ assert.strictEqual(plan.rows[1].accepted, true, 'Solo applies');
+ assert.deepStrictEqual(plan.conflicts, []);
+
+ const res = applyPerSectionSync({
+ proposals: plan.proposals,
+ confidenceFloor: plan.confidenceFloor,
+ domain: plan.domain,
+ });
+ assert.strictEqual(res.applied, true);
+ const notes = s.arrangements[0].notes;
+ assert.strictEqual(notes[0].time, 5, 'skipped section note untouched');
+ assert.strictEqual(notes[1].time, 14, 'trusted section note baked');
+ assert.strictEqual(notes[2].time, 34, 'trusted section note baked');
+});
+
+test('selecting the low-confidence section applies it through the same plan', () => {
+ const s = scenario();
+ const a = {
+ ok: true,
+ domain: { start: 0, end: 30 },
+ proposals: [
+ prop(0, 0, 10, { confidence: 0.2, slope: 2, name: 'Intro' }),
+ prop(1, 10, 30, { confidence: 0.9, slope: 2, name: 'Solo' }),
+ ],
+ };
+ const plan = _sectionSyncPlanPure(a, new Map([[0, true]]));
+ assert.strictEqual(plan.rows[0].accepted, true, 'selected low-confidence section applies');
+ const res = applyPerSectionSync({
+ proposals: plan.proposals,
+ confidenceFloor: plan.confidenceFloor,
+ domain: plan.domain,
+ });
+ assert.strictEqual(res.applied, true);
+ const notes = s.arrangements[0].notes;
+ assert.strictEqual(notes[0].time, 10, 'selected section note baked (2× at 0)');
+ assert.strictEqual(notes[1].time, 24, 'selected run maps continuously');
+});
From 332a1146caf9ef9c16bf8c4f0a7e1aea4e44d437 Mon Sep 17 00:00:00 2001
From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com>
Date: Mon, 12 Oct 2026 16:31:41 +0000
Subject: [PATCH 2/4] fix(sync): guard per-section review when no onsets are
detected
---
src/section-sync-ui.js | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/src/section-sync-ui.js b/src/section-sync-ui.js
index 67aa248..fdd5ce2 100644
--- a/src/section-sync-ui.js
+++ b/src/section-sync-ui.js
@@ -39,6 +39,10 @@ export function editorShowSectionSync() {
return;
}
const onsets = _ensureOnsetsShifted() || [];
+ if (!onsets.length) {
+ setStatus('No audio onsets detected yet — cannot per-section sync.');
+ return;
+ }
const analysis = _alignSectionsPure({
sections: S.sections || [],
beats: S.beats,
From 8423af3e968e1e9c3e5cad0ce7a28688175ef1d9 Mon Sep 17 00:00:00 2001
From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com>
Date: Mon, 12 Oct 2026 16:39:27 +0000
Subject: [PATCH 3/4] fix(sync): per-section review focus trap, decision
tracking and summary
Self-review fixes for #26:
- Restore focus inside the modal after a toggle re-renders the rows
container (previously focus fell to , so Escape/Tab and global
shortcuts leaked through the overlay), and fall back to the primary
control / modal root when the disabled Apply button refuses focus.
- Track the decision INTENT (`wanted`) separately from the conflict-demoted
acceptance so a both-ends-pinned section is not toggle-stuck; show it as
`conflict` and let the user skip it to clear.
- Count only genuinely-skipped low-confidence sections in the summary
(a forced-on section no longer reads as skipped).
- Disable 'Apply all high-confidence' when a conflict blocks Apply.
- Preserve the rows scroll position across re-renders; add dialog ARIA.
- Add a DOM-driven suite pinning the focus/summary/apply/Escape behaviour.
---
screen.html | 13 +-
src/section-sync-ui.js | 62 +++++++--
src/section-sync.js | 7 +
tests/section_sync_ui.test.mjs | 13 ++
tests/section_sync_ui_dom.test.mjs | 197 +++++++++++++++++++++++++++++
5 files changed, 276 insertions(+), 16 deletions(-)
create mode 100644 tests/section_sync_ui_dom.test.mjs
diff --git a/screen.html b/screen.html
index 2de7994..3a34089 100644
--- a/screen.html
+++ b/screen.html
@@ -886,10 +886,11 @@
never applied unless explicitly selected; "Apply all high-confidence" is the
safe default. -->
-
+
- Per-section sync
-
+ Per-section sync
+
Review each section's alignment. Low-confidence sections are skipped by
@@ -898,11 +899,11 @@
-
-
-
diff --git a/src/section-sync-ui.js b/src/section-sync-ui.js
index fdd5ce2..92b58e6 100644
--- a/src/section-sync-ui.js
+++ b/src/section-sync-ui.js
@@ -31,6 +31,22 @@ function _modal() {
return typeof document === 'undefined' ? null : document.getElementById(MODAL_ID);
}
+// Focus a live control in the modal. A disabled button refuses focus, so fall
+// back through the primary action, any enabled control, then the modal root
+// (which is `tabIndex = -1` and inside range of the keydown trap).
+function _focusModal() {
+ if (typeof document === 'undefined') return;
+ const modal = _modal();
+ if (!modal) return;
+ const apply = document.getElementById('editor-section-sync-apply');
+ if (apply && !apply.disabled && apply.focus) { apply.focus(); return; }
+ if (typeof modal.querySelector === 'function') {
+ const first = modal.querySelector('button:not([disabled])');
+ if (first && first.focus) { first.focus(); return; }
+ }
+ if (modal.focus) modal.focus();
+}
+
export function editorShowSectionSync() {
if (typeof document === 'undefined') return;
if (S.beats.length < 2) { setStatus('Need at least two beats to sync.'); return; }
@@ -66,8 +82,7 @@ export function editorShowSectionSync() {
// only needs to stay focusable so a backdrop click can be redirected in.
_installModalKeyboard(modal, modal.firstElementChild, editorHideSectionSync);
}
- const applyBtn = document.getElementById('editor-section-sync-apply');
- if (applyBtn && applyBtn.focus) applyBtn.focus();
+ _focusModal();
setStatus('Review per-section sync results.');
}
@@ -88,8 +103,26 @@ export function editorToggleSectionSync(index) {
setStatus('That section has no usable alignment to apply.');
return;
}
- _decisions.set(index, !row.accepted);
+ // Flip relative to the DECISION intent, not the conflict-demoted acceptance:
+ // an explicitly-skipped section that a conflict would otherwise hide must
+ // still toggle back on, and a demoted run must not become toggle-stuck.
+ _decisions.set(index, !row.wanted);
_renderSectionSync();
+ // The re-render replaced the rows container, so the clicked button is gone
+ // and focus fell to — which would let global shortcuts through the
+ // overlay. Put focus back on this row's toggle (or a safe fallback).
+ _focusSectionToggle(index);
+}
+
+// Restore focus to a row's toggle after a re-render, falling back inside the
+// modal. `document.querySelector` is absent in some DOM stubs; the fallback
+// covers that.
+function _focusSectionToggle(index) {
+ if (typeof document === 'undefined') return;
+ const btn = typeof document.querySelector === 'function'
+ ? document.querySelector(`[data-section-ss="${index}"]`) : null;
+ if (btn && btn.focus) { btn.focus(); return; }
+ _focusModal();
}
export function editorApplySectionSync(allHighConfidence) {
@@ -100,7 +133,7 @@ export function editorApplySectionSync(allHighConfidence) {
const plan = _sectionSyncPlanPure(_analysis, decisions);
if (!plan.ok) { setStatus('Per-section sync analysis is unavailable.'); return; }
if (plan.conflicts.length) {
- setStatus('Cannot apply: a skipped section conflicts with the fit.');
+ setStatus('Cannot apply: a section conflicts with the fit. Skip it and try again.');
return;
}
if (!plan.summary.applied) { setStatus('No sections selected to apply.'); return; }
@@ -132,6 +165,7 @@ function _tierBadge(row) {
}
function _rowDetail(row) {
+ if (row.conflict) return 'conflict';
if (!row.accepted) {
return row.tier === 'high' ? 'skipped'
: row.tier === 'low' ? 'below threshold' : 'no match';
@@ -144,9 +178,9 @@ function _sectionRowHtml(row) {
const toggle = row.forceable
? ``
+ + ` aria-pressed="${row.wanted}" class="w-16 px-2 py-1 rounded text-[11px] `
+ + `${row.wanted ? 'bg-accent hover:bg-accent-light' : 'bg-dark-600 hover:bg-dark-500'}">`
+ + `${row.wanted ? 'Apply' : 'Skip'}`
: '—';
return '
'
+ ''
@@ -160,8 +194,12 @@ function _summaryText(plan) {
const s = plan.summary;
if (!s.sections) return 'No sections to sync.';
const parts = [`${s.applied}/${s.sections} selected`];
- if (s.low) parts.push(`${s.low} low-confidence skipped`);
+ // Count only low-confidence sections that are NOT being applied — a section
+ // the user explicitly selected must not read as "skipped".
+ const lowSkipped = plan.rows.filter((r) => r.tier === 'low' && !r.accepted).length;
+ if (lowSkipped) parts.push(`${lowSkipped} low-confidence skipped`);
if (s.failed) parts.push(`${s.failed} unmatched`);
+ if (plan.conflicts.length) parts.push(`${plan.conflicts.length} conflict`);
return parts.join(' · ');
}
@@ -169,7 +207,11 @@ function _renderSectionSync() {
if (typeof document === 'undefined') return;
const plan = _sectionSyncPlanPure(_analysis, _decisions);
const rowsEl = document.getElementById('editor-section-sync-rows');
- if (rowsEl) rowsEl.innerHTML = plan.rows.map(_sectionRowHtml).join('');
+ if (rowsEl) {
+ const scroll = rowsEl.scrollTop || 0;
+ rowsEl.innerHTML = plan.rows.map(_sectionRowHtml).join('');
+ rowsEl.scrollTop = scroll;
+ }
const summaryEl = document.getElementById('editor-section-sync-summary');
if (summaryEl) summaryEl.textContent = _summaryText(plan);
const applyBtn = document.getElementById('editor-section-sync-apply');
@@ -181,5 +223,5 @@ function _renderSectionSync() {
: 'Apply selected';
}
const highBtn = document.getElementById('editor-section-sync-apply-high');
- if (highBtn) highBtn.disabled = plan.summary.high === 0;
+ if (highBtn) highBtn.disabled = plan.summary.high === 0 || plan.conflicts.length > 0;
}
diff --git a/src/section-sync.js b/src/section-sync.js
index 1eeeac0..aeecc6d 100644
--- a/src/section-sync.js
+++ b/src/section-sync.js
@@ -807,6 +807,11 @@ export function _sectionSyncPlanPure(analysis, decisions, options = {}) {
const rows = proposals.map((p) => {
const iv = intervalByIndex.get(p.index);
const trusted = _proposalAcceptedPure({ ...p, selected: undefined }, confidenceFloor);
+ // `wanted` is the decision intent (confidence default + explicit
+ // override, ignoring conflict demotion); `accepted` is what the builder
+ // actually commits after run continuity. They differ when a run is
+ // demoted to identity by a conflict, which the UI shows as a conflict.
+ const wanted = _proposalAcceptedPure(p, confidenceFloor);
const accepted = Boolean(iv && iv.accepted);
const slope = accepted && Number.isFinite(iv.slope) ? iv.slope : 1;
const intercept = accepted && Number.isFinite(iv.intercept) ? iv.intercept : 0;
@@ -826,6 +831,8 @@ export function _sectionSyncPlanPure(analysis, decisions, options = {}) {
// _proposalAcceptedPure); a failed row is not selectable.
tier: trusted ? 'high' : (p.ok ? 'low' : 'failed'),
forceable: _proposalAcceptedPure({ ...p, selected: true }, confidenceFloor),
+ wanted,
+ conflict: wanted && !accepted,
selected: accepted,
accepted,
factor: _round4(Number.isFinite(p.factor) ? p.factor : 1),
diff --git a/tests/section_sync_ui.test.mjs b/tests/section_sync_ui.test.mjs
index 7e20b09..c88af0f 100644
--- a/tests/section_sync_ui.test.mjs
+++ b/tests/section_sync_ui.test.mjs
@@ -74,10 +74,12 @@ test('an explicit decision overrides the confidence default both ways', () => {
// Force the low-confidence section on.
const forced = _sectionSyncPlanPure(a, new Map([[1, true]]));
assert.strictEqual(forced.rows[1].accepted, true, 'selected low-confidence section applies');
+ assert.strictEqual(forced.rows[1].wanted, true);
assert.ok(forced.rows[1].tier === 'low', 'it stays labelled low even when forced');
// Skip the trusted section.
const skipped = _sectionSyncPlanPure(a, new Map([[0, false]]));
assert.strictEqual(skipped.rows[0].accepted, false, 'deselected trusted section is skipped');
+ assert.strictEqual(skipped.rows[0].wanted, false);
});
test('a section with no usable fit is never selectable, even when forced', () => {
@@ -114,6 +116,17 @@ test('a run pinned to identity at both ends surfaces a conflict the UI can block
assert.strictEqual(plan.conflicts.length, 1);
assert.strictEqual(plan.conflicts[0].reason, 'identity-conflict');
assert.strictEqual(plan.summary.applied, 0);
+ // The middle row still WANTS to apply (trusted, no user decision) but the
+ // conflict demotes it — the UI distinguishes the two so its toggle is not
+ // stuck reading "Skip".
+ const middle = plan.rows[1];
+ assert.strictEqual(middle.wanted, true);
+ assert.strictEqual(middle.accepted, false);
+ assert.strictEqual(middle.conflict, true);
+ // Skipping the middle section (the decision intent) removes the conflict.
+ const skipped = _sectionSyncPlanPure(a, new Map([[1, false]]));
+ assert.deepStrictEqual(skipped.conflicts, []);
+ assert.strictEqual(skipped.rows[1].wanted, false);
});
test('_proposalAcceptedPure honours selected overrides but never affine validity', () => {
diff --git a/tests/section_sync_ui_dom.test.mjs b/tests/section_sync_ui_dom.test.mjs
new file mode 100644
index 0000000..a7ef061
--- /dev/null
+++ b/tests/section_sync_ui_dom.test.mjs
@@ -0,0 +1,197 @@
+/*
+ * Per-section sync REVIEW UI (#26) — the DOM wiring behind the pure plan.
+ *
+ * The pure decision layer is unit-tested in section_sync_ui.test.mjs; this
+ * drives the real src/section-sync-ui.js against a DOM stub so the interaction
+ * bugs a pure test can't see are pinned:
+ * • the default (all-low-confidence) plan disables Apply and still puts focus
+ * INSIDE the modal (a disabled button refuses focus — the code must fall
+ * back, or global shortcuts leak through the overlay);
+ * • toggling a row re-renders the rows container, so focus must be restored
+ * to the modal (not dropped to );
+ * • the summary counts only sections that are actually skipped;
+ * • Apply bakes the selection and closes the modal; Escape closes and
+ * restores the opener.
+ *
+ * Run: node tests/section_sync_ui_dom.test.mjs
+ */
+import assert from 'node:assert';
+
+// ── DOM stub (the sync_dialog_keyboard.test.mjs pattern) ─────────────
+const listeners = new Map();
+let activeEl = null;
+
+function mkEl(id, opts = {}) {
+ const children = [];
+ const classList = {
+ _s: new Set(),
+ add(c) { this._s.add(c); },
+ remove(c) { this._s.delete(c); },
+ toggle(c, on) { on ? this._s.add(c) : this._s.delete(c); },
+ contains(c) { return this._s.has(c); },
+ };
+ return {
+ id, className: opts.className || '', tagName: (opts.tag || 'DIV').toUpperCase(),
+ textContent: '', value: '', disabled: false, files: [],
+ style: {}, classList, children, parentNode: null,
+ tabIndex: 0, isConnected: true, scrollTop: 0,
+ _attrs: {},
+ setAttribute(k, v) { this._attrs[k] = v; },
+ getAttribute(k) { return this._attrs[k]; },
+ appendChild(c) { children.push(c); c.parentNode = this; return c; },
+ addEventListener(type, fn) {
+ if (!listeners.has(id)) listeners.set(id, []);
+ listeners.get(id).push({ type, fn });
+ },
+ removeEventListener() {},
+ focus() { activeEl = this; },
+ blur() { activeEl = null; },
+ contains(n) { return n === this || children.some((c) => c.contains && c.contains(n)); },
+ querySelectorAll(sel) {
+ const want = [];
+ if (sel.includes('button')) want.push('BUTTON');
+ if (sel.includes('input')) want.push('INPUT');
+ if (sel.includes('select')) want.push('SELECT');
+ if (sel.includes('textarea')) want.push('TEXTAREA');
+ return this._all().filter((e) => want.includes(e.tagName));
+ },
+ querySelector(sel) { return this.querySelectorAll(sel)[0] || null; },
+ get firstElementChild() { return children[0] || null; },
+ _all() {
+ let out = [...children];
+ for (const c of children) out = out.concat(c._all ? c._all() : []);
+ return out;
+ },
+ get innerHTML() { return ''; },
+ set innerHTML(_v) { /* rows are HTML strings; the stub drops them */ },
+ };
+}
+
+const registry = new Map();
+function reg(id, el) { registry.set(id, el); return el; }
+function byId(id) {
+ if (!registry.has(id)) registry.set(id, mkEl(id));
+ return registry.get(id);
+}
+
+globalThis.document = {
+ getElementById: byId,
+ createElement: (t) => mkEl(t),
+ querySelector: () => null, // row toggles are innerHTML strings
+ querySelectorAll: () => [],
+ addEventListener() {},
+ removeEventListener() {},
+ body: { appendChild() {} },
+ get activeElement() { return activeEl; },
+};
+globalThis.window = globalThis.window || globalThis;
+globalThis.localStorage = globalThis.localStorage || { getItem: () => null, setItem: () => {} };
+
+// Shipped markup shape: overlay root → panel (firstElementChild) → controls.
+const modal = reg('editor-section-sync-modal', mkEl('modal', { className: 'absolute inset-0' }));
+const panel = mkEl('panel');
+const rowsEl = reg('editor-section-sync-rows', mkEl('rows'));
+const summaryEl = reg('editor-section-sync-summary', mkEl('summary'));
+const applyHigh = reg('editor-section-sync-apply-high', mkEl('apply-high', { tag: 'button' }));
+const applyBtn = reg('editor-section-sync-apply', mkEl('apply', { tag: 'button' }));
+const cancel = mkEl('cancel', { tag: 'button' });
+modal.appendChild(panel);
+panel.appendChild(rowsEl);
+panel.appendChild(summaryEl);
+panel.appendChild(applyHigh);
+panel.appendChild(applyBtn);
+panel.appendChild(cancel);
+reg('editor-status', mkEl('editor-status'));
+
+const { S } = await import('../src/state.js');
+const { EditHistory } = await import('../src/history.js');
+const { setHostHooks } = await import('../src/host.js');
+setHostHooks({ draw: () => {}, updateStatus: () => {}, ensureArr: () => true });
+
+// A single full-domain section reads low-confidence (periodic onsets vs an
+// off-grid melody) but is `ok` — exactly the forceable-but-skipped case.
+const rms = new Float32Array(60);
+for (let i = 0; i < rms.length; i++) rms[i] = i % 2 === 0 ? 1 : 0;
+Object.assign(S, {
+ beats: Array.from({ length: 61 }, (_, k) => ({ time: k * 0.5, measure: k % 4 === 0 ? k / 4 + 1 : -1, den: 4 })),
+ sections: [],
+ duration: 30,
+ waveformPeaks: { bins: 60, rms },
+ audioBuffer: null,
+ audioShift: 0,
+ activeAudioSourceOffset: 0,
+ activeAudioSourceId: 'master',
+ trackSession: { version: 2, tracks: [], removedSourceIds: [], tempoGuideSourceId: '', tempoGuideLocked: false, tempoGuideMode: 'audio' },
+ arrangements: [{
+ notes: Array.from({ length: 16 }, (_, k) => ({ time: 2 + k * 1.7, sustain: 1, midi: 60 })),
+ chords: [], anchors: [], anchors_user: [], handshapes: [], phrases: [],
+ }],
+ drumTab: null,
+ history: new EditHistory(),
+});
+
+const { editorShowSectionSync, editorToggleSectionSync, editorApplySectionSync } =
+ await import('../src/section-sync-ui.js');
+
+function dispatchKey(el, key) {
+ const evt = { key, target: el, preventDefault() {}, stopPropagation() {} };
+ for (const { type, fn } of (listeners.get(el.id) || [])) if (type === 'keydown') fn(evt);
+}
+
+let pass = 0, fail = 0;
+function t(name, fn) {
+ try { fn(); pass++; console.log(' ok ' + name); }
+ catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); }
+}
+
+// ── Tests ───────────────────────────────────────────────────────────
+t('opening shows the modal, disables Apply on an all-skipped plan, and focuses inside', () => {
+ activeEl = null;
+ editorShowSectionSync();
+ assert.ok(!modal.classList.contains('hidden'), 'modal visible');
+ assert.match(summaryEl.textContent, /0\/1 selected/, 'summary counts 0 applied');
+ assert.match(summaryEl.textContent, /1 low-confidence skipped/, 'low section reported skipped');
+ assert.strictEqual(applyBtn.disabled, true, 'Apply disabled with nothing selected');
+ assert.ok(activeEl && activeEl !== document.body, 'focus moved into the modal despite disabled Apply');
+ assert.strictEqual(panel.contains(activeEl) || activeEl === modal, true, 'focus is inside the modal');
+});
+
+t('toggling the low-confidence section selects it, enables Apply and keeps focus in the modal', () => {
+ editorToggleSectionSync(0);
+ assert.match(summaryEl.textContent, /1\/1 selected/, 'summary counts the forced section');
+ assert.doesNotMatch(summaryEl.textContent, /skipped/, 'no skipped count once selected');
+ assert.strictEqual(applyBtn.disabled, false, 'Apply enabled after selecting');
+ assert.ok(activeEl && activeEl !== document.body, 'focus restored after the re-render');
+ assert.strictEqual(panel.contains(activeEl) || activeEl === modal, true, 'focus stays inside the modal');
+});
+
+t('"Apply all high-confidence" on an all-low song does not bake', () => {
+ activeEl = null;
+ editorShowSectionSync();
+ const before = S.arrangements[0].notes[0].time;
+ editorApplySectionSync(true);
+ assert.strictEqual(S.arrangements[0].notes[0].time, before, 'nothing baked');
+ assert.ok(!modal.classList.contains('hidden'), 'modal stays open when nothing applies');
+});
+
+t('applying the selection bakes the notes and closes the modal', () => {
+ activeEl = null;
+ editorShowSectionSync();
+ const before = S.arrangements[0].notes[0].time;
+ editorToggleSectionSync(0);
+ editorApplySectionSync(false);
+ assert.notStrictEqual(S.arrangements[0].notes[0].time, before, 'selected section baked');
+ assert.ok(modal.classList.contains('hidden'), 'modal closed after apply');
+});
+
+t('Escape closes the modal and restores focus to the opener', () => {
+ const opener = mkEl('opener');
+ opener.focus();
+ editorShowSectionSync();
+ dispatchKey(modal, 'Escape');
+ assert.ok(modal.classList.contains('hidden'), 'modal hidden after Escape');
+ assert.strictEqual(activeEl, opener, 'focus restored to the opener');
+});
+
+console.log(`\n${pass} passed, ${fail} failed`);
+process.exit(fail ? 1 : 0);
From 1cf7fdc7366fd0047a3d9969d7551ba743cb9304 Mon Sep 17 00:00:00 2001
From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com>
Date: Mon, 12 Oct 2026 17:02:53 +0000
Subject: [PATCH 4/4] fix(sync): gate the high-confidence apply on the default
plan (#26)
---
src/section-sync-ui.js | 6 ++++--
src/section-sync.js | 13 +++++++++++++
tests/section_sync_ui.test.mjs | 20 +++++++++++++++++++-
tests/section_sync_ui_dom.test.mjs | 4 ++++
4 files changed, 40 insertions(+), 3 deletions(-)
diff --git a/src/section-sync-ui.js b/src/section-sync-ui.js
index 92b58e6..6956e4d 100644
--- a/src/section-sync-ui.js
+++ b/src/section-sync-ui.js
@@ -16,7 +16,9 @@
import { S } from './state.js';
import { _ensureOnsetsShifted } from './audio.js';
import { applyPerSectionSync } from './tempo.js';
-import { _alignSectionsPure, _sectionSyncPlanPure } from './section-sync.js';
+import {
+ _alignSectionsPure, _sectionSyncHighConfidenceDisabledPure, _sectionSyncPlanPure,
+} from './section-sync.js';
import { _editorEscHtml, _installModalKeyboard, setStatus } from './ui.js';
import { host } from './host.js';
@@ -223,5 +225,5 @@ function _renderSectionSync() {
: 'Apply selected';
}
const highBtn = document.getElementById('editor-section-sync-apply-high');
- if (highBtn) highBtn.disabled = plan.summary.high === 0 || plan.conflicts.length > 0;
+ if (highBtn) highBtn.disabled = _sectionSyncHighConfidenceDisabledPure(_analysis);
}
diff --git a/src/section-sync.js b/src/section-sync.js
index aeecc6d..57c54a3 100644
--- a/src/section-sync.js
+++ b/src/section-sync.js
@@ -857,4 +857,17 @@ export function _sectionSyncPlanPure(analysis, decisions, options = {}) {
},
};
}
+
+/**
+ * The availability of the #26 "Apply all high-confidence" action. That button
+ * always commits the DEFAULT decision set (the confidence floor alone), so its
+ * gate must be read from the default plan — never from the user's current
+ * `decisions`, whose skips can clear a conflict the default plan still carries
+ * and enable a button whose click is then refused. True when the action would
+ * be refused: no trusted section, or a run-continuity conflict in that plan.
+ */
+export function _sectionSyncHighConfidenceDisabledPure(analysis) {
+ const plan = _sectionSyncPlanPure(analysis);
+ return plan.summary.high === 0 || plan.conflicts.length > 0;
+}
/* @pure:section-sync:end */
diff --git a/tests/section_sync_ui.test.mjs b/tests/section_sync_ui.test.mjs
index c88af0f..767c205 100644
--- a/tests/section_sync_ui.test.mjs
+++ b/tests/section_sync_ui.test.mjs
@@ -21,7 +21,8 @@ import { EditHistory } from '../src/history.js';
import { seedState } from './_history_env.mjs';
import { applyPerSectionSync } from '../src/tempo.js';
import {
- SECTION_SYNC_CONFIDENCE_FLOOR, _proposalAcceptedPure, _sectionSyncPlanPure,
+ SECTION_SYNC_CONFIDENCE_FLOOR, _proposalAcceptedPure, _sectionSyncHighConfidenceDisabledPure,
+ _sectionSyncPlanPure,
} from '../src/section-sync.js';
function prop(index, start, end, opts = {}) {
@@ -129,6 +130,23 @@ test('a run pinned to identity at both ends surfaces a conflict the UI can block
assert.strictEqual(skipped.rows[1].wanted, false);
});
+test('the "Apply all high-confidence" gate reads the DEFAULT plan, not current decisions', () => {
+ const a = analysis([
+ prop(0, 0, 10, { confidence: 0.3 }),
+ prop(1, 10, 20, { confidence: 0.9, slope: 2 }),
+ prop(2, 20, 30, { confidence: 0.3 }),
+ ]);
+ // The default plan pins the trusted middle run at both ends -> conflict, so
+ // the button that commits that plan is blocked.
+ assert.strictEqual(_sectionSyncHighConfidenceDisabledPure(a), true);
+ // Skipping the middle section clears the CURRENT plan's conflict, but the
+ // button rebuilds the default decision set, so it must stay blocked — the
+ // regression the gate is derived from the default plan to avoid.
+ const current = _sectionSyncPlanPure(a, new Map([[1, false]]));
+ assert.deepStrictEqual(current.conflicts, []);
+ assert.strictEqual(_sectionSyncHighConfidenceDisabledPure(a), true);
+});
+
test('_proposalAcceptedPure honours selected overrides but never affine validity', () => {
assert.strictEqual(_proposalAcceptedPure(prop(0, 0, 10, { confidence: 0.1 }), 0.7), false);
assert.strictEqual(_proposalAcceptedPure({ ...prop(0, 0, 10, { confidence: 0.1 }), selected: true }, 0.7), true);
diff --git a/tests/section_sync_ui_dom.test.mjs b/tests/section_sync_ui_dom.test.mjs
index a7ef061..30c4801 100644
--- a/tests/section_sync_ui_dom.test.mjs
+++ b/tests/section_sync_ui_dom.test.mjs
@@ -152,6 +152,7 @@ t('opening shows the modal, disables Apply on an all-skipped plan, and focuses i
assert.match(summaryEl.textContent, /0\/1 selected/, 'summary counts 0 applied');
assert.match(summaryEl.textContent, /1 low-confidence skipped/, 'low section reported skipped');
assert.strictEqual(applyBtn.disabled, true, 'Apply disabled with nothing selected');
+ assert.strictEqual(applyHigh.disabled, true, '"Apply all high-confidence" disabled with no trusted section');
assert.ok(activeEl && activeEl !== document.body, 'focus moved into the modal despite disabled Apply');
assert.strictEqual(panel.contains(activeEl) || activeEl === modal, true, 'focus is inside the modal');
});
@@ -161,6 +162,9 @@ t('toggling the low-confidence section selects it, enables Apply and keeps focus
assert.match(summaryEl.textContent, /1\/1 selected/, 'summary counts the forced section');
assert.doesNotMatch(summaryEl.textContent, /skipped/, 'no skipped count once selected');
assert.strictEqual(applyBtn.disabled, false, 'Apply enabled after selecting');
+ // The high-confidence action still has no trusted section (the tier is
+ // confidence, not the toggle), so its gate stays disabled.
+ assert.strictEqual(applyHigh.disabled, true, 'high-confidence action still unavailable');
assert.ok(activeEl && activeEl !== document.body, 'focus restored after the re-render');
assert.strictEqual(panel.contains(activeEl) || activeEl === modal, true, 'focus stays inside the modal');
});