Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 77 additions & 2 deletions assets/toolbox/sprites/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ const editorHistory = {
redoStack: [],
undoStack: [],
};
const animationPreview = {
frameIndex: 0,
timerId: null,
};

function gridLabel(size) {
return `Sprite Creator ${size} by ${size} pixel canvas`;
Expand Down Expand Up @@ -467,12 +471,12 @@ function renderPixelsToCanvas(canvas, paintedPixels, gridSize) {
}
}

function renderPreview() {
function renderPreview(frame = currentFrame()) {
const canvas = document.querySelector("[data-sprites-preview-canvas]");
if (!canvas) {
return;
}
renderPixelsToCanvas(canvas, editorState.paintedPixels, editorState.gridSize);
renderPixelsToCanvas(canvas, frame.paintedPixels, editorState.gridSize);
}

function renderFrameStrip() {
Expand Down Expand Up @@ -558,6 +562,63 @@ function deleteFrame() {
updateHistoryControls();
}

function updateAnimationControls(isPlaying) {
const playButton = document.querySelector("[data-sprites-play-animation]");
const stopButton = document.querySelector("[data-sprites-stop-animation]");
if (playButton) {
playButton.disabled = isPlaying;
}
if (stopButton) {
stopButton.disabled = !isPlaying;
}
}

function animationStatusText(prefix, frameIndex = editorState.activeFrameIndex) {
return `${prefix} Frame ${frameIndex + 1} of ${editorState.frames.length}. Unsaved preview only.`;
}

function showAnimationFrame(frameIndex) {
const normalizedIndex = frameIndex % editorState.frames.length;
animationPreview.frameIndex = normalizedIndex;
renderPreview(editorState.frames[normalizedIndex]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve selected draft export during playback

While Play Preview is running, this writes animation frames into the same [data-sprites-preview-canvas] that exportPreviewPng later downloads, even though the export panel promises the unsaved editor draft. If a user previews motion and clicks Download PNG before stopping, the file contains whichever animation frame the timer last rendered rather than the selected frame; stop playback or use a separate animation canvas before exporting.

Useful? React with 👍 / 👎.

const status = document.querySelector("[data-sprites-animation-status]");
if (status) {
status.textContent = animationStatusText("Playing", normalizedIndex);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid live-region updates on every animation tick

When Play Preview runs, this line rewrites [data-sprites-animation-status] every 120 ms, and the new HTML renders that element with role="status". For screen-reader users that makes the animation frame counter a live region that can announce continuously while the preview plays, making the page noisy and hard to control. Announce only play/stop, or make the per-frame counter non-live/throttled.

Useful? React with 👍 / 👎.

}
}

function stopAnimationPreview(message = "Animation preview stopped.") {
if (animationPreview.timerId) {
clearInterval(animationPreview.timerId);
animationPreview.timerId = null;
}
renderPreview(currentFrame());
updateAnimationControls(false);
const status = document.querySelector("[data-sprites-animation-status]");
if (status) {
status.textContent = `${message} Selected Frame ${currentFrame().frameNumber} is shown.`;
}
}

function playAnimationPreview() {
const status = document.querySelector("[data-sprites-animation-status]");
if (editorState.frames.length < 2) {
if (status) {
status.textContent = "Animation preview needs at least two unsaved frames.";
}
return;
}
if (animationPreview.timerId) {
clearInterval(animationPreview.timerId);
}
animationPreview.frameIndex = 0;
updateAnimationControls(true);
showAnimationFrame(animationPreview.frameIndex);
animationPreview.timerId = setInterval(() => {
showAnimationFrame((animationPreview.frameIndex + 1) % editorState.frames.length);
}, DEFAULT_FRAME_DURATION_MS);
Comment on lines +617 to +619

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop playback when frames drop below two

If the user starts Play Preview with two frames and then clicks Delete Frame, the frame list can shrink to one while this interval keeps firing; the controls stay in the playing state and the status becomes Playing Frame 1 of 1 even though playAnimationPreview refuses to start with fewer than two frames. Re-check the frame count in the timer or stop playback from frame mutation handlers.

Useful? React with 👍 / 👎.

}

function exportPreviewPng() {
const canvas = document.querySelector("[data-sprites-preview-canvas]");
const status = document.querySelector("[data-sprites-export-status]");
Expand Down Expand Up @@ -701,6 +762,18 @@ function wireFrameActions() {
}
}

function wireAnimationActions() {
const playButton = document.querySelector("[data-sprites-play-animation]");
if (playButton) {
playButton.addEventListener("click", playAnimationPreview);
}

const stopButton = document.querySelector("[data-sprites-stop-animation]");
if (stopButton) {
stopButton.addEventListener("click", () => stopAnimationPreview());
}
}

function wireHistoryActions() {
const undoButton = document.querySelector("[data-sprites-undo]");
if (undoButton) {
Expand Down Expand Up @@ -741,6 +814,7 @@ wireDrawingTools();
wirePaletteButtons();
wireCanvasActions();
wireFrameActions();
wireAnimationActions();
wireHistoryActions();
wireZoomControls();
wireExportButton();
Expand All @@ -751,3 +825,4 @@ setZoomLevel(editorState.zoomLevel);
updateHistoryControls();
renderPreview();
renderFrameStrip();
updateAnimationControls(false);
31 changes: 31 additions & 0 deletions dev/tests/playwright/tools/SpritesToolShell.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ test("Sprite Creator shell loads with visible tool, canvas, details, and status
await expect(page.getByRole("heading", { level: 2, name: "Sprite Details" })).toBeVisible();
await expect(page.locator("[data-sprites-frame-strip]")).toBeVisible();
await expect(page.locator("[data-sprites-frame-status]")).toContainText("1 unsaved frame");
await expect(page.locator("[data-sprites-animation-status]")).toContainText("Animation preview is stopped");
await expect(page.locator("[data-sprites-toolbar]")).toBeVisible();
await expect(page.getByRole("button", { name: "Pencil tool" })).toBeEnabled();
await expect(page.getByRole("button", { name: "Eraser tool" })).toBeEnabled();
Expand Down Expand Up @@ -535,3 +536,33 @@ test("Sprite Creator edits unsaved frame strip frames", async ({ page }) => {
await server.close();
}
});

test("Sprite Creator previews unsaved animation frames", async ({ page }) => {
const server = await startSpriteShellTestServer();
const failures = collectPageFailures(page);

try {
await page.goto(`${server.baseUrl}/toolbox/sprites/index.html`, { waitUntil: "networkidle" });

await page.getByRole("button", { name: "Gold editor color" }).click();
await spriteCell(page, 1, 1).click();
await page.getByRole("button", { name: "Add Frame" }).click();
await page.getByRole("button", { name: "Blue editor color" }).click();
await spriteCell(page, 1, 1).click();

await page.getByRole("button", { name: "Play Preview" }).click();
await expect(page.getByRole("button", { name: "Stop Preview" })).toBeEnabled();
await expect(page.locator("[data-sprites-animation-status]")).toContainText("Playing Frame 1");
await expect(page.locator("[data-sprites-animation-status]")).toContainText("Playing Frame 2", { timeout: 1500 });

await page.getByRole("button", { name: "Stop Preview" }).click();
await expect(page.locator("[data-sprites-animation-status]")).toContainText("Selected Frame 2 is shown");
await expectCenterAndPreviewPainted(page, 1, 1, "sprite-canvas-cell--blue");

expect(failures.failedRequests).toEqual([]);
expect(failures.pageErrors).toEqual([]);
expect(failures.consoleErrors).toEqual([]);
} finally {
await server.close();
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# PR_26179_CHARLIE_036-sprites-animation-preview

Team: CHARLIE

Mode: Batch governance stacked feature workflow

Base branch: PR_26179_CHARLIE_035-sprites-frame-editing

Branch: PR_26179_CHARLIE_036-sprites-animation-preview

## Summary

Added unsaved animation preview playback for Sprite Creator. Play Preview cycles the right preview canvas through unsaved page-session frames, and Stop Preview returns the preview to the selected frame. No persistence, database, API, schema, save-to-library, or publishing behavior was introduced.

## Branch Validation

PASS

- Built from `PR_26179_CHARLIE_035-sprites-frame-editing`.
- Scope stayed limited to unsaved animation preview.
- No DB/API/schema changes.
- No saved product data or browser-owned authoritative product data added.
- No start_of_day files changed.
- ZIP package created at the requested path.

## Requirement Checklist

PASS - Animation preview controls added.

PASS - Preview requires unsaved frame strip frames only.

PASS - Play cycles through unsaved frames in the right preview canvas.

PASS - Stop returns the preview to the selected frame.

PASS - No persistence added.

PASS - Targeted Playwright coverage updated.

## Validation Lane

PASS - `node --check assets/toolbox/sprites/js/index.js`

PASS - `node --check dev/tests/playwright/tools/SpritesToolShell.spec.mjs`

PASS - `git diff --check -- toolbox/sprites/index.html assets/toolbox/sprites/js/index.js dev/tests/playwright/tools/SpritesToolShell.spec.mjs`

PASS - Runtime guard scan found no prohibited inline style/script/event-handler or stale placeholder text in touched runtime files.

PASS - `npx playwright test dev/tests/playwright/tools/SpritesToolShell.spec.mjs --workers=1 --reporter=list`

## Manual Validation Notes

1. Open `toolbox/sprites/index.html`.
2. Draw Frame 1.
3. Add Frame 2 and draw a different draft.
4. Click Play Preview and confirm the right preview cycles frames.
5. Click Stop Preview and confirm the selected frame is shown.
6. Confirm no save, publish, API, database, or library behavior is introduced.

## ZIP

`dev/workspace/zip/PR_26179_CHARLIE_036-sprites-animation-preview_delta.zip`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Put the delta ZIP at the required tmp path

The root AGENTS.md Packaging rule requires the BUILD ZIP at <project folder>/tmp/<TASK_NAME>_delta.zip, but this report records dev/workspace/zip/PR_26179_CHARLIE_036-sprites-animation-preview_delta.zip while also claiming the requested path was used. This committed validation record points consumers at the wrong location; update artifact generation/reporting to use tmp/PR_26179_CHARLIE_036-sprites-animation-preview_delta.zip.

Useful? React with 👍 / 👎.

2 changes: 1 addition & 1 deletion docs_build/dev/reports/codex_changed_files.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
assets/toolbox/sprites/js/index.js
dev/tests/playwright/tools/SpritesToolShell.spec.mjs
docs_build/dev/reports/PR_26179_CHARLIE_035-sprites-frame-editing.md
docs_build/dev/reports/PR_26179_CHARLIE_036-sprites-animation-preview.md
docs_build/dev/reports/codex_changed_files.txt
docs_build/dev/reports/codex_review.diff
toolbox/sprites/index.html
Loading
Loading