From ca7a6270d03a0cc4075ad8c938ded89f36b9447f Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 31 Aug 2026 16:14:46 +0200 Subject: [PATCH] fix: stabilize Sudoku workspace layout --- CHANGELOG.md | 10 +++ README.md | 2 +- SOURCE.md | 4 +- package-lock.json | 4 +- package.json | 2 +- public/CHANGELOG.md | 10 +++ public/README.md | 2 +- public/SOURCE.md | 4 +- public/toolbox-app.json | 2 +- src/components/BoardViewport.tsx | 2 +- src/components/Workbench.tsx | 4 +- src/styles.css | 27 ++++---- src/toolbox/manifest.source.json | 2 +- src/version.ts | 2 +- tests/browser/workbench.spec.ts | 89 +++++++++++++++++++++++++ tests/components/boardViewport.test.tsx | 23 ++++++- tests/components/numberPad.test.tsx | 37 +++++++++- 17 files changed, 194 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab1d3dc..d1d4cb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes are documented here. ## Unreleased +## 0.2.1 - 2026-08-31 + +- Fixed compact solver-step cards so their explanations no longer overflow. +- Fixed 75% board zoom and removed unnecessary inner scrolling at fitted and + reduced board sizes. +- Changed the standard digit keypad to a 3-by-3 layout with a full-width Erase + key, while retaining a 4-column layout for larger grids. +- Kept board and sidebar widths stable across Play, Set, Generate, Solve and + Helpers, with helper controls reflowing inside the shared sidebar width. + ## 0.2.0 - 2026-08-31 - Added staged guided hints that reveal focus, technique, reasoning and effects diff --git a/README.md b/README.md index c047285..5554194 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,7 @@ npm run release:artifact The command verifies the manifest, types, formatting, tests, production build, browser workflows and Toolbox contract before creating a deterministic -`release/sudoku-tools-0.2.0.zip` with a matching SHA-256 sidecar. +`release/sudoku-tools-0.2.1.zip` with a matching SHA-256 sidecar. ## Scope and references diff --git a/SOURCE.md b/SOURCE.md index 78807b7..5711895 100644 --- a/SOURCE.md +++ b/SOURCE.md @@ -1,8 +1,8 @@ # Corresponding source -The corresponding source for Sudoku Tools 0.2.0 is available at: +The corresponding source for Sudoku Tools 0.2.1 is available at: -https://git.add-ideas.de/lotobo/sudoku-tools/src/tag/v0.2.0 +https://git.add-ideas.de/lotobo/sudoku-tools/src/tag/v0.2.1 The production archive is generated from that tag with the scripts and exact dependency lock contained in the repository. It contains source identity, diff --git a/package-lock.json b/package-lock.json index 4a8365a..1c8adf8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sudoku-tools", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sudoku-tools", - "version": "0.2.0", + "version": "0.2.1", "license": "GPL-3.0-or-later", "dependencies": { "@add-ideas/toolbox-contract": "0.2.3", diff --git a/package.json b/package.json index a46db04..56a7b7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sudoku-tools", - "version": "0.2.0", + "version": "0.2.1", "description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.", "license": "GPL-3.0-or-later", "author": "Albrecht Degering", diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index ab1d3dc..d1d4cb4 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes are documented here. ## Unreleased +## 0.2.1 - 2026-08-31 + +- Fixed compact solver-step cards so their explanations no longer overflow. +- Fixed 75% board zoom and removed unnecessary inner scrolling at fitted and + reduced board sizes. +- Changed the standard digit keypad to a 3-by-3 layout with a full-width Erase + key, while retaining a 4-column layout for larger grids. +- Kept board and sidebar widths stable across Play, Set, Generate, Solve and + Helpers, with helper controls reflowing inside the shared sidebar width. + ## 0.2.0 - 2026-08-31 - Added staged guided hints that reveal focus, technique, reasoning and effects diff --git a/public/README.md b/public/README.md index c047285..5554194 100644 --- a/public/README.md +++ b/public/README.md @@ -268,7 +268,7 @@ npm run release:artifact The command verifies the manifest, types, formatting, tests, production build, browser workflows and Toolbox contract before creating a deterministic -`release/sudoku-tools-0.2.0.zip` with a matching SHA-256 sidecar. +`release/sudoku-tools-0.2.1.zip` with a matching SHA-256 sidecar. ## Scope and references diff --git a/public/SOURCE.md b/public/SOURCE.md index 78807b7..5711895 100644 --- a/public/SOURCE.md +++ b/public/SOURCE.md @@ -1,8 +1,8 @@ # Corresponding source -The corresponding source for Sudoku Tools 0.2.0 is available at: +The corresponding source for Sudoku Tools 0.2.1 is available at: -https://git.add-ideas.de/lotobo/sudoku-tools/src/tag/v0.2.0 +https://git.add-ideas.de/lotobo/sudoku-tools/src/tag/v0.2.1 The production archive is generated from that tag with the scripts and exact dependency lock contained in the repository. It contains source identity, diff --git a/public/toolbox-app.json b/public/toolbox-app.json index 754ea75..d0c59bc 100644 --- a/public/toolbox-app.json +++ b/public/toolbox-app.json @@ -3,7 +3,7 @@ "schemaVersion": 1, "id": "de.add-ideas.sudoku-tools", "name": "Sudoku Tools", - "version": "0.2.0", + "version": "0.2.1", "description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.", "entry": "./", "icon": "./favicon.svg", diff --git a/src/components/BoardViewport.tsx b/src/components/BoardViewport.tsx index a5834bd..fe496dc 100644 --- a/src/components/BoardViewport.tsx +++ b/src/components/BoardViewport.tsx @@ -100,7 +100,7 @@ export function BoardViewport({ return (
1 ? " is-zoomed-in" : ""}${panMode ? " is-pan-mode" : ""}`} aria-label="Board zoom and pan" onKeyDownCapture={onKeyDownCapture} > diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx index 32f9c59..c646834 100644 --- a/src/components/Workbench.tsx +++ b/src/components/Workbench.tsx @@ -2335,9 +2335,7 @@ export function Workbench() {

)} -
+
diff --git a/src/styles.css b/src/styles.css index 81614b8..27569d5 100644 --- a/src/styles.css +++ b/src/styles.css @@ -632,10 +632,6 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { gap: clamp(0.85rem, 1.6vw, 1.35rem); } -.workbench-grid--helpers { - grid-template-columns: minmax(24rem, 0.92fr) minmax(31rem, 1.08fr); -} - .board-column, .side-panel, .play-panel, @@ -738,19 +734,23 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { .board-viewport__scroller { width: 100%; - max-height: min(82dvh, 56rem); + max-height: none; overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable; } +.board-viewport.is-zoomed-in .board-viewport__scroller { + max-height: min(82dvh, 56rem); +} + .board-viewport__scroller:focus-visible { outline: 2px solid var(--toolbox-focus); outline-offset: 2px; } .board-viewport__canvas { - min-width: 100%; + min-width: 0; margin-inline: auto; } @@ -2858,12 +2858,12 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { .digit-pad { display: grid; - grid-template-columns: repeat(auto-fit, minmax(2.75rem, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.42rem; } .digit-pad--wide { - grid-template-columns: repeat(auto-fit, minmax(2.5rem, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); } .digit-pad button { @@ -2875,6 +2875,7 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { } .digit-pad .erase-key { + grid-column: 1 / -1; padding-inline: 0.65rem; color: var(--toolbox-muted); font-size: 0.76rem; @@ -3273,7 +3274,7 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { .candidate-lab__controls { display: grid; - grid-template-columns: minmax(12rem, 1.5fr) repeat(3, minmax(8rem, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: end; gap: 0.55rem 0.8rem; } @@ -3510,6 +3511,8 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { .solve-steps { max-height: 32rem; display: grid; + grid-auto-rows: max-content; + align-content: start; gap: 0.42rem; overflow: auto; padding: 0.15rem; @@ -3518,7 +3521,6 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { .solve-step { width: 100%; - min-height: 0 !important; display: grid !important; grid-template-columns: 2rem minmax(0, 1fr); align-items: start !important; @@ -3966,8 +3968,7 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { /* Responsive composition */ @media (max-width: 67rem) { - .workbench-grid, - .workbench-grid--helpers { + .workbench-grid { grid-template-columns: minmax(0, 1fr); } @@ -4033,7 +4034,7 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error { min-height: 2.75rem; } - .board-viewport__scroller { + .board-viewport.is-zoomed-in .board-viewport__scroller { max-height: min(72dvh, 48rem); } diff --git a/src/toolbox/manifest.source.json b/src/toolbox/manifest.source.json index 754ea75..d0c59bc 100644 --- a/src/toolbox/manifest.source.json +++ b/src/toolbox/manifest.source.json @@ -3,7 +3,7 @@ "schemaVersion": 1, "id": "de.add-ideas.sudoku-tools", "name": "Sudoku Tools", - "version": "0.2.0", + "version": "0.2.1", "description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.", "entry": "./", "icon": "./favicon.svg", diff --git a/src/version.ts b/src/version.ts index da2384c..2f65050 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const APPLICATION_VERSION = "0.2.0"; +export const APPLICATION_VERSION = "0.2.1"; diff --git a/tests/browser/workbench.spec.ts b/tests/browser/workbench.spec.ts index fa1c933..4f26410 100644 --- a/tests/browser/workbench.spec.ts +++ b/tests/browser/workbench.spec.ts @@ -65,6 +65,95 @@ test("loads standalone and keeps the core play workflow local", async ({ expect(runtimeErrors).toEqual([]); }); +test("keeps the board, keypad and solver controls within their layouts", async ({ + page, +}) => { + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.goto("/deep/nested/sudoku/"); + + const frame = page.locator(".sudoku-board-frame"); + const sidebar = page.locator(".side-panel"); + const boardColumn = page.locator(".board-column"); + const scroller = page.locator(".board-viewport__scroller"); + await expect(frame).toBeVisible(); + + const fittedWidth = (await frame.boundingBox())?.width ?? 0; + expect(fittedWidth).toBeGreaterThan(0); + expect( + await scroller.evaluate( + (element) => element.scrollHeight <= element.clientHeight + 1, + ), + ).toBe(true); + + const digits = page.getByRole("group", { name: "Digits" }); + const digitBoxes = await Promise.all( + ["1", "2", "3", "4", "7", "9"].map(async (digit) => + digits.getByRole("button", { name: digit, exact: true }).boundingBox(), + ), + ); + const [one, two, three, four, seven, nine] = digitBoxes; + const erase = await digits + .getByRole("button", { name: "Erase" }) + .boundingBox(); + for (const box of [...digitBoxes, erase]) expect(box).not.toBeNull(); + expect(Math.abs(one!.y - two!.y)).toBeLessThan(1); + expect(Math.abs(two!.y - three!.y)).toBeLessThan(1); + expect(Math.abs(one!.x - four!.x)).toBeLessThan(1); + expect(Math.abs(four!.x - seven!.x)).toBeLessThan(1); + expect(four!.y).toBeGreaterThan(one!.y + one!.height); + expect(seven!.y).toBeGreaterThan(four!.y + four!.height); + expect(Math.abs(erase!.x - one!.x)).toBeLessThan(1); + expect( + Math.abs(erase!.x + erase!.width - (three!.x + three!.width)), + ).toBeLessThan(1); + expect(erase!.y).toBeGreaterThan(nine!.y + nine!.height); + + await page.getByRole("button", { name: "Zoom board out" }).click(); + await expect(page.getByLabel("Board zoom level")).toHaveText("75%"); + const reducedWidth = (await frame.boundingBox())?.width ?? 0; + expect(reducedWidth / fittedWidth).toBeCloseTo(0.75, 2); + await page.getByRole("button", { name: "Fit board" }).click(); + + const baselineSidebarWidth = (await sidebar.boundingBox())?.width ?? 0; + const baselineBoardWidth = (await boardColumn.boundingBox())?.width ?? 0; + for (const workspace of ["Set", "Generate", "Solve", "Helpers"]) { + await page.getByRole("button", { name: workspace, exact: true }).click(); + expect((await sidebar.boundingBox())?.width ?? 0).toBeCloseTo( + baselineSidebarWidth, + 1, + ); + expect((await boardColumn.boundingBox())?.width ?? 0).toBeCloseTo( + baselineBoardWidth, + 1, + ); + } + + await page.getByRole("tab", { name: "Candidate links" }).click(); + await expect( + page.getByRole("heading", { name: "Links and houses" }), + ).toBeVisible(); + expect( + await sidebar.evaluate( + (element) => element.scrollWidth <= element.clientWidth + 1, + ), + ).toBe(true); + + await page.getByRole("button", { name: "Solve", exact: true }).click(); + await page.getByRole("button", { name: "Build logical solve path" }).click(); + const solveSteps = page.locator(".solve-step"); + await expect(solveSteps.first()).toBeVisible({ timeout: 60_000 }); + expect(await solveSteps.count()).toBeGreaterThan(0); + expect( + await solveSteps.evaluateAll((steps) => + steps.every( + (step) => + step.scrollHeight <= step.clientHeight + 1 && + step.scrollWidth <= step.clientWidth + 1, + ), + ), + ).toBe(true); +}); + test("replaces setter cages and generates a rated variant", async ({ page, }) => { diff --git a/tests/components/boardViewport.test.tsx b/tests/components/boardViewport.test.tsx index 864a785..333b780 100644 --- a/tests/components/boardViewport.test.tsx +++ b/tests/components/boardViewport.test.tsx @@ -9,19 +9,35 @@ describe("BoardViewport", () => { it("zooms, fits and restores the persisted scale", async () => { const user = userEvent.setup(); - const { unmount } = render( + const { container, unmount } = render(
Board
, ); expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("100%"); + expect(container.querySelector(".board-viewport")).not.toHaveClass( + "is-zoomed-in", + ); + await user.click(screen.getByRole("button", { name: "Zoom board out" })); + expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("75%"); + expect(container.querySelector(".board-viewport__canvas")).toHaveStyle({ + width: "75%", + maxWidth: "36rem", + }); + expect(container.querySelector(".board-viewport")).not.toHaveClass( + "is-zoomed-in", + ); + await user.click(screen.getByRole("button", { name: "Zoom board in" })); await user.click(screen.getByRole("button", { name: "Zoom board in" })); expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("125%"); + expect(container.querySelector(".board-viewport")).toHaveClass( + "is-zoomed-in", + ); expect(localStorage.getItem(BOARD_SCALE_STORAGE_KEY)).toBe("1.25"); unmount(); - render( + const restored = render(
Board
, @@ -29,6 +45,9 @@ describe("BoardViewport", () => { expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("125%"); await user.click(screen.getByRole("button", { name: "Fit board" })); expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("100%"); + expect(restored.container.querySelector(".board-viewport")).not.toHaveClass( + "is-zoomed-in", + ); }); it("offers explicit pan mode and board-scoped zoom shortcuts", async () => { diff --git a/tests/components/numberPad.test.tsx b/tests/components/numberPad.test.tsx index 272cdb4..0e3c373 100644 --- a/tests/components/numberPad.test.tsx +++ b/tests/components/numberPad.test.tsx @@ -3,7 +3,42 @@ import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { NumberPad } from "../../src/components/NumberPad"; -describe("NumberPad colour accessibility", () => { +describe("NumberPad", () => { + it("marks standard and large digit layouts and exposes a full-row erase key", () => { + const { container, rerender } = render( + , + ); + + expect(screen.getByRole("group", { name: "Digits" })).toHaveClass( + "digit-pad", + ); + expect(container.querySelector(".digit-pad")).not.toHaveClass( + "digit-pad--wide", + ); + expect(screen.getByRole("button", { name: "Erase" })).toHaveClass( + "erase-key", + ); + + rerender( + , + ); + expect(container.querySelector(".digit-pad")).toHaveClass( + "digit-pad--wide", + ); + }); + it("names each colour by both hue and pattern", async () => { const user = userEvent.setup(); const onValue = vi.fn();