Skip to content

Commit 844e4ca

Browse files
author
DavidQ
committed
Add frame state contextual help and remove hardcoded idle frame naming - PR_26133_047-frame-state-help-and-frame-id-generation-fix
1 parent bb46832 commit 844e4ca

9 files changed

Lines changed: 148 additions & 39 deletions

File tree

docs/dev/reports/playwright_v8_coverage_report.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# PR_26133_046 Playwright V8 Coverage Report
1+
# PR_26133_047 Playwright V8 Coverage Report
22

3-
Task: PR_26133_046-object-vector-frame-palette-and-shape-action-cleanup
3+
Task: PR_26133_047-frame-state-help-and-frame-id-generation-fix
44
Date: 2026-05-15
55

66
## Result
@@ -37,4 +37,4 @@ PASS - Coverage reporting was generated during `npm run test:workspace-v2`.
3737

3838
## PR-Specific Note
3939

40-
The Workspace V2 run exercised Object Vector Studio V2 frame creation/duplication/deletion/reordering, palette sort controls, Paint/Stroke mode application through canvas clicks, and icon-only shape order/group controls under Objects.
40+
The Workspace V2 run exercised Object Vector Studio V2 state selection, contextual state help text, legacy state/frame manifest loading, generic frame duplication, playback, runtime preview, and export paths.
Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# PR_26133_046 Workspace V2 Playwright Results
1+
# PR_26133_047 Workspace V2 Playwright Results
22

3-
Task: PR_26133_046-object-vector-frame-palette-and-shape-action-cleanup
3+
Task: PR_26133_047-frame-state-help-and-frame-id-generation-fix
44
Date: 2026-05-15
55

66
## Result
@@ -14,21 +14,14 @@ PASS - `npm run test:workspace-v2` completed successfully.
1414

1515
## PR-Specific Coverage
1616

17-
- Verified Delete Frame removes a selected frame and refuses to delete the final remaining frame.
18-
- Verified generated and canonical Asteroids frame IDs use `frame-x` instead of state-prefixed frame IDs.
19-
- Verified Left/Right frame controls and Frame Earlier/Frame Later reorder frames through the same sequence-safe path.
20-
- Verified palette sort buttons show ascending/descending caret indicators and toggle sort direction.
21-
- Verified Paint/Stroke buttons switch action mode without changing selected shape style.
22-
- Verified canvas clicks still apply the currently selected Paint/Stroke color.
23-
- Verified shape order/group actions moved under Objects and render as icon-only controls with titles.
17+
- Verified the Object Vector Studio V2 frame state dropdown renders beside a `?` help button.
18+
- Verified the `idle` help text title is `Default stationary state.` and `No movement or action animation active.`
19+
- Verified the `move` help text title is `Movement/action state.` and `Used for thrusting, walking, flying, or active movement visuals.`
20+
- Verified new schema-valid state IDs include `move` while legacy `thrust` manifests still load.
21+
- Verified duplicate frame generation uses generic `frame-x` IDs and does not continue legacy `idle-frame-x` naming.
22+
- Verified a legacy `idle-frame-1` import duplicates into `frame-1`.
2423

2524
## Additional Validation
2625

27-
- Targeted Playwright check passed for Object Vector Studio layout, animation frame controls, and Asteroids runtime rendering.
28-
- Node syntax checks passed for `tools/object-vector-studio-v2/js/ToolStarterApp.js` and `tools/object-vector-studio-v2/js/bootstrap.js`.
29-
- Node schema-service check passed for `games/Asteroids/game.manifest.json` and its workspace manifest.
26+
- Targeted Playwright check passed for `supports Object Vector Studio V2 animation states and frame timeline foundation`.
3027
- `git diff --check` passed.
31-
32-
## Frame Control Note
33-
34-
`Left` and `Right` are directional aliases for the same frame-order mutation used by `Frame Earlier` and `Frame Later`; all four controls preserve the selected state linkage and only change frame order.

