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();