Skip to content

Commit 15de451

Browse files
committed
Add explicit unwired control visibility states to MIDI Studio V2 - PR_26146_055-midi-studio-v2-unwired-control-visibility
1 parent 4b669f2 commit 15de451

10 files changed

Lines changed: 361 additions & 11 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# PR_26146_055 MIDI Studio V2 Unwired Control Audit
2+
3+
## Scope
4+
5+
Scope reviewed: MIDI Studio V2 visible UI surfaces after PR054 tab bucket consolidation.
6+
7+
Central marker: `setUnwiredControlState` in `tools/midi-studio-v2/js/controls/UnwiredControlState.js`.
8+
9+
Central style: `.midi-studio-v2__unwired-control` in `tools/midi-studio-v2/styles/midiStudioV2.css`.
10+
11+
## Marked Incomplete / Unwired Controls
12+
13+
| Surface | Controls | Status | Behavior |
14+
| --- | --- | --- | --- |
15+
| Tool nav rendered output | `renderedExportTargetTypeSelect`, `renderedExportSaveButton`, Output Type label | Not implemented | Red styling plus tooltip. Save Output still logs `Export rendering not implemented...` and does not claim project save. |
16+
| Tool nav playback for source-only songs | `playButton` when selected song has no editable arrangement and no rendered OGG/MP3/WAV target | Incomplete | Red styling plus tooltip before click. Clicking logs live MIDI not implemented and actionable failure. |
17+
| Workspace proxy nav outside Workspace Manager handoff | `workspaceImportManifestButton`, `workspaceCopyManifestButton`, `workspaceExportManifestButton` | Incomplete | Red styling plus tooltip when visible. In Workspace Manager launches these proxy buttons remain hidden and Return to Workspace remains normal. |
18+
19+
## Reviewed As Wired / Not Marked
20+
21+
| Surface | Controls | Reason |
22+
| --- | --- | --- |
23+
| Tool nav | Import JSON Manifest, Save Project, Reset Song Edits, Export JSON, Stop All Audio | Wired to manifest import, tool-state serialization, save/reset state, JSON Details preview, and audio stop behavior. Disabled states are readiness gates, not unwired placeholders. |
24+
| MIDI Import | MIDI file picker, Import MIDI Source, Inspect MIDI Source | Wired to source file selection and MIDI inspection. Inspect disabled without a selected song is state-dependent. |
25+
| Song Setup | Add Song, Song Details fields, Key/Style dropdowns, Song Sheet parse | Wired to canonical song model and existing parse/update paths. |
26+
| Instruments | GM Type, GM Instrument/Patch, visibility, mute, solo, volume, pan, add/delete | Wired to preview lane state, visible timeline rendering, and playback settings. Unsupported preview mappings remain audible approximations with WARN status rather than unwired controls. |
27+
| Octave Timeline | Canvas editor, zoom controls, section controls, timing preview controls | Wired to canonical timeline edits, zoom, playhead, and Preview Synth timing preview. Section presets disabled for missing section labels are unavailable state, not incomplete implementation. |
28+
| Diagnostics | JSON Details, Timeline Diagnostics, Audio Diagnostics, Rendered Preview diagnostics, Rendered Export Targets, Status | Read-only diagnostics or explicit diagnostic actions such as Copy JSON and Clear. |
29+
30+
## Result
31+
32+
No known visible MIDI Studio V2 incomplete control remains visually indistinguishable from a functional control in the audited surfaces.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# PR_26146_055 MIDI Studio V2 Unwired Control Visibility Validation
2+
3+
## Summary
4+
5+
Status: PASS
6+
7+
Playwright impacted: Yes
8+
9+
This PR adds centralized MIDI Studio V2 unwired/incomplete control visibility. Known incomplete controls now receive a shared red visual state plus tooltip/accessibility status text, while working controls remain normally styled.
10+
11+
## Files Changed
12+
13+
- `tools/midi-studio-v2/js/controls/UnwiredControlState.js`
14+
- `tools/midi-studio-v2/js/controls/RenderedExportActionsControl.js`
15+
- `tools/midi-studio-v2/js/controls/ActionNavControl.js`
16+
- `tools/midi-studio-v2/js/controls/PlaybackControl.js`
17+
- `tools/midi-studio-v2/js/MidiStudioV2App.js`
18+
- `tools/midi-studio-v2/js/bootstrap.js`
19+
- `tools/midi-studio-v2/styles/midiStudioV2.css`
20+
- `tests/playwright/tools/MidiStudioV2.spec.mjs`
21+
- `docs/dev/reports/PR_26146_055-midi-studio-v2-unwired-control-visibility_validation.md`
22+
- `docs/dev/reports/PR_26146_055-midi-studio-v2-unwired-control-audit.md`
23+
- `docs/dev/codex_commands.md`
24+
- `docs/dev/commit_comment.txt`
25+
26+
## Validation Commands
27+
28+
- PASS: `node --check tools/midi-studio-v2/js/controls/UnwiredControlState.js`
29+
- PASS: `node --check tools/midi-studio-v2/js/controls/RenderedExportActionsControl.js`
30+
- PASS: `node --check tools/midi-studio-v2/js/controls/ActionNavControl.js`
31+
- PASS: `node --check tools/midi-studio-v2/js/controls/PlaybackControl.js`
32+
- PASS: `node --check tools/midi-studio-v2/js/MidiStudioV2App.js`
33+
- PASS: `node --check tools/midi-studio-v2/js/bootstrap.js`
34+
- PASS: `node --check tests/playwright/tools/MidiStudioV2.spec.mjs`
35+
- PASS: HTML external-only check for inline `<script>` blocks.
36+
- PASS: HTML no-inline-style/no-inline-handler check for `<style| on[a-z]+=`.
37+
- PASS: `npx playwright test tests/playwright/tools/MidiStudioV2.spec.mjs --grep "marks unwired visible controls|canvas octave timeline edits canonical data"`.
38+
- PASS: `git diff --check` with line-ending warnings only.
39+
40+
## Playwright Coverage
41+
42+
The targeted MIDI Studio V2 tests prove:
43+
44+
- Known incomplete rendered-output controls render with red unwired styling.
45+
- Implemented controls such as Stop All Audio and normal Play remain normal.
46+
- Unwired controls expose tooltip/title/status text.
47+
- Source-only playback that would require live MIDI synthesis marks Play as incomplete before the user clicks it.
48+
- Workspace proxy actions are marked incomplete when they are visible.
49+
- Play and Stop still work for a normal rendered-preview song.
50+
- The canvas-backed Octave Timeline still edits canonical data and drives playback without DOM grid repaint classes.
51+
52+
## Coverage Artifacts
53+
54+
- `docs/dev/reports/playwright_v8_coverage_report.txt` was refreshed by the targeted Playwright run.
55+
- `docs/dev/reports/coverage_changed_js_guardrail.txt` was refreshed by the targeted Playwright run.
56+
- Coverage guardrail WARN: `tools/midi-studio-v2/js/MidiStudioV2App.js` reported advisory low function coverage at 46%. This is advisory only; the changed runtime file was collected and exercised.
57+
58+
## Samples Decision
59+
60+
Full samples smoke test: SKIP.
61+
62+
Reason: explicitly out of scope for this PR and not requested; this change is limited to MIDI Studio V2 visible control state and targeted tool behavior.
63+
64+
## Manual Validation Notes
65+
66+
1. Open MIDI Studio V2 with the UAT manifest.
67+
2. Confirm Save Output and Output Type are visibly red and titled as not implemented.
68+
3. Confirm Play/Stop work for a rendered or editable song.
69+
4. Select a source-only song with no rendered target and confirm Play becomes red/incomplete before clicking.
70+
5. Launch with `?launch=workspace` without Workspace Manager handoff and confirm workspace proxy actions are red/incomplete.

