Skip to content

Commit d87c0b5

Browse files
committed
Polish MIDI Studio V2 instrument row icons, width, layering, and scroll stability - PR_26146_030-midi-studio-v2-instrument-row-polish
1 parent 7ba5765 commit d87c0b5

4 files changed

Lines changed: 329 additions & 18 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# PR_26146_030 MIDI Studio V2 Instrument Row Polish Validation
2+
3+
Status: PASS
4+
5+
## Scope
6+
7+
- Continued from PR_26146_029.
8+
- Replaced visible Mute/Solo text controls with CSS-drawn icon controls.
9+
- Kept Mute, Solo, Eye visibility, and Delete/X controls on one horizontal row.
10+
- Increased the MIDI Studio V2 left column by 350px and verified instrument controls fit without wrapping or clipping.
11+
- Applied gray styling to non-selected instrument notes.
12+
- Gave selected instrument note cells a higher draw layer than dimmed/non-selected note cells.
13+
- Preserved PR029 octave density and multi-note/chord behavior.
14+
- Preserved GM Type + Instrument dropdown behavior.
15+
- Preserved playback, Play/Stop, and Stop All Audio behavior.
16+
17+
## Changed Files
18+
19+
- `tools/midi-studio-v2/js/controls/InstrumentGridControl.js`
20+
- `tools/midi-studio-v2/styles/midiStudioV2.css`
21+
- `tests/playwright/tools/MidiStudioV2.spec.mjs`
22+
- `docs/dev/reports/PR_26146_030-midi-studio-v2-instrument-row-polish_validation.md`
23+
24+
## Validation
25+
26+
- PASS: `node --check tools/midi-studio-v2/js/controls/InstrumentGridControl.js`
27+
- PASS: `node --check tests/playwright/tools/MidiStudioV2.spec.mjs`
28+
- PASS: `npx playwright test tests/playwright/tools/MidiStudioV2.spec.mjs -g "octave timeline editor is the default editable and playable Studio workflow|octave grid density supports icon controls and simultaneous chord editing" --reporter=list --workers=1 --timeout=60000`
29+
- 2 passed.
30+
- PASS: `git diff --check`
31+
- No whitespace errors. Git emitted LF-to-CRLF working-copy warnings for touched text files.
32+
33+
## Required Proof Points
34+
35+
- PASS: Mute and Solo render as icon controls with no visible text.
36+
- PASS: mute/solo/eye/delete controls stay on one horizontal row.
37+
- PASS: widened left column is at least the expected PR030 width and instrument controls fit without wrapping or clipping.
38+
- PASS: non-selected instrument notes use gray color/background treatment.
39+
- PASS: selected instrument notes use a higher z-layer than dimmed notes.
40+
- PASS: clicking mute, solo, eye visibility, and delete controls preserves instrument-area scroll positions.
41+
- PASS: show/hide behavior still removes and restores hidden-lane notes.
42+
- PASS: GM Type + Instrument dropdown behavior remains covered by the carried octave workflow.
43+
- PASS: multi-note/chord editing still adds sibling notes without removing existing notes.
44+
- PASS: drum simultaneous events still work.
45+
- PASS: Play and Stop still work.
46+
- PASS: Stop All Audio still returns playback controls to idle.
47+
- PASS: all JS/CSS remains external; no HTML file was modified.
48+
49+
## Notes
50+
51+
- Full samples smoke test was not run, per BUILD request.
52+
- Initial targeted Playwright run exposed a Songs/MIDI Import overlap after widening the left column; the song list now sizes to its contents and the final targeted run passed.

tests/playwright/tools/MidiStudioV2.spec.mjs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,51 @@ function instrumentSelect(page, lane) {
372372
return instrumentRow(page, lane).locator("[data-lane-instrument-select]");
373373
}
374374

