diff --git a/assets/theme-v2/css/gamefoundrystudio.css b/assets/theme-v2/css/gamefoundrystudio.css index 82dff717c..105b24073 100644 --- a/assets/theme-v2/css/gamefoundrystudio.css +++ b/assets/theme-v2/css/gamefoundrystudio.css @@ -1156,6 +1156,7 @@ body.tool-focus-mode .tool-column:last-of-type { .sprite-canvas-shell { display: flex; justify-content: center; + overflow: auto; padding: 16px; border: 1px solid var(--line); border-radius: var(--radius-md); @@ -1180,6 +1181,14 @@ body.tool-focus-mode .tool-column:last-of-type { --sprite-grid-size: 32 } +.sprite-canvas-shell[data-sprites-zoom-level="2"] .sprite-canvas-grid { + width: min(720px, 160%) +} + +.sprite-canvas-shell[data-sprites-zoom-level="4"] .sprite-canvas-grid { + width: min(960px, 220%) +} + .sprite-canvas-cell { min-width: 0; min-height: 0; diff --git a/assets/toolbox/sprites/js/index.js b/assets/toolbox/sprites/js/index.js index 788ba41c1..594a91d85 100644 --- a/assets/toolbox/sprites/js/index.js +++ b/assets/toolbox/sprites/js/index.js @@ -1,6 +1,7 @@ const DEFAULT_GRID_SIZE = 16; const SUPPORTED_GRID_SIZES = Object.freeze([16, 32]); -const DRAWING_TOOLS = Object.freeze(["pencil", "eraser", "fill"]); +const SUPPORTED_ZOOM_LEVELS = Object.freeze([1, 2, 4]); +const EDITOR_TOOLS = Object.freeze(["pencil", "eraser", "fill", "picker", "zoom"]); const EDITOR_COLOR_KEYS = Object.freeze(["ink", "orange", "gold", "green", "blue"]); const EDITOR_COLOR_CSS_VARIABLES = Object.freeze({ blue: "--electric-blue", @@ -15,6 +16,7 @@ const editorState = { activeColor: "ink", gridSize: DEFAULT_GRID_SIZE, paintedPixels: new Map(), + zoomLevel: 1, }; const editorHistory = { redoStack: [], @@ -88,6 +90,16 @@ function normalizeColorKey(colorKey) { return EDITOR_COLOR_KEYS.includes(colorKey) ? colorKey : "ink"; } +function colorLabel(colorKey) { + const normalizedColorKey = normalizeColorKey(colorKey); + return `${normalizedColorKey[0].toUpperCase()}${normalizedColorKey.slice(1)}`; +} + +function normalizeZoomLevel(zoomLevel) { + const value = Number(zoomLevel); + return SUPPORTED_ZOOM_LEVELS.includes(value) ? value : 1; +} + function clearCellColor(cell) { cell.classList.remove("is-painted"); cell.classList.remove(...EDITOR_COLOR_KEYS.map((colorKey) => `sprite-canvas-cell--${colorKey}`)); @@ -136,7 +148,7 @@ function updateDraftStatus() { function updatePaletteStatus() { const status = document.querySelector("[data-sprites-palette-status]"); if (status) { - status.textContent = `Active editor color: ${editorState.activeColor[0].toUpperCase()}${editorState.activeColor.slice(1)}. Palette/Colors remains the reusable color source for future saved sprite records.`; + status.textContent = `Active editor color: ${colorLabel(editorState.activeColor)}. Palette/Colors remains the reusable color source for future saved sprite records.`; } } @@ -152,7 +164,7 @@ function setActiveColor(colorKey) { } function setActiveTool(toolName) { - if (!DRAWING_TOOLS.includes(toolName)) { + if (!EDITOR_TOOLS.includes(toolName)) { return; } editorState.activeTool = toolName; @@ -167,8 +179,51 @@ function setActiveTool(toolName) { } } +function setZoomLevel(zoomLevel) { + const normalizedZoomLevel = normalizeZoomLevel(zoomLevel); + const shell = document.querySelector("[data-sprites-grid-shell]"); + const status = document.querySelector("[data-sprites-zoom-status]"); + editorState.zoomLevel = normalizedZoomLevel; + + if (shell) { + shell.dataset.spritesZoomLevel = String(normalizedZoomLevel); + } + + document.querySelectorAll("[data-sprites-zoom-level]").forEach((button) => { + const isActive = button.dataset.spritesZoomLevel === String(normalizedZoomLevel); + button.classList.toggle("primary", isActive); + button.setAttribute("aria-pressed", String(isActive)); + }); + + if (status) { + status.textContent = `Canvas zoom display: ${normalizedZoomLevel * 100}%.`; + } +} + +function pickCellColor(cell) { + const colorKey = cell.dataset.spriteColorKey; + const status = document.querySelector("[data-sprites-tool-status]"); + if (!colorKey) { + if (status) { + status.textContent = "Picker found an empty pixel. Active color was not changed."; + } + return; + } + setActiveColor(colorKey); + if (status) { + status.textContent = `Picker selected ${colorLabel(colorKey)} from the unsaved editor canvas.`; + } +} + function paintCell(cell) { const key = pixelKey(cell.dataset.spritePixelRow, cell.dataset.spritePixelColumn); + if (editorState.activeTool === "picker") { + pickCellColor(cell); + return; + } + if (editorState.activeTool === "zoom") { + return; + } if (editorState.activeTool === "eraser") { if (!editorState.paintedPixels.has(key)) { return; @@ -334,7 +389,7 @@ function wireGridControls() { function wireDrawingTools() { document.querySelectorAll("[data-sprite-tool-button]").forEach((button) => { const toolName = button.dataset.spriteToolButton; - if (!DRAWING_TOOLS.includes(toolName) || button.disabled) { + if (!EDITOR_TOOLS.includes(toolName) || button.disabled) { return; } button.addEventListener("click", () => { @@ -346,6 +401,12 @@ function wireDrawingTools() { }); } +function wireZoomControls() { + document.querySelectorAll("[data-sprites-zoom-level]").forEach((button) => { + button.addEventListener("click", () => setZoomLevel(button.dataset.spritesZoomLevel)); + }); +} + function wirePaletteButtons() { document.querySelectorAll("[data-sprite-color-button]").forEach((button) => { button.addEventListener("click", () => { @@ -406,9 +467,11 @@ wireDrawingTools(); wirePaletteButtons(); wireCanvasActions(); wireHistoryActions(); +wireZoomControls(); wireExportButton(); setGridSize(DEFAULT_GRID_SIZE); setActiveTool(editorState.activeTool); setActiveColor(editorState.activeColor); +setZoomLevel(editorState.zoomLevel); updateHistoryControls(); renderPreview(); diff --git a/dev/tests/playwright/tools/SpritesToolShell.spec.mjs b/dev/tests/playwright/tools/SpritesToolShell.spec.mjs index 8a1f5da74..40812b4af 100644 --- a/dev/tests/playwright/tools/SpritesToolShell.spec.mjs +++ b/dev/tests/playwright/tools/SpritesToolShell.spec.mjs @@ -13,9 +13,7 @@ const SPRITE_TOOLBAR_PLACEHOLDERS = [ "Line", "Rectangle", "Circle", - "Picker", "Move", - "Zoom", ]; function contentTypeForPath(filePath) { @@ -230,6 +228,8 @@ test("Sprite Creator shell loads with visible tool, canvas, details, and status await expect(page.getByRole("button", { name: "Pencil tool" })).toBeEnabled(); await expect(page.getByRole("button", { name: "Eraser tool" })).toBeEnabled(); await expect(page.getByRole("button", { name: "Fill tool" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Picker tool" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Zoom tool" })).toBeEnabled(); for (const toolName of SPRITE_TOOLBAR_PLACEHOLDERS) { await expect(page.getByRole("button", { name: `${toolName} tool placeholder` })).toBeDisabled(); } @@ -239,6 +239,7 @@ test("Sprite Creator shell loads with visible tool, canvas, details, and status await expect(page.locator("[data-sprites-pixel-grid]")).toBeVisible(); await expect(page.locator("[data-sprites-pixel-grid] [role='gridcell']")).toHaveCount(256); await expect(page.locator("[data-sprites-grid-status]")).toContainText("Canvas display mode: 16x16"); + await expect(page.locator("[data-sprites-zoom-status]")).toContainText("100%"); await page.getByRole("button", { name: "32x32" }).click(); await expect(page.locator("[data-sprites-pixel-grid]")).toHaveAttribute("aria-label", "Sprite Creator 32 by 32 pixel canvas"); await expect(page.locator("[data-sprites-pixel-grid] [role='gridcell']")).toHaveCount(1024); @@ -269,6 +270,11 @@ test("Sprite Creator shell loads with visible tool, canvas, details, and status await firstPixel.click(); await expect(firstPixel).toHaveClass(/sprite-canvas-cell--gold/); await page.getByRole("button", { name: "Blue editor color" }).click(); + await page.getByRole("button", { name: "Picker tool" }).click(); + await firstPixel.click(); + await expect(page.locator("[data-sprites-palette-status]")).toContainText("Active editor color: Gold"); + await expect(page.locator("[data-sprites-tool-status]")).toContainText("Picker selected Gold"); + await page.getByRole("button", { name: "Blue editor color" }).click(); await page.getByRole("button", { name: "Fill tool" }).click(); await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(1024); await expect(page.locator("[data-sprites-pixel-grid] .sprite-canvas-cell--blue")).toHaveCount(1024); @@ -300,6 +306,15 @@ test("Sprite Creator shell loads with visible tool, canvas, details, and status await page.getByRole("button", { name: "Blue editor color" }).click(); await page.getByRole("button", { name: "Fill tool" }).click(); await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(256); + await page.getByRole("button", { name: "Zoom tool" }).click(); + await page.getByRole("button", { name: "200%" }).click(); + await expect(page.locator("[data-sprites-grid-shell]")).toHaveAttribute("data-sprites-zoom-level", "2"); + await expect(page.locator("[data-sprites-zoom-status]")).toContainText("200%"); + await page.getByRole("button", { name: "400%" }).click(); + await expect(page.locator("[data-sprites-grid-shell]")).toHaveAttribute("data-sprites-zoom-level", "4"); + await expect(page.locator("[data-sprites-zoom-status]")).toContainText("400%"); + await page.getByRole("button", { name: "100%" }).click(); + await expect(page.locator("[data-sprites-grid-shell]")).toHaveAttribute("data-sprites-zoom-level", "1"); await expect(page.locator("[data-sprites-preview-canvas]")).toBeVisible(); const previewHasPaint = await page.locator("[data-sprites-preview-canvas]").evaluate((canvas) => { const context = canvas.getContext("2d"); diff --git a/docs_build/dev/reports/PR_26179_CHARLIE_031-sprites-picker-zoom.md b/docs_build/dev/reports/PR_26179_CHARLIE_031-sprites-picker-zoom.md new file mode 100644 index 000000000..4e61c7b6d --- /dev/null +++ b/docs_build/dev/reports/PR_26179_CHARLIE_031-sprites-picker-zoom.md @@ -0,0 +1,69 @@ +# PR_26179_CHARLIE_031-sprites-picker-zoom + +Team: CHARLIE + +Mode: Batch governance stacked feature workflow + +Base branch: PR_26179_CHARLIE_030-sprites-undo-redo + +Branch: PR_26179_CHARLIE_031-sprites-picker-zoom + +## Summary + +Implemented Sprite Creator picker and zoom display controls. Picker reads an existing unsaved draft pixel color and updates the active editor color. Zoom changes only the canvas display scale through Theme V2 CSS and page-session UI state. No persistence, database, API, schema, browser-owned authoritative product data, or start_of_day files were changed. + +## Branch Validation + +PASS + +- Built on the previous stacked Sprite branch. +- Scope stayed limited to Picker and Zoom display controls. +- Move remains disabled/deferred. +- No DB/API/schema changes. +- No product data persistence was added. +- No stale PR #219-#228 code was copied. +- ZIP package created at the user-requested batch path. + +## Requirement Checklist + +PASS - Picker implemented for existing painted pixels. + +PASS - Picker updates the active editor color without mutating the canvas. + +PASS - Zoom display controls added for 100%, 200%, and 400%. + +PASS - Zoom is display-only and does not persist data. + +PASS - Move remains disabled/deferred. + +PASS - Theme V2 CSS used for zoom sizing. + +PASS - No inline CSS, script blocks, style blocks, or inline event handlers 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 assets/theme-v2/css/gamefoundrystudio.css 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. Paint a pixel with Gold. +3. Change the active editor color to Blue. +4. Select Picker and click the Gold pixel. +5. Confirm the active editor color returns to Gold and the canvas does not change. +6. Select Zoom and click 200%, 400%, and 100%. +7. Confirm the canvas display scale updates and no sprite data is saved. + +## ZIP + +`dev/workspace/zip/PR_26179_CHARLIE_031-sprites-picker-zoom_delta.zip` diff --git a/docs_build/dev/reports/codex_changed_files.txt b/docs_build/dev/reports/codex_changed_files.txt index 0ae35c274..9947f86b8 100644 --- a/docs_build/dev/reports/codex_changed_files.txt +++ b/docs_build/dev/reports/codex_changed_files.txt @@ -1,6 +1,7 @@ +assets/theme-v2/css/gamefoundrystudio.css assets/toolbox/sprites/js/index.js dev/tests/playwright/tools/SpritesToolShell.spec.mjs -docs_build/dev/reports/PR_26179_CHARLIE_030-sprites-undo-redo.md +docs_build/dev/reports/PR_26179_CHARLIE_031-sprites-picker-zoom.md docs_build/dev/reports/codex_changed_files.txt docs_build/dev/reports/codex_review.diff toolbox/sprites/index.html diff --git a/docs_build/dev/reports/codex_review.diff b/docs_build/dev/reports/codex_review.diff index cf3667225..5ce39b515 100644 --- a/docs_build/dev/reports/codex_review.diff +++ b/docs_build/dev/reports/codex_review.diff @@ -1,337 +1,280 @@ +diff --git a/assets/theme-v2/css/gamefoundrystudio.css b/assets/theme-v2/css/gamefoundrystudio.css +index 82dff717c..105b24073 100644 +--- a/assets/theme-v2/css/gamefoundrystudio.css ++++ b/assets/theme-v2/css/gamefoundrystudio.css +@@ -1156,6 +1156,7 @@ body.tool-focus-mode .tool-column:last-of-type { + .sprite-canvas-shell { + display: flex; + justify-content: center; ++ overflow: auto; + padding: 16px; + border: 1px solid var(--line); + border-radius: var(--radius-md); +@@ -1180,6 +1181,14 @@ body.tool-focus-mode .tool-column:last-of-type { + --sprite-grid-size: 32 + } + ++.sprite-canvas-shell[data-sprites-zoom-level="2"] .sprite-canvas-grid { ++ width: min(720px, 160%) ++} ++ ++.sprite-canvas-shell[data-sprites-zoom-level="4"] .sprite-canvas-grid { ++ width: min(960px, 220%) ++} ++ + .sprite-canvas-cell { + min-width: 0; + min-height: 0; diff --git a/assets/toolbox/sprites/js/index.js b/assets/toolbox/sprites/js/index.js -index 3ed671948..788ba41c1 100644 +index 788ba41c1..594a91d85 100644 --- a/assets/toolbox/sprites/js/index.js +++ b/assets/toolbox/sprites/js/index.js -@@ -16,6 +16,10 @@ const editorState = { +@@ -1,6 +1,7 @@ + const DEFAULT_GRID_SIZE = 16; + const SUPPORTED_GRID_SIZES = Object.freeze([16, 32]); +-const DRAWING_TOOLS = Object.freeze(["pencil", "eraser", "fill"]); ++const SUPPORTED_ZOOM_LEVELS = Object.freeze([1, 2, 4]); ++const EDITOR_TOOLS = Object.freeze(["pencil", "eraser", "fill", "picker", "zoom"]); + const EDITOR_COLOR_KEYS = Object.freeze(["ink", "orange", "gold", "green", "blue"]); + const EDITOR_COLOR_CSS_VARIABLES = Object.freeze({ + blue: "--electric-blue", +@@ -15,6 +16,7 @@ const editorState = { + activeColor: "ink", gridSize: DEFAULT_GRID_SIZE, paintedPixels: new Map(), ++ zoomLevel: 1, }; -+const editorHistory = { -+ redoStack: [], -+ undoStack: [], -+}; - - function gridLabel(size) { - return `Sprite Creator ${size} by ${size} pixel canvas`; -@@ -29,6 +33,49 @@ function pixelKey(row, column) { - return `${row}:${column}`; + const editorHistory = { + redoStack: [], +@@ -88,6 +90,16 @@ function normalizeColorKey(colorKey) { + return EDITOR_COLOR_KEYS.includes(colorKey) ? colorKey : "ink"; } -+function stateSnapshot() { -+ return { -+ gridSize: editorState.gridSize, -+ paintedPixels: Array.from(editorState.paintedPixels.entries()), -+ }; ++function colorLabel(colorKey) { ++ const normalizedColorKey = normalizeColorKey(colorKey); ++ return `${normalizedColorKey[0].toUpperCase()}${normalizedColorKey.slice(1)}`; +} + -+function sameSnapshot(left, right) { -+ return JSON.stringify(left) === JSON.stringify(right); ++function normalizeZoomLevel(zoomLevel) { ++ const value = Number(zoomLevel); ++ return SUPPORTED_ZOOM_LEVELS.includes(value) ? value : 1; +} + -+function pushUndoSnapshot() { -+ const snapshot = stateSnapshot(); -+ const lastSnapshot = editorHistory.undoStack[editorHistory.undoStack.length - 1]; -+ if (!lastSnapshot || !sameSnapshot(snapshot, lastSnapshot)) { -+ editorHistory.undoStack.push(snapshot); + function clearCellColor(cell) { + cell.classList.remove("is-painted"); + cell.classList.remove(...EDITOR_COLOR_KEYS.map((colorKey) => `sprite-canvas-cell--${colorKey}`)); +@@ -136,7 +148,7 @@ function updateDraftStatus() { + function updatePaletteStatus() { + const status = document.querySelector("[data-sprites-palette-status]"); + if (status) { +- status.textContent = `Active editor color: ${editorState.activeColor[0].toUpperCase()}${editorState.activeColor.slice(1)}. Palette/Colors remains the reusable color source for future saved sprite records.`; ++ status.textContent = `Active editor color: ${colorLabel(editorState.activeColor)}. Palette/Colors remains the reusable color source for future saved sprite records.`; + } + } + +@@ -152,7 +164,7 @@ function setActiveColor(colorKey) { + } + + function setActiveTool(toolName) { +- if (!DRAWING_TOOLS.includes(toolName)) { ++ if (!EDITOR_TOOLS.includes(toolName)) { + return; + } + editorState.activeTool = toolName; +@@ -167,8 +179,51 @@ function setActiveTool(toolName) { + } + } + ++function setZoomLevel(zoomLevel) { ++ const normalizedZoomLevel = normalizeZoomLevel(zoomLevel); ++ const shell = document.querySelector("[data-sprites-grid-shell]"); ++ const status = document.querySelector("[data-sprites-zoom-status]"); ++ editorState.zoomLevel = normalizedZoomLevel; ++ ++ if (shell) { ++ shell.dataset.spritesZoomLevel = String(normalizedZoomLevel); + } -+ editorHistory.redoStack = []; -+ updateHistoryControls(); -+} + -+function historyStatusText() { -+ if (editorHistory.undoStack.length === 0 && editorHistory.redoStack.length === 0) { -+ return "Undo history is empty."; -+ } -+ return `Undo steps: ${editorHistory.undoStack.length}. Redo steps: ${editorHistory.redoStack.length}.`; -+} ++ document.querySelectorAll("[data-sprites-zoom-level]").forEach((button) => { ++ const isActive = button.dataset.spritesZoomLevel === String(normalizedZoomLevel); ++ button.classList.toggle("primary", isActive); ++ button.setAttribute("aria-pressed", String(isActive)); ++ }); + -+function updateHistoryControls() { -+ const undoButton = document.querySelector("[data-sprites-undo]"); -+ const redoButton = document.querySelector("[data-sprites-redo]"); -+ const status = document.querySelector("[data-sprites-history-status]"); -+ if (undoButton) { -+ undoButton.disabled = editorHistory.undoStack.length === 0; -+ } -+ if (redoButton) { -+ redoButton.disabled = editorHistory.redoStack.length === 0; -+ } + if (status) { -+ status.textContent = historyStatusText(); ++ status.textContent = `Canvas zoom display: ${normalizedZoomLevel * 100}%.`; + } +} + - function draftStatusText() { - const count = editorState.paintedPixels.size; - if (count === 0) { -@@ -54,6 +101,30 @@ function setCellColor(cell, colorKey) { - cell.dataset.spriteColorKey = normalizedColorKey; - } - -+function applyPaintedPixelsToGrid() { -+ const grid = document.querySelector("[data-sprites-pixel-grid]"); -+ if (!grid) { ++function pickCellColor(cell) { ++ const colorKey = cell.dataset.spriteColorKey; ++ const status = document.querySelector("[data-sprites-tool-status]"); ++ if (!colorKey) { ++ if (status) { ++ status.textContent = "Picker found an empty pixel. Active color was not changed."; ++ } + return; + } -+ grid.querySelectorAll("[data-sprite-pixel-row]").forEach((cell) => { -+ const key = pixelKey(cell.dataset.spritePixelRow, cell.dataset.spritePixelColumn); -+ const colorKey = editorState.paintedPixels.get(key); -+ if (colorKey) { -+ setCellColor(cell, colorKey); -+ } else { -+ clearCellColor(cell); -+ } -+ }); -+} -+ -+function applySnapshot(snapshot) { -+ setGridSize(snapshot.gridSize, { recordHistory: false }); -+ editorState.paintedPixels = new Map(snapshot.paintedPixels); -+ applyPaintedPixelsToGrid(); -+ updateDraftStatus(); -+ updateHistoryControls(); ++ setActiveColor(colorKey); ++ if (status) { ++ status.textContent = `Picker selected ${colorLabel(colorKey)} from the unsaved editor canvas.`; ++ } +} + - function updateDraftStatus() { - const status = document.querySelector("[data-sprites-draft-status]"); - if (status) { -@@ -99,9 +170,17 @@ function setActiveTool(toolName) { function paintCell(cell) { const key = pixelKey(cell.dataset.spritePixelRow, cell.dataset.spritePixelColumn); - if (editorState.activeTool === "eraser") { -+ if (!editorState.paintedPixels.has(key)) { -+ return; -+ } -+ pushUndoSnapshot(); - editorState.paintedPixels.delete(key); - clearCellColor(cell); - } else { -+ if (editorState.paintedPixels.get(key) === editorState.activeColor) { -+ return; -+ } -+ pushUndoSnapshot(); - editorState.paintedPixels.set(key, editorState.activeColor); - setCellColor(cell, editorState.activeColor); - } -@@ -113,6 +192,7 @@ function fillGrid() { - if (!grid) { - return; - } -+ pushUndoSnapshot(); - editorState.paintedPixels.clear(); - grid.querySelectorAll("[data-sprite-pixel-row]").forEach((cell) => { - const key = pixelKey(cell.dataset.spritePixelRow, cell.dataset.spritePixelColumn); -@@ -122,8 +202,11 @@ function fillGrid() { - updateDraftStatus(); - } - --function clearCanvas() { -+function clearCanvas(options = {}) { - const grid = document.querySelector("[data-sprites-pixel-grid]"); -+ if (options.recordHistory !== false && editorState.paintedPixels.size > 0) { -+ pushUndoSnapshot(); ++ if (editorState.activeTool === "picker") { ++ pickCellColor(cell); ++ return; + } - editorState.paintedPixels.clear(); - if (grid) { - grid.querySelectorAll("[data-sprite-pixel-row]").forEach(clearCellColor); -@@ -132,8 +215,9 @@ function clearCanvas() { - } - - function resetGridToDefault() { -- setGridSize(DEFAULT_GRID_SIZE); -- clearCanvas(); -+ pushUndoSnapshot(); -+ setGridSize(DEFAULT_GRID_SIZE, { recordHistory: false }); -+ clearCanvas({ recordHistory: false }); - } - - function editorColorValue(colorKey) { -@@ -194,13 +278,17 @@ function exportPreviewPng() { - }, "image/png"); - } - --function setGridSize(size) { -+function setGridSize(size, options = {}) { - const grid = document.querySelector("[data-sprites-pixel-grid]"); - const status = document.querySelector("[data-sprites-grid-status]"); - if (!grid || !SUPPORTED_GRID_SIZES.includes(size)) { - return; - } - -+ if (options.recordHistory && (editorState.gridSize !== size || editorState.paintedPixels.size > 0)) { -+ pushUndoSnapshot(); ++ if (editorState.activeTool === "zoom") { ++ return; + } -+ - grid.replaceChildren(); - editorState.gridSize = size; - editorState.paintedPixels.clear(); -@@ -239,7 +327,7 @@ function wireGridControls() { - if (!button) { + if (editorState.activeTool === "eraser") { + if (!editorState.paintedPixels.has(key)) { + return; +@@ -334,7 +389,7 @@ function wireGridControls() { + function wireDrawingTools() { + document.querySelectorAll("[data-sprite-tool-button]").forEach((button) => { + const toolName = button.dataset.spriteToolButton; +- if (!DRAWING_TOOLS.includes(toolName) || button.disabled) { ++ if (!EDITOR_TOOLS.includes(toolName) || button.disabled) { return; } -- button.addEventListener("click", () => setGridSize(size)); -+ button.addEventListener("click", () => setGridSize(size, { recordHistory: true })); + button.addEventListener("click", () => { +@@ -346,6 +401,12 @@ function wireDrawingTools() { }); } -@@ -278,6 +366,34 @@ function wireCanvasActions() { - } - } - -+function wireHistoryActions() { -+ const undoButton = document.querySelector("[data-sprites-undo]"); -+ if (undoButton) { -+ undoButton.addEventListener("click", () => { -+ const snapshot = editorHistory.undoStack.pop(); -+ if (!snapshot) { -+ updateHistoryControls(); -+ return; -+ } -+ editorHistory.redoStack.push(stateSnapshot()); -+ applySnapshot(snapshot); -+ }); -+ } -+ -+ const redoButton = document.querySelector("[data-sprites-redo]"); -+ if (redoButton) { -+ redoButton.addEventListener("click", () => { -+ const snapshot = editorHistory.redoStack.pop(); -+ if (!snapshot) { -+ updateHistoryControls(); -+ return; -+ } -+ editorHistory.undoStack.push(stateSnapshot()); -+ applySnapshot(snapshot); -+ }); -+ } ++function wireZoomControls() { ++ document.querySelectorAll("[data-sprites-zoom-level]").forEach((button) => { ++ button.addEventListener("click", () => setZoomLevel(button.dataset.spritesZoomLevel)); ++ }); +} + - function wireExportButton() { - const button = document.querySelector("[data-sprites-export-png]"); - if (button) { -@@ -289,8 +405,10 @@ wireGridControls(); - wireDrawingTools(); + function wirePaletteButtons() { + document.querySelectorAll("[data-sprite-color-button]").forEach((button) => { + button.addEventListener("click", () => { +@@ -406,9 +467,11 @@ wireDrawingTools(); wirePaletteButtons(); wireCanvasActions(); -+wireHistoryActions(); + wireHistoryActions(); ++wireZoomControls(); wireExportButton(); setGridSize(DEFAULT_GRID_SIZE); setActiveTool(editorState.activeTool); setActiveColor(editorState.activeColor); -+updateHistoryControls(); ++setZoomLevel(editorState.zoomLevel); + updateHistoryControls(); renderPreview(); diff --git a/dev/tests/playwright/tools/SpritesToolShell.spec.mjs b/dev/tests/playwright/tools/SpritesToolShell.spec.mjs -index b3f86a2e6..8a1f5da74 100644 +index 8a1f5da74..40812b4af 100644 --- a/dev/tests/playwright/tools/SpritesToolShell.spec.mjs +++ b/dev/tests/playwright/tools/SpritesToolShell.spec.mjs -@@ -244,14 +244,25 @@ test("Sprite Creator shell loads with visible tool, canvas, details, and status +@@ -13,9 +13,7 @@ const SPRITE_TOOLBAR_PLACEHOLDERS = [ + "Line", + "Rectangle", + "Circle", +- "Picker", + "Move", +- "Zoom", + ]; + + function contentTypeForPath(filePath) { +@@ -230,6 +228,8 @@ test("Sprite Creator shell loads with visible tool, canvas, details, and status + await expect(page.getByRole("button", { name: "Pencil tool" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Eraser tool" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Fill tool" })).toBeEnabled(); ++ await expect(page.getByRole("button", { name: "Picker tool" })).toBeEnabled(); ++ await expect(page.getByRole("button", { name: "Zoom tool" })).toBeEnabled(); + for (const toolName of SPRITE_TOOLBAR_PLACEHOLDERS) { + await expect(page.getByRole("button", { name: `${toolName} tool placeholder` })).toBeDisabled(); + } +@@ -239,6 +239,7 @@ test("Sprite Creator shell loads with visible tool, canvas, details, and status + await expect(page.locator("[data-sprites-pixel-grid]")).toBeVisible(); + await expect(page.locator("[data-sprites-pixel-grid] [role='gridcell']")).toHaveCount(256); + await expect(page.locator("[data-sprites-grid-status]")).toContainText("Canvas display mode: 16x16"); ++ await expect(page.locator("[data-sprites-zoom-status]")).toContainText("100%"); + await page.getByRole("button", { name: "32x32" }).click(); + await expect(page.locator("[data-sprites-pixel-grid]")).toHaveAttribute("aria-label", "Sprite Creator 32 by 32 pixel canvas"); await expect(page.locator("[data-sprites-pixel-grid] [role='gridcell']")).toHaveCount(1024); - await expect(page.locator("[data-sprites-grid-status]")).toContainText("Canvas display mode: 32x32"); - await expect(page.getByRole("button", { name: "32x32" })).toHaveAttribute("aria-pressed", "true"); -+ await expect(page.getByRole("button", { name: "Undo" })).toBeEnabled(); -+ await expect(page.getByRole("button", { name: "Redo" })).toBeDisabled(); - const firstPixel = page.locator("[data-sprites-pixel-grid] [role='gridcell']").first(); +@@ -269,6 +270,11 @@ test("Sprite Creator shell loads with visible tool, canvas, details, and status await firstPixel.click(); - await expect(firstPixel).toHaveClass(/is-painted/); - await expect(page.locator("[data-sprites-draft-status]")).toContainText("1 draft pixel painted"); -+ await page.getByRole("button", { name: "Undo" }).click(); -+ await expect(firstPixel).not.toHaveClass(/is-painted/); -+ await expect(page.getByRole("button", { name: "Redo" })).toBeEnabled(); -+ await page.getByRole("button", { name: "Redo" }).click(); -+ await expect(firstPixel).toHaveClass(/is-painted/); - await page.getByRole("button", { name: "Eraser tool" }).click(); - await firstPixel.click(); - await expect(firstPixel).not.toHaveClass(/is-painted/); - await expect(page.locator("[data-sprites-draft-status]")).toContainText("empty draft"); -+ await page.getByRole("button", { name: "Undo" }).click(); -+ await expect(firstPixel).toHaveClass(/is-painted/); -+ await page.getByRole("button", { name: "Redo" }).click(); -+ await expect(firstPixel).not.toHaveClass(/is-painted/); - await page.getByRole("button", { name: "Gold editor color" }).click(); - await expect(page.locator("[data-sprites-palette-status]")).toContainText("Active editor color: Gold"); - await page.getByRole("button", { name: "Pencil tool" }).click(); -@@ -262,9 +273,17 @@ test("Sprite Creator shell loads with visible tool, canvas, details, and status - await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(1024); - await expect(page.locator("[data-sprites-pixel-grid] .sprite-canvas-cell--blue")).toHaveCount(1024); - await expect(page.locator("[data-sprites-draft-status]")).toContainText("1024 draft pixels painted"); -+ await page.getByRole("button", { name: "Undo" }).click(); -+ await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(1); -+ await page.getByRole("button", { name: "Redo" }).click(); -+ await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(1024); - await page.getByRole("button", { name: "Clear Canvas" }).click(); - await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(0); - await expect(page.locator("[data-sprites-draft-status]")).toContainText("empty draft"); -+ await page.getByRole("button", { name: "Undo" }).click(); -+ await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(1024); -+ await page.getByRole("button", { name: "Redo" }).click(); -+ await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(0); + await expect(firstPixel).toHaveClass(/sprite-canvas-cell--gold/); + await page.getByRole("button", { name: "Blue editor color" }).click(); ++ await page.getByRole("button", { name: "Picker tool" }).click(); ++ await firstPixel.click(); ++ await expect(page.locator("[data-sprites-palette-status]")).toContainText("Active editor color: Gold"); ++ await expect(page.locator("[data-sprites-tool-status]")).toContainText("Picker selected Gold"); ++ await page.getByRole("button", { name: "Blue editor color" }).click(); await page.getByRole("button", { name: "Fill tool" }).click(); await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(1024); - await page.getByRole("button", { name: "Reset to 16x16" }).click(); -@@ -272,6 +291,12 @@ test("Sprite Creator shell loads with visible tool, canvas, details, and status - await expect(page.locator("[data-sprites-pixel-grid] [role='gridcell']")).toHaveCount(256); - await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(0); - await expect(page.locator("[data-sprites-draft-status]")).toContainText("empty draft"); -+ await page.getByRole("button", { name: "Undo" }).click(); -+ await expect(page.locator("[data-sprites-pixel-grid]")).toHaveAttribute("aria-label", "Sprite Creator 32 by 32 pixel canvas"); -+ await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(1024); -+ await page.getByRole("button", { name: "Redo" }).click(); -+ await expect(page.locator("[data-sprites-pixel-grid]")).toHaveAttribute("aria-label", "Sprite Creator 16 by 16 pixel canvas"); -+ await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(0); + await expect(page.locator("[data-sprites-pixel-grid] .sprite-canvas-cell--blue")).toHaveCount(1024); +@@ -300,6 +306,15 @@ test("Sprite Creator shell loads with visible tool, canvas, details, and status await page.getByRole("button", { name: "Blue editor color" }).click(); await page.getByRole("button", { name: "Fill tool" }).click(); await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(256); -diff --git a/docs_build/dev/reports/PR_26179_CHARLIE_030-sprites-undo-redo.md b/docs_build/dev/reports/PR_26179_CHARLIE_030-sprites-undo-redo.md ++ await page.getByRole("button", { name: "Zoom tool" }).click(); ++ await page.getByRole("button", { name: "200%" }).click(); ++ await expect(page.locator("[data-sprites-grid-shell]")).toHaveAttribute("data-sprites-zoom-level", "2"); ++ await expect(page.locator("[data-sprites-zoom-status]")).toContainText("200%"); ++ await page.getByRole("button", { name: "400%" }).click(); ++ await expect(page.locator("[data-sprites-grid-shell]")).toHaveAttribute("data-sprites-zoom-level", "4"); ++ await expect(page.locator("[data-sprites-zoom-status]")).toContainText("400%"); ++ await page.getByRole("button", { name: "100%" }).click(); ++ await expect(page.locator("[data-sprites-grid-shell]")).toHaveAttribute("data-sprites-zoom-level", "1"); + await expect(page.locator("[data-sprites-preview-canvas]")).toBeVisible(); + const previewHasPaint = await page.locator("[data-sprites-preview-canvas]").evaluate((canvas) => { + const context = canvas.getContext("2d"); +diff --git a/docs_build/dev/reports/PR_26179_CHARLIE_031-sprites-picker-zoom.md b/docs_build/dev/reports/PR_26179_CHARLIE_031-sprites-picker-zoom.md new file mode 100644 -index 000000000..62593187a +index 000000000..4e61c7b6d --- /dev/null -+++ b/docs_build/dev/reports/PR_26179_CHARLIE_030-sprites-undo-redo.md -@@ -0,0 +1,73 @@ -+# PR_26179_CHARLIE_030-sprites-undo-redo ++++ b/docs_build/dev/reports/PR_26179_CHARLIE_031-sprites-picker-zoom.md +@@ -0,0 +1,69 @@ ++# PR_26179_CHARLIE_031-sprites-picker-zoom + +Team: CHARLIE + +Mode: Batch governance stacked feature workflow + -+Base branch: PR_26179_CHARLIE_029-sprites-clear-reset-controls ++Base branch: PR_26179_CHARLIE_030-sprites-undo-redo + -+Branch: PR_26179_CHARLIE_030-sprites-undo-redo ++Branch: PR_26179_CHARLIE_031-sprites-picker-zoom + +## Summary + -+Added undo and redo controls for Sprite Creator page-session editor actions. Undo/redo covers pencil, eraser, fill, clear canvas, and grid reset actions while preserving the unsaved editor-state-only model. No persistence, database, API, schema, or start_of_day files were changed. ++Implemented Sprite Creator picker and zoom display controls. Picker reads an existing unsaved draft pixel color and updates the active editor color. Zoom changes only the canvas display scale through Theme V2 CSS and page-session UI state. No persistence, database, API, schema, browser-owned authoritative product data, or start_of_day files were changed. + +## Branch Validation + +PASS + -+- Started from the prior stacked Sprite branch. -+- Scope stayed limited to Sprite Creator undo/redo. -+- No DB/API/schema/runtime ownership changes. -+- No browser-owned authoritative product data was introduced. -+- No start_of_day files were changed. ++- Built on the previous stacked Sprite branch. ++- Scope stayed limited to Picker and Zoom display controls. ++- Move remains disabled/deferred. ++- No DB/API/schema changes. ++- No product data persistence was added. ++- No stale PR #219-#228 code was copied. +- ZIP package created at the user-requested batch path. + +## Requirement Checklist + -+PASS - Add undo/redo for drawing. -+ -+PASS - Add undo/redo for erase. ++PASS - Picker implemented for existing painted pixels. + -+PASS - Add undo/redo for fill. ++PASS - Picker updates the active editor color without mutating the canvas. + -+PASS - Add undo/redo for clear canvas. ++PASS - Zoom display controls added for 100%, 200%, and 400%. + -+PASS - Add undo/redo for grid reset. ++PASS - Zoom is display-only and does not persist data. + -+PASS - Keep state as unsaved page-session editor state only. ++PASS - Move remains disabled/deferred. + -+PASS - No persistence added. ++PASS - Theme V2 CSS used for zoom sizing. + -+PASS - No DB/API/schema changes. ++PASS - No inline CSS, script blocks, style blocks, or inline event handlers added. + -+PASS - No stale PR #219-#228 code copied. -+ -+PASS - Targeted Playwright test updated. ++PASS - Targeted Playwright coverage updated. + +## Validation Lane + @@ -339,56 +282,69 @@ index 000000000..62593187a + +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 - `git diff --check -- toolbox/sprites/index.html assets/toolbox/sprites/js/index.js assets/theme-v2/css/gamefoundrystudio.css dev/tests/playwright/tools/SpritesToolShell.spec.mjs` + -+PASS - Guard scan found no prohibited inline style/script/event-handler or stale placeholder text in touched runtime files. ++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. Confirm Undo and Redo controls are visible and disabled before edits. -+3. Switch to 32x32 and confirm Undo becomes available. -+4. Paint a pixel, undo it, then redo it. -+5. Erase a pixel, undo it, then redo it. -+6. Fill the canvas, undo to the prior draft, then redo the fill. -+7. Clear the canvas, undo to restore the filled draft, then redo the clear. -+8. Reset to 16x16, undo to restore the prior 32x32 draft, then redo the reset. ++2. Paint a pixel with Gold. ++3. Change the active editor color to Blue. ++4. Select Picker and click the Gold pixel. ++5. Confirm the active editor color returns to Gold and the canvas does not change. ++6. Select Zoom and click 200%, 400%, and 100%. ++7. Confirm the canvas display scale updates and no sprite data is saved. + +## ZIP + -+`dev/workspace/zip/PR_26179_CHARLIE_030-sprites-undo-redo_delta.zip` ++`dev/workspace/zip/PR_26179_CHARLIE_031-sprites-picker-zoom_delta.zip` diff --git a/docs_build/dev/reports/codex_changed_files.txt b/docs_build/dev/reports/codex_changed_files.txt -index bcbb75975..0ae35c274 100644 +index 0ae35c274..9947f86b8 100644 --- a/docs_build/dev/reports/codex_changed_files.txt +++ b/docs_build/dev/reports/codex_changed_files.txt -@@ -1,6 +1,6 @@ --toolbox/sprites/index.html +@@ -1,6 +1,7 @@ ++assets/theme-v2/css/gamefoundrystudio.css assets/toolbox/sprites/js/index.js dev/tests/playwright/tools/SpritesToolShell.spec.mjs --docs_build/dev/reports/PR_26179_CHARLIE_029-sprites-clear-reset-controls.md -+docs_build/dev/reports/PR_26179_CHARLIE_030-sprites-undo-redo.md +-docs_build/dev/reports/PR_26179_CHARLIE_030-sprites-undo-redo.md ++docs_build/dev/reports/PR_26179_CHARLIE_031-sprites-picker-zoom.md docs_build/dev/reports/codex_changed_files.txt docs_build/dev/reports/codex_review.diff -+toolbox/sprites/index.html + toolbox/sprites/index.html diff --git a/toolbox/sprites/index.html b/toolbox/sprites/index.html -index c0b9ee284..fced926d7 100644 +index fced926d7..e4d4bfe59 100644 --- a/toolbox/sprites/index.html +++ b/toolbox/sprites/index.html -@@ -107,12 +107,15 @@ - - - -+ -+ +@@ -41,9 +41,9 @@ + + + +- ++ + +- ++ + +
Pencil is active. Drawing stays in unsaved editor state for this page session only.
+ +@@ -110,10 +110,16 @@ + + -Canvas display mode: 16x16. No pixel data is saved.
++Canvas zoom display: 100%.
Unsaved editor state: empty draft.
-+Undo history is empty.
+Undo history is empty.
Pencil is active. Drawing stays in unsaved editor state for this page session only.
@@ -110,10 +110,16 @@Canvas display mode: 16x16. No pixel data is saved.
+Canvas zoom display: 100%.
Unsaved editor state: empty draft.
Undo history is empty.