Skip to content

Commit 26f6217

Browse files
committed
Add MIDI Studio V2 section color coding across timeline bars and section buttons - PR_26146_069-midi-studio-v2-section-color-visibility
1 parent 55530cb commit 26f6217

6 files changed

Lines changed: 316 additions & 23 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# PR_26146_069 MIDI Studio V2 Section Color Visibility Validation
2+
3+
## Status
4+
5+
PASS
6+
7+
## Scope Validated
8+
9+
- Octave Timeline bars render section-colored canvas regions from normalized canonical section data.
10+
- Section shortcut buttons use the matching section colors from the same normalized section data.
11+
- Missing section shortcut buttons are disabled, marked red/unwired, and expose a not-available tooltip.
12+
- Existing section buttons select their section and update the playhead/selected section state.
13+
- Play Section and Play Loop expose active section/loop visual state in buttons and canvas snapshot data.
14+
- Bar/Beat header remains visible after horizontal and vertical timeline scrolling.
15+
- Canvas-backed Octave Timeline, Play, Stop, manifest/canonical song flow, and PR068 piano-key/header behavior remain intact.
16+
17+
## Commands Run
18+
19+
- `node --check tools/midi-studio-v2/js/sectionColors.js`
20+
- `node --check tools/midi-studio-v2/js/controls/OctaveTimelineCanvasRenderer.js`
21+
- `node --check tools/midi-studio-v2/js/controls/InstrumentGridControl.js`
22+
- `node --check tests/playwright/tools/MidiStudioV2.spec.mjs`
23+
- `npx playwright test tests/playwright/tools/MidiStudioV2.spec.mjs --grep "PR069" --reporter=list`
24+
- `npx playwright test tests/playwright/tools/MidiStudioV2.spec.mjs --grep "PR068|PR069" --reporter=list`
25+
- `git diff --check`
26+
27+
## Results
28+
29+
- Syntax checks: PASS.
30+
- Targeted PR069 Playwright test: PASS, 1/1.
31+
- Targeted PR068/PR069 Playwright regression slice: PASS, 3/3.
32+
- `git diff --check`: PASS. Git emitted line-ending normalization warnings for existing Windows checkout behavior only.
33+
- Full samples smoke test: SKIPPED per BUILD instruction.
34+
35+
## Notes
36+
37+
- Section colors are centralized in `tools/midi-studio-v2/js/sectionColors.js` and shared by the canvas renderer and section shortcut controls.
38+
- The canvas snapshot now reports section colors, selected section, and active loop bounds so tests can prove canvas state without brittle DOM-grid assumptions.
39+
- No new tabs were added and no section editing controls were duplicated.

tests/playwright/tools/MidiStudioV2.spec.mjs

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4043,6 +4043,132 @@ test.describe("MIDI Studio V2", () => {
40434043
}
40444044
});
40454045