375+
function instrumentToggle(page, lane, kind) {
376+
return instrumentRow(page, lane).locator(`.midi-studio-v2__lane-toggle--${kind}`);
377+
}
378+
379+
async function instrumentScrollSnapshot(page) {
380+
return page.evaluate(() => {
381+
const leftPanel = document.querySelector(".tool-starter__panel--left");
382+
const instrumentPanel = document.querySelector(".midi-studio-v2__instrument-list-panel");
383+
return {
384+
instrumentLeft: instrumentPanel?.scrollLeft || 0,
385+
instrumentTop: instrumentPanel?.scrollTop || 0,
386+
leftLeft: leftPanel?.scrollLeft || 0,
387+
leftTop: leftPanel?.scrollTop || 0,
388+
windowX: window.scrollX,
389+
windowY: window.scrollY
390+
};
391+
});
392+
}
393+
394+
async function prepareInstrumentScrollSentinel(page) {
395+
await page.evaluate(() => {
396+
const leftPanel = document.querySelector(".tool-starter__panel--left");
397+
const instrumentPanel = document.querySelector(".midi-studio-v2__instrument-list-panel");
398+
if (leftPanel) {
399+
leftPanel.style.maxHeight = "260px";
400+
leftPanel.style.overflow = "auto";
401+
leftPanel.scrollLeft = 0;
402+
}
403+
if (instrumentPanel) {
404+
instrumentPanel.style.maxHeight = "170px";
405+
instrumentPanel.style.overflow = "auto";
406+
instrumentPanel.scrollLeft = 0;
407+
}
408+
});
409+
}
410+
411+
async function expectInstrumentClickKeepsScroll(page, locator) {
412+
await prepareInstrumentScrollSentinel(page);
413+
await locator.evaluate((element) => element.scrollIntoView({ block: "center", inline: "nearest" }));
414+
const before = await instrumentScrollSnapshot(page);
415+
await locator.click();
416+
await page.waitForTimeout(50);
417+
await expect.poll(() => instrumentScrollSnapshot(page)).toEqual(before);
418+
}
419+
375420
function laneHeader(page, lane) {
376421
return page.locator(`.midi-studio-v2__lane-header-cell[data-lane="${lane}"]`);
377422
}
@@ -535,6 +580,15 @@ test.describe("MIDI Studio V2", () => {
535580
await expect(leadVisibility).toHaveText("");
536581
await expect(leadVisibility).toHaveAttribute("aria-pressed", "true");
537582
await expect(leadVisibility).toHaveAttribute("aria-label", /Hide Lead/);
583+
await expect(instrumentToggle(page, "lead", "mute")).toHaveText("");
584+
await expect(instrumentToggle(page, "lead", "solo")).toHaveText("");
585+
await expect(instrumentToggle(page, "lead", "mute").locator(".midi-studio-v2__lane-toggle-icon")).toHaveCount(1);
586+
await expect(instrumentToggle(page, "lead", "solo").locator(".midi-studio-v2__lane-toggle-icon")).toHaveCount(1);
587+
await expect(instrumentRow(page, "lead").locator('[aria-label="Mute Lead"]')).not.toBeChecked();
588+
await instrumentToggle(page, "lead", "mute").click();
589+
await expect(instrumentRow(page, "lead").locator('[aria-label="Mute Lead"]')).toBeChecked();
590+
await instrumentToggle(page, "lead", "mute").click();
591+
await expect(instrumentRow(page, "lead").locator('[aria-label="Mute Lead"]')).not.toBeChecked();
538592
const controlAlignment = await instrumentRow(page, "lead").locator(".midi-studio-v2__instrument-control-row").evaluate((row) => {
539593
const controls = [
540594
row.querySelector('[aria-label="Mute Lead"]')?.closest("label"),
@@ -556,6 +610,20 @@ test.describe("MIDI Studio V2", () => {
556610
});
557611
expect(controlAlignment.missing).toBe(false);
558612
expect(controlAlignment.maxCenterDelta).toBeLessThanOrEqual(4);
613+
const leftColumnFit = await instrumentRow(page, "lead").locator(".midi-studio-v2__instrument-control-row").evaluate((row) => {
614+
const leftPanel = document.querySelector(".tool-starter__panel--left").getBoundingClientRect();
615+
const rowRect = row.getBoundingClientRect();
616+
const controls = Array.from(row.children).map((control) => control.getBoundingClientRect());
617+
return {
618+
controlsFit: controls.every((rect) => rect.left >= rowRect.left - 1 && rect.right <= rowRect.right + 1 && rect.right <= leftPanel.right + 1),
619+
leftWidth: leftPanel.width,
620+
rowClientWidth: row.clientWidth,
621+
rowScrollWidth: row.scrollWidth
622+
};
623+
});
624+
expect(leftColumnFit.leftWidth).toBeGreaterThanOrEqual(560);
625+
expect(leftColumnFit.controlsFit).toBe(true);
626+
expect(leftColumnFit.rowScrollWidth).toBeLessThanOrEqual(leftColumnFit.rowClientWidth + 1);
559627

560628
const octaveCellHeight = await octaveCell(page, "C5", 0).evaluate((cell) => cell.getBoundingClientRect().height);
561629
expect(octaveCellHeight).toBeLessThanOrEqual(32);
@@ -590,6 +658,33 @@ test.describe("MIDI Studio V2", () => {
590658
expect(leadChordValues).toEqual(expect.arrayContaining(initialLeadValues));
591659
await expect(octaveCell(page, "C5", chordStep)).toHaveClass(/midi-studio-v2__grid-cell--lane-selected/);
592660
await expect(octaveNoteBlock(page, "chords").first()).toHaveClass(/midi-studio-v2__grid-cell--lane-dimmed/);
661+
const noteLayering = await page.evaluate(() => {
662+
const selected = document.querySelector('.midi-studio-v2__octave-note-cell[data-row-token="C5"].midi-studio-v2__grid-cell--lane-selected');
663+
const dimmed = document.querySelector(".midi-studio-v2__octave-note-cell.midi-studio-v2__grid-cell--lane-dimmed");
664+
const selectedStyles = getComputedStyle(selected);
665+
const dimmedStyles = getComputedStyle(dimmed);
666+
return {
667+
dimmedBackground: dimmedStyles.backgroundColor,
668+
dimmedColor: dimmedStyles.color,
669+
dimmedZ: Number(dimmedStyles.zIndex),
670+
selectedZ: Number(selectedStyles.zIndex)
671+
};
672+
});
673+
expect(noteLayering.dimmedColor).toBe("rgb(148, 163, 184)");
674+
expect(noteLayering.dimmedBackground).toMatch(/rgba?\(71,\s*85,\s*105/);
675+
expect(noteLayering.selectedZ).toBeGreaterThan(noteLayering.dimmedZ);
676+
677+
await expectInstrumentClickKeepsScroll(page, instrumentToggle(page, "lead", "mute"));
678+
await expectInstrumentClickKeepsScroll(page, instrumentToggle(page, "lead", "mute"));
679+
await expectInstrumentClickKeepsScroll(page, instrumentToggle(page, "lead", "solo"));
680+
await expectInstrumentClickKeepsScroll(page, instrumentToggle(page, "lead", "solo"));
681+
await expectInstrumentClickKeepsScroll(page, leadVisibility);
682+
await expectInstrumentClickKeepsScroll(page, leadVisibility);
683+
await page.locator("#addInstrumentRowButton").click();
684+
await expect(instrumentRow(page, "instrument-1")).toBeVisible();
685+
await expectInstrumentClickKeepsScroll(page, instrumentRow(page, "instrument-1").locator("[data-delete-instrument-row='instrument-1']"));
686+
await expect(instrumentRow(page, "instrument-1")).toHaveCount(0);
687+
await selectInstrumentRow(page, "lead");
593688

594689
await leadVisibility.click();
595690
await expect(octaveNoteBlock(page, "lead")).toHaveCount(0);
@@ -649,6 +744,11 @@ test.describe("MIDI Studio V2", () => {
649744
await expect(page.locator(`.midi-studio-v2__grid-cell--timing-header.midi-studio-v2__grid-cell--playhead-active[data-step-index="${activeStep}"]`)).toHaveCount(1);
650745
await page.locator("#stopButton").click();
651746
await expect(page.locator("#playButton")).toBeEnabled();
747+
await page.locator("#playButton").click();
748+
await expect(page.locator("#stopButton")).toBeEnabled();
749+
await page.locator("#stopAllAudioButton").click();
750+
await expect(page.locator("#stopButton")).toBeDisabled();
751+
await expect(page.locator("#playButton")).toBeEnabled();
652752
} finally {
653753
await workspaceV2CoverageReporter.stop(page);
654754
await server.close();

tools/midi-studio-v2/js/controls/InstrumentGridControl.js

Lines changed: 88 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -687,7 +687,10 @@ export class InstrumentGridControl {
687687
button.className = "midi-studio-v2__lane-icon-button midi-studio-v2__visibility-button";
688688
button.type = "button";
689689
button.dataset.toggleInstrumentVisibility = lane;
690-
button.addEventListener("click", () => this.toggleInstrumentVisibility(lane));
690+
this.preventPointerFocusScroll(button);
691+
button.addEventListener("click", () => {
692+
this.runInstrumentControlAction(() => this.toggleInstrumentVisibility(lane));
693+
});
691694
this.updateVisibilityButton(button, lane);
692695
return button;
693696
}
@@ -725,7 +728,7 @@ export class InstrumentGridControl {
725728
}
726729

727730
createOctaveTimelineCell({ result, row, stepIndex }) {
728-
const events = this.visibleEventsForCell({ result, row, stepIndex });
731+
const events = this.orderedEventsForCell(this.visibleEventsForCell({ result, row, stepIndex }));
729732
const cell = document.createElement("div");
730733
cell.className = this.octaveCellClass(events).join(" ");
731734
cell.dataset.octaveRow = row.value;
@@ -775,6 +778,14 @@ export class InstrumentGridControl {
775778
}))).join(" ");
776779
}
777780

781+
orderedEventsForCell(events) {
782+
return events.slice().sort((left, right) => {
783+
const leftSelected = left.lane === this.selectedLane ? 1 : 0;
784+
const rightSelected = right.lane === this.selectedLane ? 1 : 0;
785+
return leftSelected - rightSelected;
786+
});
787+
}
788+
778789
visibleEventsForCell({ result, row, stepIndex }) {
779790
return (result.timeline || []).filter((event) => {
780791
if (event.stepIndex !== stepIndex || this.previewLaneState[event.lane]?.visible === false) {
@@ -1119,29 +1130,36 @@ export class InstrumentGridControl {
11191130
createLaneToggle(lane, kind) {
11201131
const input = document.createElement("input");
11211132
const label = document.createElement("label");
1122-
const text = document.createElement("span");
1133+
const icon = document.createElement("span");
11231134
const controlLabel = kind === "mute" ? "Mute" : "Solo";
11241135
input.id = `preview${controlLabel}${laneId(lane)}Toggle`;
11251136
input.type = "checkbox";
11261137
input.checked = kind === "mute" ? this.previewLaneState[lane]?.muted === true : this.previewLaneState[lane]?.soloed === true;
11271138
input.dataset[kind === "mute" ? "previewMuteLane" : "previewSoloLane"] = lane;
11281139
input.setAttribute("aria-label", `${controlLabel} ${laneLabel(lane)}`);
1129-
label.className = "tool-starter__toggle midi-studio-v2__lane-toggle";
1140+
label.className = `tool-starter__toggle midi-studio-v2__lane-toggle midi-studio-v2__lane-toggle--${kind}`;
1141+
label.classList.toggle("is-active", input.checked);
1142+
label.dataset.laneControlKind = kind;
11301143
label.htmlFor = input.id;
1131-
text.textContent = controlLabel;
1144+
icon.className = "midi-studio-v2__lane-toggle-icon";
1145+
icon.setAttribute("aria-hidden", "true");
1146+
this.preventPointerFocusScroll(label);
11321147
input.addEventListener("change", () => {
1133-
if (kind === "mute") {
1134-
this.previewLaneState[lane].muted = input.checked;
1135-
} else {
1136-
this.previewLaneState[lane].soloed = input.checked;
1137-
}
1138-
this.onLaneSettingChange?.(kind, {
1139-
enabled: input.checked,
1140-
lane,
1141-
laneLabel: laneLabel(lane)
1148+
this.runInstrumentControlAction(() => {
1149+
label.classList.toggle("is-active", input.checked);
1150+
if (kind === "mute") {
1151+
this.previewLaneState[lane].muted = input.checked;
1152+
} else {
1153+
this.previewLaneState[lane].soloed = input.checked;
1154+
}
1155+
this.onLaneSettingChange?.(kind, {
1156+
enabled: input.checked,
1157+
lane,
1158+
laneLabel: laneLabel(lane)
1159+
});
11421160
});
11431161
});
1144-
label.append(input, text);
1162+
label.append(input, icon);
11451163
return { input, label };
11461164
}
11471165

@@ -1199,10 +1217,64 @@ export class InstrumentGridControl {
11991217
button.setAttribute("aria-label", `Delete instrument row ${laneLabel(lane)}`);
12001218
button.title = "Delete instrument row";
12011219
button.textContent = "x";
1202-
button.addEventListener("click", () => this.deleteInstrumentRow(lane));
1220+
this.preventPointerFocusScroll(button);
1221+
button.addEventListener("click", () => {
1222+
this.runInstrumentControlAction(() => this.deleteInstrumentRow(lane));
1223+
});
12031224
return button;
12041225
}
12051226

1227+
preventPointerFocusScroll(element) {
1228+
element.addEventListener("pointerdown", (event) => {
1229+
event.preventDefault();
1230+
});
1231+
}
1232+
1233+
runInstrumentControlAction(action) {
1234+
const scrollState = this.captureInstrumentScrollState();
1235+
action();
1236+
this.restoreInstrumentScrollState(scrollState);
1237+
}
1238+
1239+
captureInstrumentScrollState() {
1240+
const leftPanel = this.instrumentList?.closest(".tool-starter__panel--left") || null;
1241+
const instrumentPanel = this.instrumentList?.closest(".midi-studio-v2__instrument-list-panel") || null;
1242+
return {
1243+
instrumentPanel,
1244+
instrumentScrollLeft: instrumentPanel?.scrollLeft || 0,
1245+
instrumentScrollTop: instrumentPanel?.scrollTop || 0,
1246+
leftPanel,
1247+
leftScrollLeft: leftPanel?.scrollLeft || 0,
1248+
leftScrollTop: leftPanel?.scrollTop || 0,
1249+
windowScrollX: this.window.scrollX || 0,
1250+
windowScrollY: this.window.scrollY || 0
1251+
};
1252+
}
1253+
1254+
restoreInstrumentScrollState(scrollState) {
1255+
this.applyInstrumentScrollState(scrollState);
1256+
this.window.requestAnimationFrame?.(() => this.applyInstrumentScrollState(scrollState));
1257+
}
1258+
1259+
applyInstrumentScrollState(scrollState) {
1260+
if (!scrollState) {
1261+
return;
1262+
}
1263+
if (scrollState.leftPanel) {
1264+
scrollState.leftPanel.scrollTop = scrollState.leftScrollTop;
1265+
scrollState.leftPanel.scrollLeft = scrollState.leftScrollLeft;
1266+
}
1267+
if (scrollState.instrumentPanel) {
1268+
scrollState.instrumentPanel.scrollTop = scrollState.instrumentScrollTop;
1269+
scrollState.instrumentPanel.scrollLeft = scrollState.instrumentScrollLeft;
1270+
}
1271+
this.window.scrollTo(scrollState.windowScrollX, scrollState.windowScrollY);
1272+
const activeElement = this.window.document.activeElement;
1273+
if (activeElement?.closest?.(".midi-studio-v2__instrument-control-row")) {
1274+
activeElement.blur();
1275+
}
1276+
}
1277+
12061278
addInstrumentRow() {
12071279
const lane = this.nextInstrumentLaneName();
12081280
this.extraLaneSources[lane] = "";

0 commit comments

Comments
 (0)