Skip to content

Commit 40b9cfe

Browse files
committed
Freeze MIDI Studio V2 octave grid headers and render active notes as highlights - PR_26146_035-midi-studio-v2-octave-grid-freeze-and-highlight
1 parent fede39b commit 40b9cfe

4 files changed

Lines changed: 293 additions & 56 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# PR_26146_035-midi-studio-v2-octave-grid-freeze-and-highlight Validation
2+
3+
Status: PASS
4+
5+
## Scope
6+
7+
- Continued from `PR_26146_034-midi-studio-v2-export-ui-simplification`.
8+
- Refined MIDI Studio V2 octave timeline layout only.
9+
- Added a compact two-row sticky timing header:
10+
- row 1: `Octave | Bar | ...`
11+
- row 2: ` | Beat | ...`
12+
- Kept timing header and note grid in one scroll surface so horizontal movement stays synchronized.
13+
- Froze octave/note labels on the left during horizontal scrolling.
14+
- Reduced note step columns to compact fixed-width columns.
15+
- Removed printed note names from active octave cells and replaced them with highlight markers.
16+
- Preserved selected-lane prominence, dimmed non-selected notes, multi-note/chord editing, Play/Stop, export Type + Save behavior, and 350px left column layout.
17+
18+
## Changed Files
19+
20+
- `tools/midi-studio-v2/js/controls/InstrumentGridControl.js`
21+
- `tools/midi-studio-v2/styles/midiStudioV2.css`
22+
- `tests/playwright/tools/MidiStudioV2.spec.mjs`
23+
- `docs/dev/reports/PR_26146_035-midi-studio-v2-octave-grid-freeze-and-highlight_validation.md`
24+
- `docs/dev/reports/codex_review.diff`
25+
- `docs/dev/reports/codex_changed_files.txt`
26+
- `docs/dev/reports/playwright_v8_coverage_report.txt`
27+
- `docs/dev/reports/coverage_changed_js_guardrail.txt`
28+
- `docs/dev/codex_commands.md`
29+
- `docs/dev/commit_comment.txt`
30+
31+
## Validation Commands
32+
33+
```powershell
34+
node --check tools/midi-studio-v2/js/controls/InstrumentGridControl.js
35+
node --check tests/playwright/tools/MidiStudioV2.spec.mjs
36+
node -e "const fs=require('fs'); const p='tools/midi-studio-v2/styles/midiStudioV2.css'; const css=fs.readFileSync(p,'utf8'); if(/\/\*[^]*$/.test(css.replace(/\/\*[^]*?\*\//g,''))) throw new Error('Unclosed CSS comment'); let depth=0; for (const ch of css.replace(/\/\*[^]*?\*\//g,'')) { if (ch==='{') depth++; if (ch==='}') depth--; if (depth<0) throw new Error('Unexpected }'); } if (depth!==0) throw new Error('Unbalanced CSS braces'); console.log('CSS syntax guard passed:', p);"
37+
npx.cmd playwright test tests/playwright/tools/MidiStudioV2.spec.mjs -g "octave timeline freezes compact headers and note labels while active cells stay textless|fast octave note editing supports drag painting keyboard shortcuts selection and timeline scroll sync|octave grid density supports icon controls and simultaneous chord editing|exports through Type dropdown and Save without claiming files were written" --config=codex_playwright_system_chrome.config.cjs --reporter=list --workers=1 --timeout=60000
38+
git diff --check
39+
```
40+
41+
## Results
42+
43+
- PASS: changed JavaScript files passed `node --check`.
44+
- PASS: changed CSS passed the CSS brace/comment syntax guard.
45+
- PASS: targeted MIDI Studio V2 Playwright run passed, `4 passed`.
46+
- PASS: `git diff --check` exited successfully. Git reported only line-ending notices for tracked files.
47+
- PASS: Playwright V8 coverage artifacts were refreshed:
48+
- `docs/dev/reports/playwright_v8_coverage_report.txt`
49+
- `docs/dev/reports/coverage_changed_js_guardrail.txt`
50+
51+
## Playwright Proof
52+
53+
The targeted MIDI Studio V2 Playwright run validates:
54+
55+
- octave/timeline header stays frozen at the top during vertical scroll.
56+
- left octave/note labels stay frozen during horizontal scroll.
57+
- timing header and body note cells remain horizontally aligned with no drift.
58+
- two-row `Octave | Bar` and `Beat` timing header renders with expected bar/beat values.
59+
- note columns are compact at or under 32px.
60+
- active octave note cells render highlight markers and no printed note text.
61+
- selected instrument notes remain visually dominant.
62+
- non-selected instrument notes remain gray/dim.
63+
- multi-note/chord editing and simultaneous notes in one beat column still work.
64+
- click, drag painting, keyboard shortcuts, selection highlight, and timeline scroll sync still work.
65+
- Play and Stop still work.
66+
- export Type + Save behavior from PR034 still works.
67+
68+
## Lanes
69+
70+
- runtime: executed because this PR changes MIDI Studio V2 octave timeline rendering, scrolling, and note-cell visuals.
71+
- contract/docs: executed through required report artifacts; roadmap/audit files were preserved and not modified.
72+
- integration: skipped because Workspace Manager handoff contracts did not change.
73+
- engine: skipped because no engine/shared runtime files changed.
74+
- samples: skipped by explicit PR instruction. Full samples smoke test was not run.
75+
- recovery/UAT: covered only for the MIDI Studio V2 octave grid freeze/highlight slice named by this PR.
76+
77+
## Manual Validation Notes
78+
79+
1. Open `tools/midi-studio-v2/index.html`.
80+
2. Import `tests/fixtures/midi-studio-v2/uat-midi-studio-v2.game.manifest.json`.
81+
3. Vertically scroll the octave note grid and confirm the two-row Octave/Bar/Beat header stays pinned at the top.
82+
4. Horizontally scroll the grid and confirm the header columns and body note columns remain aligned.
83+
5. Horizontally scroll the grid and confirm the octave/note labels stay visible on the left.
84+
6. Click and drag notes; active cells should show highlights without printed note names.
85+
7. Confirm selected instrument notes remain prominent, non-selected notes remain dim, chords still allow simultaneous notes, and Play/Stop still work.
86+
87+
## Out Of Scope
88+
89+
- Full samples smoke test.
90+
- SoundFont playback.
91+
- Rendered export implementation.
92+
- DAW mixer complexity.
93+
- Automation lanes.
94+
- Roadmap or implementation audit status changes.