4046+
test("color-codes PR069 octave timeline sections and matching section buttons", async ({ page }) => {
4047+
await page.setViewportSize({ width: 1600, height: 900 });
4048+
const server = await openMidiStudio(page);
4049+
try {
4050+
await fillGuidedSongSheet(page, {
4051+
loopSections: "loop, bridge",
4052+
sections: "intro: C F\nloop: G C\nbridge: Dm G"
4053+
});
4054+
await page.locator("#parseSongSheetButton").click();
4055+
await selectMidiStudioTab(page, "studio");
4056+
await waitForCanvasRender(page);
4057+
4058+
const sectionState = await canvasTimelineState(page);
4059+
expect(sectionState.sections.map((section) => section.label)).toEqual(["intro", "loop", "bridge"]);
4060+
expect(new Set(sectionState.sections.map((section) => section.color)).size).toBe(3);
4061+
4062+
const canvasSectionPixels = await page.evaluate(() => {
4063+
const canvas = document.querySelector("[data-octave-timeline-canvas='true']");
4064+
const state = window.__midiStudioV2App.instrumentGrid.timelineCanvasState();
4065+
const ratio = canvas.width / canvas.clientWidth;
4066+
const context = canvas.getContext("2d");
4067+
const sampleBody = (sectionLabel) => {
4068+
const section = state.sections.find((entry) => entry.label === sectionLabel);
4069+
const x = Math.round((state.axisWidth + section.startStep * state.cellSize + state.cellSize / 2) * ratio);
4070+
const y = Math.round((state.headerHeight + 4) * ratio);
4071+
return Array.from(context.getImageData(x, y, 1, 1).data).slice(0, 3);
4072+
};
4073+
return {
4074+
bridge: sampleBody("bridge"),
4075+
intro: sampleBody("intro"),
4076+
loop: sampleBody("loop")
4077+
};
4078+
});
4079+
expect(canvasSectionPixels.intro).not.toEqual(canvasSectionPixels.loop);
4080+
expect(canvasSectionPixels.loop).not.toEqual(canvasSectionPixels.bridge);
4081+
4082+
const buttonState = await page.locator(".midi-studio-v2__section-preset").evaluateAll((buttons) => Object.fromEntries(buttons.map((button) => [
4083+
button.dataset.sectionPreset,
4084+
{
4085+
color: button.dataset.sectionColor || "",
4086+
disabled: button.disabled,
4087+
loop: button.dataset.sectionLoop,
4088+
selected: button.dataset.sectionSelected,
4089+
title: button.title,
4090+
unwired: button.classList.contains("midi-studio-v2__unwired-control")
4091+
}
4092+
])));
4093+
const colorsBySection = Object.fromEntries(sectionState.sections.map((section) => [section.label, section.color]));
4094+
expect(buttonState.intro).toEqual(expect.objectContaining({
4095+
color: colorsBySection.intro,
4096+
disabled: false,
4097+
unwired: false
4098+
}));
4099+
expect(buttonState.loop).toEqual(expect.objectContaining({
4100+
color: colorsBySection.loop,
4101+
disabled: false,
4102+
unwired: false
4103+
}));
4104+
expect(buttonState.bridge).toEqual(expect.objectContaining({
4105+
color: colorsBySection.bridge,
4106+
disabled: false,
4107+
unwired: false
4108+
}));
4109+
expect(buttonState.boss).toEqual(expect.objectContaining({
4110+
disabled: true,
4111+
title: "Boss section not available for this song",
4112+
unwired: true
4113+
}));
4114+
await expect(page.locator("[data-section-preset='boss']")).toHaveClass(/midi-studio-v2__unwired-control/);
4115+
4116+
await page.locator("[data-section-preset='intro']").click();
4117+
await expect(page.locator("#instrumentGridSectionSelect")).toHaveValue("intro");
4118+
await expect(page.locator("[data-section-preset='intro']")).toHaveAttribute("data-section-selected", "true");
4119+
await expect(page.locator("#instrumentGridOutput")).toHaveAttribute("data-playhead-section", "intro");
4120+
expect((await canvasTimelineState(page)).selectedSection).toEqual(expect.objectContaining({
4121+
color: colorsBySection.intro,
4122+
label: "intro"
4123+
}));
4124+
4125+
await page.locator("#playSectionButton").click();
4126+
await expect(page.locator("#statusLog")).toHaveValue(/OK Preview Synth started for section intro with \d+ playable events\./);
4127+
await expect(page.locator("[data-section-preset='intro']")).toHaveClass(/is-selected-section/);
4128+
await page.locator("#stopTimingPreviewButton").click();
4129+
4130+
await page.locator("#instrumentGridLoopStartSelect").selectOption("loop");
4131+
await page.locator("#instrumentGridLoopEndSelect").selectOption("bridge");
4132+
await expect(page.locator("[data-section-preset='loop']")).toHaveAttribute("data-section-loop", "true");
4133+
await expect(page.locator("[data-section-preset='bridge']")).toHaveAttribute("data-section-loop", "true");
4134+
await page.locator("#playLoopButton").click();
4135+
await expect(page.locator("#statusLog")).toHaveValue(/OK Preview Synth started for loop loop to bridge with \d+ playable events\./);
4136+
const loopState = await canvasTimelineState(page);
4137+
expect(loopState.loopBounds).toEqual(expect.objectContaining({
4138+
endLabel: "bridge",
4139+
startLabel: "loop"
4140+
}));
4141+
await page.locator("#stopTimingPreviewButton").click();
4142+
4143+
const scrollEvidence = await page.locator("#instrumentGridOutput").evaluate((output) => {
4144+
output.style.width = "340px";
4145+
output.style.maxWidth = "340px";
4146+
output.scrollLeft = 220;
4147+
output.scrollTop = 180;
4148+
output.dispatchEvent(new Event("scroll"));
4149+
return {
4150+
scrollLeft: Math.round(output.scrollLeft),
4151+
scrollTop: Math.round(output.scrollTop)
4152+
};
4153+
});
4154+
await expect(octaveTimelineCanvas(page)).toHaveAttribute("data-frozen-header", "true");
4155+
const scrolledState = await canvasTimelineState(page);
4156+
expect(scrolledState.frozenHeaderVisible).toBe(true);
4157+
expect(Math.round(scrolledState.frozenHeaderScrollLeft)).toBe(scrollEvidence.scrollLeft);
4158+
expect(Math.round(scrolledState.frozenHeaderScrollTop)).toBe(scrollEvidence.scrollTop);
4159+
4160+
await page.locator("#playButton").click();
4161+
await expect(page.locator("#playButton")).toBeDisabled();
4162+
await expect(page.locator("#stopButton")).toBeEnabled();
4163+
await page.locator("#stopButton").click();
4164+
await expect(page.locator("#stopButton")).toBeDisabled();
4165+
await expect(page.locator("#playButton")).toBeEnabled();
4166+
} finally {
4167+
await workspaceV2CoverageReporter.stop(page);
4168+
await server.close();
4169+
}
4170+
});
4171+
40464172
test("derives primary song, instrument, grid, playback, and diagnostics views from the canonical selected song", async ({ page }) => {
40474173
const server = await openMidiStudioForImport(page);
40484174
try {

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

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { PREVIEW_INSTRUMENT_PACKS, previewInstrumentById } from "../../../../src/engine/audio/PreviewInstrumentPacks.js";
22
import { OctaveTimelineCanvasRenderer } from "./OctaveTimelineCanvasRenderer.js";
33
import { setUnwiredControlState } from "./UnwiredControlState.js";
4+
import { sectionTone } from "../sectionColors.js";
45

56
function summaryRows(result) {
67
if (!result?.ok) {
@@ -794,19 +795,36 @@ export class InstrumentGridControl {
794795
}
795796

796797
updateSectionPresetAvailability(sections) {
797-
const availableLabels = new Set(sections.map((section) => section.label.toLowerCase()));
798+
const sectionByLabel = new Map(sections.map((section) => [section.label.toLowerCase(), section]));
798799
const unavailable = [];
799800
this.sectionPresetButtons.forEach((button) => {
800801
const label = String(button.dataset.sectionPreset || "").toLowerCase();
801-
const available = availableLabels.has(label);
802+
const section = sectionByLabel.get(label) || null;
803+
const available = Boolean(section);
802804
button.disabled = !available;
803805
button.classList.toggle("is-unavailable", !available);
806+
button.classList.toggle("midi-studio-v2__unwired-control", !available);
807+
button.classList.toggle("is-section-colored", available);
804808
button.setAttribute("aria-disabled", String(!available));
805-
button.title = available ? `Select ${button.textContent.trim()}` : `${button.textContent.trim()} section not available for this song`;
809+
if (available) {
810+
const color = sectionTone(section.colorIndex);
811+
button.style.setProperty("--midi-studio-v2-section-color", color);
812+
button.dataset.sectionColor = color;
813+
button.dataset.sectionStartStep = String(section.startStep);
814+
button.dataset.sectionEndStep = String(section.endStep);
815+
button.title = `Select ${section.label} section`;
816+
} else {
817+
button.style.removeProperty("--midi-studio-v2-section-color");
818+
delete button.dataset.sectionColor;
819+
delete button.dataset.sectionStartStep;
820+
delete button.dataset.sectionEndStep;
821+
button.title = `${button.textContent.trim()} section not available for this song`;
822+
}
806823
if (!available) {
807824
unavailable.push(button.textContent.trim());
808825
}
809826
});
827+
this.syncSectionPresetState();
810828
if (!this.sectionAvailability) {
811829
return;
812830
}
@@ -815,6 +833,26 @@ export class InstrumentGridControl {
815833
: "Quick sections available.";
816834
}
817835

836+
syncSectionPresetState() {
837+
const selectedLabel = String(this.sectionSelect.value || "").toLowerCase();
838+
const loopBounds = this.loopBounds?.ok ? this.loopBounds : { ok: false };
839+
this.sectionPresetButtons.forEach((button) => {
840+
const label = String(button.dataset.sectionPreset || "").toLowerCase();
841+
const isSelected = Boolean(label && label === selectedLabel);
842+
const startStep = Number(button.dataset.sectionStartStep);
843+
const endStep = Number(button.dataset.sectionEndStep);
844+
const inLoop = loopBounds.ok
845+
&& Number.isFinite(startStep)
846+
&& Number.isFinite(endStep)
847+
&& endStep >= loopBounds.startSection.startStep
848+
&& startStep <= loopBounds.endSection.endStep;
849+
button.classList.toggle("is-selected-section", isSelected);
850+
button.classList.toggle("is-loop-section", inLoop);
851+
button.dataset.sectionSelected = String(isSelected);
852+
button.dataset.sectionLoop = String(inLoop);
853+
});
854+
}
855+
818856
setTransportEnabled(enabled) {
819857
[
820858
this.jumpToSectionButton,
@@ -3077,6 +3115,7 @@ export class InstrumentGridControl {
30773115
this.sectionSelect.value = section.label;
30783116
this.setPlayheadStep(section.startStep);
30793117
this.updateSelectedSectionRegion();
3118+
this.syncSectionPresetState();
30803119
this.transportState.textContent = `Selected section: ${section.label}`;
30813120
onTransport("select-section", { section });
30823121
}
@@ -3090,6 +3129,7 @@ export class InstrumentGridControl {
30903129
}
30913130
this.setPlayheadStep(section.startStep);
30923131
this.updateSelectedSectionRegion();
3132+
this.syncSectionPresetState();
30933133
this.transportState.textContent = `Selected section: ${section.label}`;
30943134
onTransport("select-section", { section });
30953135
}
@@ -3100,6 +3140,7 @@ export class InstrumentGridControl {
31003140
onTransport("invalid-loop", { message: bounds.message });
31013141
return;
31023142
}
3143+
this.syncSectionPresetState();
31033144
this.transportState.textContent = `Loop region set: ${bounds.startSection.label} -> ${bounds.endSection.label}`;
31043145
onTransport("set-loop-region", { endSection: bounds.endSection, startSection: bounds.startSection });
31053146
}
@@ -3113,6 +3154,7 @@ export class InstrumentGridControl {
31133154
this.stopTimer();
31143155
this.setPlayheadStep(section.startStep);
31153156
this.updateSelectedSectionRegion();
3157+
this.syncSectionPresetState();
31163158
this.transportState.textContent = `Jumped to section: ${section.label}`;
31173159
onTransport("jump-section", { section });
31183160
}
@@ -3136,6 +3178,9 @@ export class InstrumentGridControl {
31363178
onTransport("invalid-loop", { message: bounds.message });
31373179
return;
31383180
}
3181+
this.loopBounds = bounds;
3182+
this.renderCanvasTimeline();
3183+
this.syncSectionPresetState();
31393184
const canStart = await onTransport("play-loop", { endSection: bounds.endSection, startSection: bounds.startSection });
31403185
if (canStart === false) {
31413186
return;

0 commit comments

Comments
 (0)