fix: stabilize Sudoku workspace layout

This commit is contained in:
2026-08-31 16:14:46 +02:00
parent 0a1bdc1a8c
commit ca7a6270d0
17 changed files with 194 additions and 32 deletions
+10
View File
@@ -4,6 +4,16 @@ All notable changes are documented here.
## Unreleased ## 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 ## 0.2.0 - 2026-08-31
- Added staged guided hints that reveal focus, technique, reasoning and effects - Added staged guided hints that reveal focus, technique, reasoning and effects
+1 -1
View File
@@ -268,7 +268,7 @@ npm run release:artifact
The command verifies the manifest, types, formatting, tests, production build, The command verifies the manifest, types, formatting, tests, production build,
browser workflows and Toolbox contract before creating a deterministic 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 ## Scope and references
+2 -2
View File
@@ -1,8 +1,8 @@
# Corresponding source # 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 The production archive is generated from that tag with the scripts and exact
dependency lock contained in the repository. It contains source identity, dependency lock contained in the repository. It contains source identity,
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sudoku-tools", "name": "sudoku-tools",
"version": "0.2.0", "version": "0.2.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sudoku-tools", "name": "sudoku-tools",
"version": "0.2.0", "version": "0.2.1",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.2.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "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.", "description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "Albrecht Degering", "author": "Albrecht Degering",
+10
View File
@@ -4,6 +4,16 @@ All notable changes are documented here.
## Unreleased ## 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 ## 0.2.0 - 2026-08-31
- Added staged guided hints that reveal focus, technique, reasoning and effects - Added staged guided hints that reveal focus, technique, reasoning and effects
+1 -1
View File
@@ -268,7 +268,7 @@ npm run release:artifact
The command verifies the manifest, types, formatting, tests, production build, The command verifies the manifest, types, formatting, tests, production build,
browser workflows and Toolbox contract before creating a deterministic 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 ## Scope and references
+2 -2
View File
@@ -1,8 +1,8 @@
# Corresponding source # 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 The production archive is generated from that tag with the scripts and exact
dependency lock contained in the repository. It contains source identity, dependency lock contained in the repository. It contains source identity,
+1 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.sudoku-tools", "id": "de.add-ideas.sudoku-tools",
"name": "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.", "description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
+1 -1
View File
@@ -100,7 +100,7 @@ export function BoardViewport({
return ( return (
<section <section
className={`board-viewport${panMode ? " is-pan-mode" : ""}`} className={`board-viewport${scale > 1 ? " is-zoomed-in" : ""}${panMode ? " is-pan-mode" : ""}`}
aria-label="Board zoom and pan" aria-label="Board zoom and pan"
onKeyDownCapture={onKeyDownCapture} onKeyDownCapture={onKeyDownCapture}
> >
+1 -3
View File
@@ -2335,9 +2335,7 @@ export function Workbench() {
</p> </p>
)} )}
<div <div className="workbench-grid">
className={`workbench-grid${workspace === "helpers" ? " workbench-grid--helpers" : ""}`}
>
<section className="board-column" aria-label="Puzzle board"> <section className="board-column" aria-label="Puzzle board">
<div className="board-toolbar"> <div className="board-toolbar">
<div className="toolbar-group"> <div className="toolbar-group">
+14 -13
View File
@@ -632,10 +632,6 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
gap: clamp(0.85rem, 1.6vw, 1.35rem); gap: clamp(0.85rem, 1.6vw, 1.35rem);
} }
.workbench-grid--helpers {
grid-template-columns: minmax(24rem, 0.92fr) minmax(31rem, 1.08fr);
}
.board-column, .board-column,
.side-panel, .side-panel,
.play-panel, .play-panel,
@@ -738,19 +734,23 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
.board-viewport__scroller { .board-viewport__scroller {
width: 100%; width: 100%;
max-height: min(82dvh, 56rem); max-height: none;
overflow: auto; overflow: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
scrollbar-gutter: stable; scrollbar-gutter: stable;
} }
.board-viewport.is-zoomed-in .board-viewport__scroller {
max-height: min(82dvh, 56rem);
}
.board-viewport__scroller:focus-visible { .board-viewport__scroller:focus-visible {
outline: 2px solid var(--toolbox-focus); outline: 2px solid var(--toolbox-focus);
outline-offset: 2px; outline-offset: 2px;
} }
.board-viewport__canvas { .board-viewport__canvas {
min-width: 100%; min-width: 0;
margin-inline: auto; margin-inline: auto;
} }
@@ -2858,12 +2858,12 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
.digit-pad { .digit-pad {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(2.75rem, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.42rem; gap: 0.42rem;
} }
.digit-pad--wide { .digit-pad--wide {
grid-template-columns: repeat(auto-fit, minmax(2.5rem, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
} }
.digit-pad button { .digit-pad button {
@@ -2875,6 +2875,7 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
} }
.digit-pad .erase-key { .digit-pad .erase-key {
grid-column: 1 / -1;
padding-inline: 0.65rem; padding-inline: 0.65rem;
color: var(--toolbox-muted); color: var(--toolbox-muted);
font-size: 0.76rem; font-size: 0.76rem;
@@ -3273,7 +3274,7 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
.candidate-lab__controls { .candidate-lab__controls {
display: grid; 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; align-items: end;
gap: 0.55rem 0.8rem; gap: 0.55rem 0.8rem;
} }
@@ -3510,6 +3511,8 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
.solve-steps { .solve-steps {
max-height: 32rem; max-height: 32rem;
display: grid; display: grid;
grid-auto-rows: max-content;
align-content: start;
gap: 0.42rem; gap: 0.42rem;
overflow: auto; overflow: auto;
padding: 0.15rem; padding: 0.15rem;
@@ -3518,7 +3521,6 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
.solve-step { .solve-step {
width: 100%; width: 100%;
min-height: 0 !important;
display: grid !important; display: grid !important;
grid-template-columns: 2rem minmax(0, 1fr); grid-template-columns: 2rem minmax(0, 1fr);
align-items: start !important; align-items: start !important;
@@ -3966,8 +3968,7 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
/* Responsive composition */ /* Responsive composition */
@media (max-width: 67rem) { @media (max-width: 67rem) {
.workbench-grid, .workbench-grid {
.workbench-grid--helpers {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
@@ -4033,7 +4034,7 @@ body:has(.toolbox-shell[data-toolbox-theme="dark"]) .fatal-error {
min-height: 2.75rem; min-height: 2.75rem;
} }
.board-viewport__scroller { .board-viewport.is-zoomed-in .board-viewport__scroller {
max-height: min(72dvh, 48rem); max-height: min(72dvh, 48rem);
} }
+1 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.sudoku-tools", "id": "de.add-ideas.sudoku-tools",
"name": "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.", "description": "Set, play, solve and analyse Sudoku puzzles locally in the browser.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
+1 -1
View File
@@ -1 +1 @@
export const APPLICATION_VERSION = "0.2.0"; export const APPLICATION_VERSION = "0.2.1";
+89
View File
@@ -65,6 +65,95 @@ test("loads standalone and keeps the core play workflow local", async ({
expect(runtimeErrors).toEqual([]); 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 ({ test("replaces setter cages and generates a rated variant", async ({
page, page,
}) => { }) => {
+21 -2
View File
@@ -9,19 +9,35 @@ describe("BoardViewport", () => {
it("zooms, fits and restores the persisted scale", async () => { it("zooms, fits and restores the persisted scale", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { unmount } = render( const { container, unmount } = render(
<BoardViewport> <BoardViewport>
<div>Board</div> <div>Board</div>
</BoardViewport>, </BoardViewport>,
); );
expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("100%"); 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" })); await user.click(screen.getByRole("button", { name: "Zoom board in" }));
expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("125%"); 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"); expect(localStorage.getItem(BOARD_SCALE_STORAGE_KEY)).toBe("1.25");
unmount(); unmount();
render( const restored = render(
<BoardViewport> <BoardViewport>
<div>Board</div> <div>Board</div>
</BoardViewport>, </BoardViewport>,
@@ -29,6 +45,9 @@ describe("BoardViewport", () => {
expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("125%"); expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("125%");
await user.click(screen.getByRole("button", { name: "Fit board" })); await user.click(screen.getByRole("button", { name: "Fit board" }));
expect(screen.getByLabelText("Board zoom level")).toHaveTextContent("100%"); 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 () => { it("offers explicit pan mode and board-scoped zoom shortcuts", async () => {
+36 -1
View File
@@ -3,7 +3,42 @@ import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { NumberPad } from "../../src/components/NumberPad"; 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(
<NumberPad
size={9}
mode="value"
onMode={vi.fn()}
onValue={vi.fn()}
onErase={vi.fn()}
/>,
);
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(
<NumberPad
size={16}
mode="value"
onMode={vi.fn()}
onValue={vi.fn()}
onErase={vi.fn()}
/>,
);
expect(container.querySelector(".digit-pad")).toHaveClass(
"digit-pad--wide",
);
});
it("names each colour by both hue and pattern", async () => { it("names each colour by both hue and pattern", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onValue = vi.fn(); const onValue = vi.fn();