tests/playwright/tools/MidiStudioV2.spec.mjs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,14 @@ async function openMidiStudioFromWorkspace(page, manifestPayload, audioOptions =
342342
return server;
343343
}
344344

345+
async function openMidiStudioWorkspaceProxyNav(page, audioOptions = {}) {
346+
const server = await startRepoServer();
347+
await installMockAudio(page, audioOptions);
348+
await workspaceV2CoverageReporter.start(page);
349+
await page.goto(`${server.baseUrl}/tools/midi-studio-v2/index.html?launch=workspace`, { waitUntil: "domcontentloaded" });
350+
return server;
351+
}
352+
345353
async function selectMidiStudioTab(page, tabId) {
346354
const tab = page.locator(`[data-midi-studio-tab="${tabId}"]`);
347355
if (await tab.getAttribute("aria-selected") === "true") {
@@ -467,6 +475,16 @@ async function instrumentScrollSnapshot(page) {
467475
});
468476
}
469477

478+
async function controlColors(page, selector) {
479+
return page.locator(selector).evaluate((control) => {
480+
const style = getComputedStyle(control);
481+
return {
482+
borderTopColor: style.borderTopColor,
483+
color: style.color
484+
};
485+
});
486+
}
487+
470488
async function prepareInstrumentScrollSentinel(page) {
471489
await page.evaluate(() => {
472490
const leftPanel = document.querySelector(".tool-starter__panel--left");
@@ -2470,6 +2488,90 @@ test.describe("MIDI Studio V2", () => {
24702488
}
24712489
});
24722490

2491+
test("marks unwired visible controls red with tooltips while working controls stay normal", async ({ page }) => {
2492+
let server = await openMidiStudio(page);
2493+
try {
2494+
const outputLabel = page.locator('label[for="renderedExportTargetTypeSelect"]');
2495+
const outputTypeSelect = page.locator("#renderedExportTargetTypeSelect");
2496+
const saveOutputButton = page.locator("#renderedExportSaveButton");
2497+
const playButton = page.locator("#playButton");
2498+
const stopButton = page.locator("#stopButton");
2499+
const stopAllAudioButton = page.locator("#stopAllAudioButton");
2500+
2501+
await expect(outputLabel).toHaveAttribute("data-midi-studio-unwired", "not-implemented");
2502+
await expect(outputTypeSelect).toHaveAttribute("data-midi-studio-unwired", "not-implemented");
2503+
await expect(saveOutputButton).toHaveAttribute("data-midi-studio-unwired", "not-implemented");
2504+
await expect(saveOutputButton).toHaveAttribute("title", /Not implemented: Rendered audio export generation is not implemented yet/);
2505+
await expect(saveOutputButton).toHaveAttribute("aria-label", /Save Output \(Not implemented\)/);
2506+
await expect(outputTypeSelect).toHaveAttribute("title", /Not implemented: Rendered audio export generation is not implemented yet/);
2507+
expect(await controlColors(page, "#renderedExportSaveButton")).toMatchObject({
2508+
borderTopColor: "rgb(248, 113, 113)",
2509+
color: "rgb(254, 202, 202)"
2510+
});
2511+
expect(await controlColors(page, "#renderedExportTargetTypeSelect")).toMatchObject({
2512+
borderTopColor: "rgb(248, 113, 113)",
2513+
color: "rgb(254, 202, 202)"
2514+
});
2515+
2516+
await expect(stopAllAudioButton).not.toHaveAttribute("data-midi-studio-unwired");
2517+
await expect(playButton).not.toHaveAttribute("data-midi-studio-unwired");
2518+
expect((await controlColors(page, "#stopAllAudioButton")).borderTopColor).not.toBe("rgb(248, 113, 113)");
2519+
2520+
await playButton.click();
2521+
await expect(stopButton).toBeEnabled();
2522+
await expect(playButton).not.toHaveAttribute("data-midi-studio-unwired");
2523+
await stopButton.click();
2524+
await expect(playButton).toBeEnabled();
2525+
await expect(stopButton).toBeDisabled();
2526+
2527+
await page.locator('[data-song-id="source-only"]').click();
2528+
await expect(playButton).toHaveAttribute("data-midi-studio-unwired", "incomplete");
2529+
await expect(playButton).toHaveAttribute("title", /Incomplete: Live MIDI playback is not implemented/);
2530+
await expect(playButton).toHaveAttribute("aria-label", /Play \(Incomplete\)/);
2531+
expect(await controlColors(page, "#playButton")).toMatchObject({
2532+
borderTopColor: "rgb(248, 113, 113)",
2533+
color: "rgb(254, 202, 202)"
2534+
});
2535+
await playButton.click();
2536+
await expect(page.locator("#statusLog")).toHaveValue(/WARN Live MIDI synthesis not implemented\./);
2537+
await expect(page.locator("#statusLog")).toHaveValue(/FAIL No rendered audio target is available for Source Only, and no live MIDI engine is available\./);
2538+
await page.locator('[data-song-id="theme-main"]').click();
2539+
await expect(playButton).not.toHaveAttribute("data-midi-studio-unwired");
2540+
2541+
const visibleUnwired = await page.locator("[data-midi-studio-unwired]").evaluateAll((controls) => controls
2542+
.filter((control) => {
2543+
const rect = control.getBoundingClientRect();
2544+
const style = getComputedStyle(control);
2545+
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
2546+
})
2547+
.map((control) => control.id || control.getAttribute("for") || control.textContent.trim()));
2548+
expect(visibleUnwired).toEqual(expect.arrayContaining([
2549+
"renderedExportTargetTypeSelect",
2550+
"renderedExportSaveButton"
2551+
]));
2552+
} finally {
2553+
await workspaceV2CoverageReporter.stop(page);
2554+
await server.close();
2555+
}
2556+
2557+
server = await openMidiStudioWorkspaceProxyNav(page);
2558+
try {
2559+
await expect(page.locator('[data-launch-mode-nav="workspace"]')).toBeVisible();
2560+
for (const selector of ["#workspaceImportManifestButton", "#workspaceCopyManifestButton", "#workspaceExportManifestButton"]) {
2561+
await expect(page.locator(selector)).toBeVisible();
2562+
await expect(page.locator(selector)).toHaveAttribute("data-midi-studio-unwired", "incomplete");
2563+
await expect(page.locator(selector)).toHaveAttribute("title", /Incomplete: Workspace Manager V2 owns this action/);
2564+
expect(await controlColors(page, selector)).toMatchObject({
2565+
borderTopColor: "rgb(248, 113, 113)",
2566+
color: "rgb(254, 202, 202)"
2567+
});
2568+
}
2569+
} finally {
2570+
await workspaceV2CoverageReporter.stop(page);
2571+
await server.close();
2572+
}
2573+
});
2574+
24732575
test("loads selected MIDI source metadata on request", async ({ page }) => {
24742576
const server = await openMidiStudio(page, validManifest, {
24752577
"assets/music/midi/theme-main.mid": validMidiBytes

tools/midi-studio-v2/js/MidiStudioV2App.js

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ export class MidiStudioV2App {
204204
this.directorPanel.render(song, this.payload?.directorMode || {});
205205
this.midiSourceDetails.render(null);
206206
this.midiSourceDetails.setEnabled(Boolean(song));
207-
this.playbackControl.setSelected(song);
207+
this.playbackControl.setSelected(song, this.playbackControlStatus(song));
208208
this.actionNav.setNowPlaying(song);
209209
this.actionNav.setToolActionsEnabled(Boolean(this.payload));
210210
this.updateAudioDiagnostics();
@@ -219,7 +219,7 @@ export class MidiStudioV2App {
219219
if (field === "name") {
220220
song.name = String(value || "").trim() || song.id;
221221
this.songList.render(this.payload?.songs || [], this.selectedSongId);
222-
this.playbackControl.setSelected(song);
222+
this.playbackControl.setSelected(song, this.playbackControlStatus(song));
223223
this.actionNav.setNowPlaying(song);
224224
} else if (field === "id") {
225225
if (!this.updateSelectedSongId(value)) {
@@ -318,6 +318,17 @@ export class MidiStudioV2App {
318318
return (this.payload?.songs || []).find((song) => song.id === this.selectedSongId) || null;
319319
}
320320

321+
playbackControlStatus(song) {
322+
if (!song || song.studioArrangement || this.playback.renderedPreviewSource(song)) {
323+
return { unwired: false };
324+
}
325+
return {
326+
detail: `Live MIDI playback is not implemented and ${song.name || song.id} has no rendered OGG/MP3/WAV target.`,
327+
status: "Incomplete",
328+
unwired: true
329+
};
330+
}
331+
321332
selectedSongState() {
322333
const song = this.selectedSong();
323334
return {
@@ -501,7 +512,7 @@ export class MidiStudioV2App {
501512
}
502513
const result = await this.playback.playRenderedPreview(song, { loop: this.playbackControl.loopEnabled() });
503514
if (!result.ok) {
504-
this.playbackControl.setStopped(song);
515+
this.playbackControl.setStopped(song, this.playbackControlStatus(song));
505516
if (result.liveMidiNotImplemented) {
506517
this.statusLog.warn("Live MIDI synthesis not implemented.");
507518
}
@@ -520,15 +531,15 @@ export class MidiStudioV2App {
520531
if (!this.currentInstrumentGridResult()?.ok) {
521532
const arranged = this.applySelectedSongArrangement("play request");
522533
if (!arranged) {
523-
this.playbackControl.setStopped(song);
534+
this.playbackControl.setStopped(song, this.playbackControlStatus(song));
524535
this.updateAudioDiagnostics();
525536
return;
526537
}
527538
}
528539
const gridResult = this.currentInstrumentGridResult();
529540
const section = this.instrumentGrid.selectedSection() || gridResult?.sections?.[0] || null;
530541
if (!section) {
531-
this.playbackControl.setStopped(song);
542+
this.playbackControl.setStopped(song, this.playbackControlStatus(song));
532543
this.statusLog.fail(`Playable arrangement section not found for ${song.name}.`);
533544
this.updateAudioDiagnostics();
534545
return;
@@ -541,7 +552,7 @@ export class MidiStudioV2App {
541552
startStep: section.startStep
542553
});
543554
if (!started) {
544-
this.playbackControl.setStopped(song);
555+
this.playbackControl.setStopped(song, this.playbackControlStatus(song));
545556
this.actionNav.setNowPlaying(song);
546557
return;
547558
}
@@ -1130,7 +1141,7 @@ export class MidiStudioV2App {
11301141
this.playback.stop();
11311142
const stoppedCount = this.previewSynth.stop();
11321143
this.instrumentGrid.stopPreviewUi();
1133-
this.playbackControl.setStopped(this.selectedSong());
1144+
this.playbackControl.setStopped(this.selectedSong(), this.playbackControlStatus(this.selectedSong()));
11341145
this.actionNav.setNowPlaying(this.selectedSong());
11351146
if (log) {
11361147
this.statusLog.ok(`Stop completed. Cleared ${stoppedCount} scheduled oscillator${stoppedCount === 1 ? "" : "s"} and stopped all MIDI Studio preview audio.`);
@@ -1142,7 +1153,7 @@ export class MidiStudioV2App {
11421153
this.playback.stop();
11431154
const stoppedCount = this.previewSynth.stop();
11441155
this.instrumentGrid.stopPreviewUi();
1145-
this.playbackControl.setStopped(this.selectedSong());
1156+
this.playbackControl.setStopped(this.selectedSong(), this.playbackControlStatus(this.selectedSong()));
11461157
this.actionNav.setNowPlaying(this.selectedSong());
11471158
this.statusLog.ok(`Stop All Audio completed. Cleared ${stoppedCount} scheduled oscillator${stoppedCount === 1 ? "" : "s"} and reset Preview Synth state.`);
11481159
this.updateAudioDiagnostics();

tools/midi-studio-v2/js/bootstrap.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ window.addEventListener("DOMContentLoaded", () => {
122122
}),
123123
previewSynth: new PreviewSynthEngine({ windowRef: window }),
124124
renderedExportActions: new RenderedExportActionsControl({
125+
exportTargetTypeLabel: requireElement('label[for="renderedExportTargetTypeSelect"]'),
125126
exportTargetTypeSelect: requireElement("#renderedExportTargetTypeSelect"),
126127
saveButton: requireElement("#renderedExportSaveButton")
127128
}),

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import { setUnwiredControlState } from "./UnwiredControlState.js";
2+
3+
const WORKSPACE_PROXY_INCOMPLETE = "Workspace Manager V2 owns this action in connected launches; this standalone workspace proxy only reports ownership.";
4+
15
export class ActionNavControl {
26
constructor({
37
locationRef = window.location,
@@ -49,6 +53,7 @@ export class ActionNavControl {
4953
onWorkspaceImportManifest
5054
}) {
5155
this.applyLaunchMode();
56+
this.markWorkspaceProxyActionsIncomplete();
5257
this.toolImportManifestButton.addEventListener("click", () => this.toolImportManifestInput.click());
5358
this.toolImportManifestInput.addEventListener("change", () => onToolImportManifest(this.toolImportManifestInput.files?.[0] || null));
5459
this.saveProjectButton.addEventListener("click", onSaveProject);
@@ -64,6 +69,20 @@ export class ActionNavControl {
6469
});
6570
}
6671

72+
markWorkspaceProxyActionsIncomplete() {
73+
[
74+
this.workspaceImportManifestButton,
75+
this.workspaceCopyManifestButton,
76+
this.workspaceExportManifestButton
77+
].forEach((control) => {
78+
setUnwiredControlState(control, {
79+
active: true,
80+
detail: WORKSPACE_PROXY_INCOMPLETE,
81+
status: "Incomplete"
82+
});
83+
});
84+
}
85+
6786
applyLaunchMode() {
6887
const params = new URLSearchParams(this.location.search);
6988
const isWorkspace = params.get("launch") === "workspace";

0 commit comments

Comments
 (0)