tests/playwright/tools/WorkspaceManagerV2.spec.mjs

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3982,8 +3982,8 @@ test.describe("Workspace Manager V2 bootstrap", () => {
39823982
]
39833983
}
39843984
],
3985-
id: "thrust",
3986-
name: "Thrust"
3985+
id: "move",
3986+
name: "Move"
39873987
}
39883988
],
39893989
tags: []
@@ -3997,6 +3997,8 @@ test.describe("Workspace Manager V2 bootstrap", () => {
39973997
await expect(page.locator('[data-object-id="object.animation.ship-template"]')).toHaveAttribute("aria-pressed", "true");
39983998
await expect(page.locator("#objectVectorStudioV2FrameTimeline [data-state-id='idle']")).toHaveCount(1);
39993999
await expect(page.locator("#objectVectorStudioV2FrameTimeline [data-frame-id='frame-1']")).toHaveAttribute("aria-pressed", "true");
4000+
await expect(page.locator("#objectVectorStudioV2StateSelect")).toHaveValue("idle");
4001+
await expect(page.locator("#objectVectorStudioV2StateHelpButton")).toHaveAttribute("title", "Default stationary state.\nNo movement or action animation active.");
40004002
await expect(page.locator("#objectVectorStudioV2JsonDetails")).toContainText('"id": "idle"');
40014003
await expect(page.locator("#objectVectorStudioV2ObjectDetails")).not.toContainText("Selected Shape");
40024004
await expect(page.locator("#objectVectorStudioV2ObjectDetails")).not.toContainText("ship-template-hull");
@@ -4042,10 +4044,12 @@ test.describe("Workspace Manager V2 bootstrap", () => {
40424044
await expect(page.locator("#objectVectorStudioV2RenderSurface [data-onion-skin-frame='frame-2']")).toHaveCount(1);
40434045
await expect(page.locator("#statusLog")).toHaveValue(/OK Onion-skin preview enabled\./);
40444046

4045-
await page.evaluate(() => window.__objectVectorStudioV2App.selectState("thrust", "test state selection"));
4047+
await page.evaluate(() => window.__objectVectorStudioV2App.selectState("move", "test state selection"));
40464048
await expect(page.locator('[data-object-id="object.animation.ship-template"]')).toHaveAttribute("aria-pressed", "true");
4047-
await expect(page.locator("#objectVectorStudioV2FrameTimeline [data-state-id='thrust']")).toHaveCount(1);
4048-
await expect(page.locator("#statusLog")).toHaveValue(/OK Selected state Thrust from test state selection; active object remains Ship Template\./);
4049+
await expect(page.locator("#objectVectorStudioV2StateSelect")).toHaveValue("move");
4050+
await expect(page.locator("#objectVectorStudioV2StateHelpButton")).toHaveAttribute("title", "Movement/action state.\nUsed for thrusting, walking, flying, or active movement visuals.");
4051+
await expect(page.locator("#objectVectorStudioV2FrameTimeline [data-state-id='move']")).toHaveCount(1);
4052+
await expect(page.locator("#statusLog")).toHaveValue(/OK Selected state Move from test state selection; active object remains Ship Template\./);
40494053
await page.evaluate(() => window.__objectVectorStudioV2App.selectState("idle", "test state selection"));
40504054
await expect(page.locator("#objectVectorStudioV2FrameTimeline [data-state-id='idle']")).toHaveCount(2);
40514055

@@ -4070,7 +4074,7 @@ test.describe("Workspace Manager V2 bootstrap", () => {
40704074

40714075
await page.locator("#objectVectorStudioV2CopyJsonButton").click();
40724076
const copiedPayload = await page.evaluate(() => JSON.parse(sessionStorage.getItem("object-vector-studio-v2.animation-copied-json")));
4073-
expect(copiedPayload.objects[0].states.map((state) => state.id)).toEqual(expect.arrayContaining(["idle", "thrust"]));
4077+
expect(copiedPayload.objects[0].states.map((state) => state.id)).toEqual(expect.arrayContaining(["idle", "move"]));
40744078
expect(copiedPayload.objects[0].states.find((state) => state.id === "idle").frames).toHaveLength(2);
40754079

40764080
const svgDownloadPromise = page.waitForEvent("download");
@@ -4082,6 +4086,29 @@ test.describe("Workspace Manager V2 bootstrap", () => {
40824086
expect(exportedSvg).toContain('data-object-state="idle"');
40834087
expect(exportedSvg).toContain('"frameId"');
40844088

4089+
const legacyPayloadPath = testInfo.outputPath("object-vector-legacy-frame-id.json");
4090+
const legacyPayload = JSON.parse(JSON.stringify(copiedPayload));
4091+
legacyPayload.objects[0].states.find((state) => state.id === "idle").frames = [
4092+
{
4093+
durationFrames: 1,
4094+
id: "idle-frame-1",
4095+
order: 1,
4096+
shapeOverrides: [
4097+
{
4098+
shapeIndex: 0,
4099+
transform: { origin: { x: 0, y: 0 }, rotation: 0, scaleX: 1, scaleY: 1, x: 12, y: 6 },
4100+
visible: true
4101+
}
4102+
]
4103+
}
4104+
];
4105+
await writeFile(legacyPayloadPath, JSON.stringify(legacyPayload, null, 2), "utf8");
4106+
await page.locator("#objectVectorStudioV2ImportJsonInput").setInputFiles(legacyPayloadPath);
4107+
await expect(page.locator("#objectVectorStudioV2FrameTimeline [data-frame-id='idle-frame-1']")).toHaveAttribute("aria-pressed", "true");
4108+
await page.locator("#objectVectorStudioV2DuplicateFrameButton").click();
4109+
await expect(page.locator("#objectVectorStudioV2FrameTimeline [data-frame-id='frame-1']")).toHaveAttribute("aria-pressed", "true");
4110+
await expect(page.locator("#statusLog")).toHaveValue(/OK Duplicated frame idle-frame-1 as frame-1\./);
4111+
40854112
const invalidPayloadPath = testInfo.outputPath("object-vector-invalid-animation.json");
40864113
await writeFile(invalidPayloadPath, JSON.stringify({
40874114
name: "Invalid Animation Payload",
@@ -4104,7 +4131,7 @@ test.describe("Workspace Manager V2 bootstrap", () => {
41044131
version: 1
41054132
}, null, 2), "utf8");
41064133
await page.locator("#objectVectorStudioV2ImportJsonInput").setInputFiles(invalidPayloadPath);
4107-
await expect(page.locator("#statusLog")).toHaveValue(/FAIL Object Vector Studio V2 schema validation failed from import:object-vector-invalid-animation\.json: root\.objects\[0\]\.states\[0\]\.id must be one of idle, thrust, damaged, destroyed, active, inactive\./);
4134+
await expect(page.locator("#statusLog")).toHaveValue(/FAIL Object Vector Studio V2 schema validation failed from import:object-vector-invalid-animation\.json: root\.objects\[0\]\.states\[0\]\.id must be one of idle, move, active, inactive, damaged, destroyed, thrust\./);
41084135

41094136
expect(pageErrors).toEqual([]);
41104137
} finally {

tools/object-vector-studio-v2/index.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,10 @@ <h2 class="tools-platform-frame__eyebrow">First-Class Tools Surface V2</h2>
165165
<div id="objectVectorStudioV2ObjectPreviewFooter" class="object-vector-studio-v2__preview-footer">Object ID: none</div>
166166
<hr class="object-vector-studio-v2__separator">
167167
<div class="object-vector-studio-v2__animation-controls" aria-label="Animation controls">
168+
<select id="objectVectorStudioV2StateSelect" class="object-vector-studio-v2__state-select" aria-label="Frame state" disabled>
169+
<option value="">No state</option>
170+
</select>
171+
<button id="objectVectorStudioV2StateHelpButton" class="object-vector-studio-v2__state-help" type="button" disabled title="Disabled until a state is selected" aria-label="State help">?</button>
168172
<button id="objectVectorStudioV2PlayButton" type="button" disabled title="Disabled until a state frame is selected">Play</button>
169173
<button id="objectVectorStudioV2PauseButton" type="button" disabled title="Disabled until playback starts">Pause</button>
170174
<button id="objectVectorStudioV2StopButton" type="button" disabled title="Disabled until a state frame is selected">Stop</button>

tools/object-vector-studio-v2/js/ToolStarterApp.js

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -110,17 +110,28 @@ const OBJECT_VECTOR_STUDIO_STATIC_ICON_TARGETS = Object.freeze([
110110
[".object-vector-studio-v2__z-icon--ungroup", "ungroup"]
111111
]);
112112

113-
const OBJECT_STATE_IDS = Object.freeze(["idle", "thrust", "damaged", "destroyed", "active", "inactive"]);
113+
const OBJECT_STATE_IDS = Object.freeze(["idle", "move", "active", "inactive", "damaged", "destroyed", "thrust"]);
114114

115115
const OBJECT_STATE_LABELS = Object.freeze({
116116
active: "Active",
117117
damaged: "Damaged",
118118
destroyed: "Destroyed",
119119
idle: "Idle",
120120
inactive: "Inactive",
121+
move: "Move",
121122
thrust: "Thrust"
122123
});
123124

125+
const OBJECT_STATE_HELP = Object.freeze({
126+
active: ["Object is enabled and participating in gameplay.", "Typically the default active runtime state."],
127+
damaged: ["Object is visually damaged but still active."],
128+
destroyed: ["Object destruction/death state.", "Usually final or transitional before removal."],
129+
idle: ["Default stationary state.", "No movement or action animation active."],
130+
inactive: ["Object is disabled, hidden, sleeping, or not participating in gameplay."],
131+
move: ["Movement/action state.", "Used for thrusting, walking, flying, or active movement visuals."],
132+
thrust: ["Movement/action state.", "Used for thrusting, walking, flying, or active movement visuals."]
133+
});
134+
124135
const SHAPE_TYPE_DETAILS = Object.freeze({
125136
arc: "Arc primitive metadata with center, radius, and angle span.",
126137
circle: "Circle primitive metadata with center point and radius.",
@@ -620,6 +631,7 @@ export class ToolStarterApp {
620631
}
621632

622633
bindAnimationControls() {
634+
this.elements.stateSelect.addEventListener("change", () => this.selectState(this.elements.stateSelect.value, "state dropdown"));
623635
this.elements.deleteFrameButton.addEventListener("click", () => this.deleteSelectedFrame());
624636
this.elements.duplicateFrameButton.addEventListener("click", () => this.duplicateSelectedFrame());
625637
this.elements.frameLeftButton.addEventListener("click", () => this.moveSelectedFrame("earlier", "left"));
@@ -1560,6 +1572,7 @@ export class ToolStarterApp {
15601572
}
15611573

15621574
renderFrameTimeline() {
1575+
this.renderStateSelect();
15631576
this.elements.frameTimeline.replaceChildren();
15641577
const object = this.selectedObject();
15651578
const state = this.selectedState();
@@ -1591,6 +1604,61 @@ export class ToolStarterApp {
15911604
this.updateAnimationActionState();
15921605
}
15931606

1607+
renderStateSelect() {
1608+
const object = this.selectedObject();
1609+
const states = this.objectStates(object);
1610+
this.elements.stateSelect.replaceChildren();
1611+
if (!object || !states.length) {
1612+
const option = document.createElement("option");
1613+
option.value = "";
1614+
option.textContent = "No state";
1615+
this.elements.stateSelect.append(option);
1616+
this.elements.stateSelect.value = "";
1617+
this.elements.stateSelect.disabled = true;
1618+
this.updateStateHelpButton(null);
1619+
return;
1620+
}
1621+
1622+
const currentState = this.selectedState();
1623+
if (!currentState) {
1624+
const option = document.createElement("option");
1625+
option.value = "";
1626+
option.textContent = "No state";
1627+
this.elements.stateSelect.append(option);
1628+
}
1629+
states.forEach((state) => {
1630+
const option = document.createElement("option");
1631+
option.value = state.id;
1632+
option.textContent = state.id;
1633+
this.elements.stateSelect.append(option);
1634+
});
1635+
this.elements.stateSelect.disabled = false;
1636+
this.elements.stateSelect.value = currentState?.id || "";
1637+
this.updateStateHelpButton(currentState);
1638+
}
1639+
1640+
updateStateHelpButton(state) {
1641+
const hasState = Boolean(state);
1642+
this.elements.stateHelpButton.disabled = !hasState;
1643+
this.elements.stateHelpButton.setAttribute("aria-disabled", String(!hasState));
1644+
if (!hasState) {
1645+
this.elements.stateHelpButton.title = "Disabled until a state is selected";
1646+
this.elements.stateHelpButton.dataset.stateHelp = "";
1647+
this.elements.stateHelpButton.setAttribute("aria-label", "State help");
1648+
return;
1649+
}
1650+
1651+
const helpText = this.stateHelpText(state.id);
1652+
this.elements.stateHelpButton.title = helpText;
1653+
this.elements.stateHelpButton.dataset.stateHelp = helpText;
1654+
this.elements.stateHelpButton.setAttribute("aria-label", `State help for ${state.id}: ${helpText.replace(/\s+/gu, " ")}`);
1655+
}
1656+
1657+
stateHelpText(stateId) {
1658+
const helpLines = OBJECT_STATE_HELP[stateId] || [`No contextual help is available for state ${stateId || "unknown"}.`];
1659+
return helpLines.join("\n");
1660+
}
1661+
15941662
createFrameThumbnail(object, frame) {
15951663
const svg = document.createElementNS(SVG_NS, "svg");
15961664
svg.classList.add("object-vector-studio-v2__frame-thumbnail");
@@ -2826,7 +2894,7 @@ export class ToolStarterApp {
28262894
this.selectedFrameId = sortedFrames(state)[0]?.id || "";
28272895
this.stopPlaybackTimer();
28282896
this.renderPayload();
2829-
this.statusLog.write(`OK Selected state ${OBJECT_STATE_LABELS[state.id]} from ${sourceLabel}; active object remains ${object.name}.`);
2897+
this.statusLog.write(`OK Selected state ${OBJECT_STATE_LABELS[state.id] || state.id} from ${sourceLabel}; active object remains ${object.name}.`);
28302898
}
28312899

28322900
selectFrame(frameId, sourceLabel) {
@@ -2856,11 +2924,11 @@ export class ToolStarterApp {
28562924
}
28572925
const stateId = "idle";
28582926
if (!OBJECT_STATE_IDS.includes(stateId)) {
2859-
this.statusLog.write("FAIL Create state blocked: choose idle, thrust, damaged, destroyed, active, or inactive.");
2927+
this.statusLog.write(`FAIL Create state blocked: choose ${OBJECT_STATE_IDS.join(", ")}.`);
28602928
return;
28612929
}
28622930
if (this.objectStates(object).some((state) => state.id === stateId)) {
2863-
this.statusLog.write(`WARN Create state skipped: ${OBJECT_STATE_LABELS[stateId]} already exists for ${object.name}.`);
2931+
this.statusLog.write(`WARN Create state skipped: ${OBJECT_STATE_LABELS[stateId] || stateId} already exists for ${object.name}.`);
28642932
this.selectState(stateId, "existing state");
28652933
return;
28662934
}
@@ -2872,9 +2940,9 @@ export class ToolStarterApp {
28722940
nextObject.states.push({
28732941
frames: [frame],
28742942
id: stateId,
2875-
name: OBJECT_STATE_LABELS[stateId]
2943+
name: OBJECT_STATE_LABELS[stateId] || stateId
28762944
});
2877-
this.commitPayloadUpdate(nextPayload, object.id, this.selectedShapeIndex, `OK Created state ${OBJECT_STATE_LABELS[stateId]} with frame ${frame.id}.`, "Create state failed schema validation", {
2945+
this.commitPayloadUpdate(nextPayload, object.id, this.selectedShapeIndex, `OK Created state ${OBJECT_STATE_LABELS[stateId] || stateId} with frame ${frame.id}.`, "Create state failed schema validation", {
28782946
selectedFrameId: frame.id,
28792947
selectedStateId: stateId
28802948
});
@@ -2917,7 +2985,7 @@ export class ToolStarterApp {
29172985
}
29182986
const frames = sortedFrames(state);
29192987
if (frames.length <= 1) {
2920-
this.statusLog.write(`WARN Delete frame skipped: frame ${frame.id} is the only frame in ${OBJECT_STATE_LABELS[state.id]}.`);
2988+
this.statusLog.write(`WARN Delete frame skipped: frame ${frame.id} is the only frame in ${OBJECT_STATE_LABELS[state.id] || state.id}.`);
29212989
return;
29222990
}
29232991
const index = frames.findIndex((candidate) => candidate.id === frame.id);
@@ -2930,7 +2998,7 @@ export class ToolStarterApp {
29302998
});
29312999
nextState.frames = nextFrames;
29323000
const nextSelectedFrame = nextFrames[Math.min(index, nextFrames.length - 1)] || nextFrames[0];
2933-
this.commitPayloadUpdate(nextPayload, object.id, this.selectedShapeIndex, `OK Deleted frame ${frame.id} from ${OBJECT_STATE_LABELS[state.id]}.`, "Delete frame failed schema validation", {
3001+
this.commitPayloadUpdate(nextPayload, object.id, this.selectedShapeIndex, `OK Deleted frame ${frame.id} from ${OBJECT_STATE_LABELS[state.id] || state.id}.`, "Delete frame failed schema validation", {
29343002
selectedFrameId: nextSelectedFrame.id,
29353003
selectedStateId: state.id
29363004
});
@@ -2987,7 +3055,7 @@ export class ToolStarterApp {
29873055
this.isAnimationPlaying = true;
29883056
this.updateAnimationActionState();
29893057
this.playbackTimerId = this.window.setInterval(() => this.advancePlaybackFrame(), Math.round(1000 / fps));
2990-
this.statusLog.write(`OK Playback started for state ${OBJECT_STATE_LABELS[state.id]} at ${fps} FPS.`);
3058+
this.statusLog.write(`OK Playback started for state ${OBJECT_STATE_LABELS[state.id] || state.id} at ${fps} FPS.`);
29913059
}
29923060

29933061
pauseAnimation() {
@@ -3041,7 +3109,7 @@ export class ToolStarterApp {
30413109
return;
30423110
}
30433111
this.stopPlaybackTimer();
3044-
this.statusLog.write(`OK Playback completed for state ${OBJECT_STATE_LABELS[state.id]}.`);
3112+
this.statusLog.write(`OK Playback completed for state ${OBJECT_STATE_LABELS[state.id] || state.id}.`);
30453113
}
30463114

30473115
addObject() {
@@ -4968,7 +5036,7 @@ export class ToolStarterApp {
49685036

49695037
uniqueFrameId(state) {
49705038
const usedIds = new Set(sortedFrames(state).map((frame) => frame.id));
4971-
let suffix = usedIds.size + 1;
5039+
let suffix = 1;
49725040
let candidate = `frame-${suffix}`;
49735041
while (usedIds.has(candidate)) {
49745042
suffix += 1;

0 commit comments

Comments
 (0)