tests/playwright/tools/MidiStudioV2.spec.mjs

Lines changed: 100 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -518,10 +518,10 @@ test.describe("MIDI Studio V2", () => {
518518
await expect(octaveNoteBlock(page, "lead").first()).toHaveClass(/midi-studio-v2__grid-cell--lane-selected/);
519519
await expect(octaveNoteBlock(page, "chords").first()).toHaveClass(/midi-studio-v2__grid-cell--lane-dimmed/);
520520
await expect(page.locator(".midi-studio-v2__octave-row-label[data-octave='5']")).not.toHaveCount(0);
521-
await expect(octaveCell(page, "G4", 0)).toContainText("G4");
521+
await expect(octaveCell(page, "G4", 0)).toHaveText("");
522522
await expect(octaveCell(page, "G4", 0)).toHaveAttribute("data-note-lanes", /lead/);
523523
await selectInstrumentRow(page, "bass");
524-
await expect(octaveCell(page, "G2", 0)).toContainText("G2");
524+
await expect(octaveCell(page, "G2", 0)).toHaveText("");
525525
await expect(octaveCell(page, "G2", 0)).toHaveAttribute("data-note-lanes", /bass/);
526526
await instrumentRow(page, "bass").locator("[data-toggle-instrument-visibility='bass']").click();
527527
await expect(octaveNoteBlock(page, "bass")).toHaveCount(0);
@@ -530,7 +530,7 @@ test.describe("MIDI Studio V2", () => {
530530
await expect(octaveNoteBlock(page, "bass").first()).toBeVisible();
531531
await selectInstrumentRow(page, "lead");
532532
await octaveCell(page, "C5", 1).click();
533-
await expect(octaveCell(page, "C5", 1)).toHaveText("C5");
533+
await expect(octaveCell(page, "C5", 1)).toHaveText("");
534534
await expect(octaveCell(page, "C5", 1)).toHaveAttribute("data-note-lanes", /lead/);
535535
expect(await page.evaluate(() => window.__midiStudioV2App.lastInstrumentGridResult.timeline.some((event) => event.lane === "lead" && event.value === "C5" && event.stepIndex === 1))).toBe(true);
536536

@@ -550,10 +550,10 @@ test.describe("MIDI Studio V2", () => {
550550
await expect(page.locator("#playButton")).toBeEnabled();
551551

552552
await page.locator('[data-song-id="frog-hop-nursery-rhyme"]').click();
553-
await expect(octaveCell(page, "C5", 0)).toContainText("C5");
553+
await expect(octaveCell(page, "C5", 0)).toHaveText("");
554554
await expect(octaveCell(page, "C5", 0)).toHaveAttribute("data-note-lanes", /lead/);
555555
await page.locator('[data-song-id="quiet-village-pad"]').click();
556-
await expect(octaveCell(page, "D5", 0)).toContainText("D5");
556+
await expect(octaveCell(page, "D5", 0)).toHaveText("");
557557
await expect(octaveCell(page, "D5", 0)).toHaveAttribute("data-note-lanes", /lead/);
558558

559559
await selectMidiStudioTab(page, "midi-import");
@@ -566,10 +566,10 @@ test.describe("MIDI Studio V2", () => {
566566
await expect(page.locator("#songList")).toContainText("Local Import");
567567
await selectMidiStudioTab(page, "studio");
568568
await expect(instrumentRow(page, "track-2-ch-1")).toBeVisible();
569-
await expect(octaveCell(page, "C4", 0)).toContainText("C4");
569+
await expect(octaveCell(page, "C4", 0)).toHaveText("");
570570
await expect(octaveCell(page, "C4", 0)).toHaveAttribute("data-note-lanes", /track-2-ch-1/);
571571
await octaveCell(page, "D4", 1).click();
572-
await expect(octaveCell(page, "D4", 1)).toHaveText("D4");
572+
await expect(octaveCell(page, "D4", 1)).toHaveText("");
573573
await expect(octaveCell(page, "D4", 1)).toHaveAttribute("data-note-lanes", /track-2-ch-1/);
574574
expect(await page.evaluate(() => window.__midiStudioV2App.selectedSong().studioArrangement.lanes["track-2-ch-1"])).toContain("D4");
575575
await page.evaluate(() => {
@@ -726,8 +726,8 @@ test.describe("MIDI Studio V2", () => {
726726
.map((event) => event.value), chordStep);
727727
await octaveCell(page, "C5", chordStep).click();
728728
await octaveCell(page, "E5", chordStep).click();
729-
await expect(octaveCell(page, "C5", chordStep)).toHaveText("C5");
730-
await expect(octaveCell(page, "E5", chordStep)).toHaveText("E5");
729+
await expect(octaveCell(page, "C5", chordStep)).toHaveText("");
730+
await expect(octaveCell(page, "E5", chordStep)).toHaveText("");
731731
await octaveCell(page, "G5", chordStep).click();
732732
const leadChordValues = await page.evaluate((stepIndex) => window.__midiStudioV2App.lastInstrumentGridResult.timeline
733733
.filter((event) => event.lane === "lead" && event.kind === "note" && event.stepIndex === stepIndex)
@@ -819,8 +819,8 @@ test.describe("MIDI Studio V2", () => {
819819
expect(playbackEvidence.oscillatorStartsAtSameTime).toBeGreaterThanOrEqual(3);
820820
expect(playbackEvidence.bufferStartsAtSameTime).toBeGreaterThanOrEqual(2);
821821
await page.waitForFunction(() => window.__midiStudioV2App.instrumentGrid.playheadStep > 0);
822-
const activeStep = await page.evaluate(() => window.__midiStudioV2App.instrumentGrid.playheadStep);
823-
await expect(page.locator(`.midi-studio-v2__grid-cell--timing-header.midi-studio-v2__grid-cell--playhead-active[data-step-index="${activeStep}"]`)).toHaveCount(1);
822+
await expect(page.locator(".midi-studio-v2__grid-cell--timing-header.midi-studio-v2__grid-cell--playhead-active")).toHaveCount(1);
823+
expect(Number(await page.locator(".midi-studio-v2__grid-cell--timing-header.midi-studio-v2__grid-cell--playhead-active").getAttribute("data-step-index"))).toBeGreaterThan(0);
824824
await page.locator("#stopButton").click();
825825
await expect(page.locator("#playButton")).toBeEnabled();
826826
await page.locator("#playButton").click();
@@ -842,7 +842,7 @@ test.describe("MIDI Studio V2", () => {
842842
await expect(octaveCell(page, "C6", 2)).not.toHaveAttribute("data-note-lanes", /lead/);
843843

844844
await octaveCell(page, "C6", 2).click();
845-
await expect(octaveCell(page, "C6", 2)).toHaveText("C6");
845+
await expect(octaveCell(page, "C6", 2)).toHaveText("");
846846
await expect(octaveCell(page, "C6", 2)).toHaveAttribute("data-note-lanes", /lead/);
847847
await expect(octaveCell(page, "C6", 2)).toHaveClass(/midi-studio-v2__grid-cell--note-selected/);
848848
await octaveCell(page, "C6", 2).click();
@@ -851,7 +851,7 @@ test.describe("MIDI Studio V2", () => {
851851

852852
await octaveCell(page, "C6", 3).dragTo(octaveCell(page, "C6", 5));
853853
for (const stepIndex of [3, 4, 5]) {
854-
await expect(octaveCell(page, "C6", stepIndex)).toHaveText("C6");
854+
await expect(octaveCell(page, "C6", stepIndex)).toHaveText("");
855855
await expect(octaveCell(page, "C6", stepIndex)).toHaveAttribute("data-note-lanes", /lead/);
856856
}
857857
await octaveCell(page, "C6", 5).dragTo(octaveCell(page, "C6", 7));
@@ -924,6 +924,93 @@ test.describe("MIDI Studio V2", () => {
924924
}
925925
});
926926

927+
test("octave timeline freezes compact headers and note labels while active cells stay textless", async ({ page }) => {
928+
const server = await openMidiStudioForImport(page);
929+
try {
930+
await page.locator("#toolImportManifestInput").setInputFiles(uatManifestPath);
931+
await selectInstrumentRow(page, "lead");
932+
933+
await expect(page.locator(".midi-studio-v2__timing-header-row-1.midi-studio-v2__note-table-instrument-header")).toHaveText("Octave");
934+
await expect(page.locator(".midi-studio-v2__timing-header-row-1.midi-studio-v2__timing-axis-header")).toHaveText("Bar");
935+
await expect(page.locator(".midi-studio-v2__timing-header-row-2.midi-studio-v2__note-table-instrument-header")).toHaveText("");
936+
await expect(page.locator(".midi-studio-v2__timing-header-row-2.midi-studio-v2__timing-axis-header")).toHaveText("Beat");
937+
await expect(page.locator('.midi-studio-v2__timing-header-row-1.midi-studio-v2__note-table-column-header[data-step-index="0"]')).toHaveText("1");
938+
await expect(page.locator('.midi-studio-v2__timing-header-row-2.midi-studio-v2__note-table-column-header[data-step-index="0"]')).toHaveText("1");
939+
await expect(page.locator('.midi-studio-v2__timing-header-row-2.midi-studio-v2__note-table-column-header[data-step-index="1"]')).toHaveText("2");
940+
await expect(page.locator('.midi-studio-v2__timing-header-row-2.midi-studio-v2__note-table-column-header[data-step-index="2"]')).toHaveText("3");
941+
await expect(page.locator('.midi-studio-v2__timing-header-row-2.midi-studio-v2__note-table-column-header[data-step-index="3"]')).toHaveText("4");
942+
943+
const output = page.locator("#instrumentGridOutput");
944+
const gridLayout = await output.evaluate((element) => {
945+
const firstNote = element.querySelector('.midi-studio-v2__octave-note-cell[data-step-index="0"]');
946+
return {
947+
columnWidth: firstNote.getBoundingClientRect().width,
948+
columnTemplate: getComputedStyle(element.querySelector(".midi-studio-v2__octave-timeline")).gridTemplateColumns
949+
};
950+
});
951+
expect(gridLayout.columnWidth).toBeLessThanOrEqual(32);
952+
expect(gridLayout.columnTemplate).toContain("28.8px");
953+
954+
await output.evaluate((element) => {
955+
element.style.maxHeight = "150px";
956+
element.scrollTop = 140;
957+
element.dispatchEvent(new Event("scroll"));
958+
});
959+
const verticalFreeze = await output.evaluate((element) => {
960+
const outputRect = element.getBoundingClientRect();
961+
const row1 = element.querySelector(".midi-studio-v2__timing-header-row-1.midi-studio-v2__note-table-column-header").getBoundingClientRect();
962+
const row2 = element.querySelector(".midi-studio-v2__timing-header-row-2.midi-studio-v2__note-table-column-header").getBoundingClientRect();
963+
return {
964+
row1TopDelta: Math.abs(row1.top - outputRect.top),
965+
row2BelowRow1: row2.top >= row1.bottom - 1,
966+
scrollTop: Math.round(element.scrollTop)
967+
};
968+
});
969+
expect(verticalFreeze.scrollTop).toBeGreaterThan(0);
970+
expect(verticalFreeze.row1TopDelta).toBeLessThanOrEqual(2);
971+
expect(verticalFreeze.row2BelowRow1).toBe(true);
972+
973+
await output.evaluate((element) => {
974+
element.scrollLeft = 220;
975+
element.dispatchEvent(new Event("scroll"));
976+
});
977+
const horizontalFreeze = await output.evaluate((element) => {
978+
const outputRect = element.getBoundingClientRect();
979+
const header = element.querySelector('.midi-studio-v2__grid-cell--beat-header[data-step-index="6"]').getBoundingClientRect();
980+
const bodyCell = element.querySelector('.midi-studio-v2__octave-note-cell[data-row-token="C5"][data-step-index="6"]').getBoundingClientRect();
981+
const label = element.querySelector('.midi-studio-v2__octave-row-label[data-octave-row="C5"]').getBoundingClientRect();
982+
return {
983+
headerBodyDelta: Math.abs(header.left - bodyCell.left),
984+
labelLeftDelta: Math.abs(label.left - outputRect.left),
985+
labelRowDelta: Math.abs(label.top - bodyCell.top),
986+
scrollLeft: Math.round(element.scrollLeft)
987+
};
988+
});
989+
expect(horizontalFreeze.scrollLeft).toBeGreaterThan(0);
990+
expect(horizontalFreeze.headerBodyDelta).toBeLessThanOrEqual(1);
991+
expect(horizontalFreeze.labelLeftDelta).toBeLessThanOrEqual(2);
992+
expect(horizontalFreeze.labelRowDelta).toBeLessThanOrEqual(1);
993+
994+
await octaveCell(page, "C6", 10).click();
995+
await expect(octaveCell(page, "C6", 10)).toHaveText("");
996+
await expect(octaveCell(page, "C6", 10)).toHaveAttribute("data-note-lanes", /lead/);
997+
await expect(octaveCell(page, "C6", 10)).toHaveAttribute("data-note-values", /C6/);
998+
await expect(octaveCell(page, "C6", 10)).toHaveClass(/midi-studio-v2__grid-cell--lane-selected/);
999+
const activeHighlight = await octaveCell(page, "C6", 10).evaluate((cell) => {
1000+
const marker = getComputedStyle(cell, "::before");
1001+
return {
1002+
content: marker.content,
1003+
width: marker.width
1004+
};
1005+
});
1006+
expect(activeHighlight.content).not.toBe("none");
1007+
expect(Number.parseFloat(activeHighlight.width)).toBeGreaterThan(8);
1008+
} finally {
1009+
await workspaceV2CoverageReporter.stop(page);
1010+
await server.close();
1011+
}
1012+
});
1013+
9271014
test("imports UAT manifest and plays the upbeat multi-instrument studio arrangement", async ({ page }) => {
9281015
const server = await openMidiStudioForImport(page);
9291016
try {

0 commit comments

Comments
 (0)