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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

> **Upgrades:** No breaking changes in **3.7.x** / **3.8.x** / **3.9.x** / **3.10.x** / **3.11.x** / **3.12.x** / **3.13.x** / **3.14.x** / **3.15.x** / **3.16.x** / **3.17.x** unless noted below.

## [3.17.6] - 2026-06-02

### Fixed

- **Wall edge lines in Chrome/Edge** - Shift-drag connections were created but invisible in Chromium: the edge SVG overlay lived inside the 0×0 `.wall-content` transform anchor with `width/height: 100%` (== 0), so lines never painted. The overlay now uses a non-zero box with `overflow: visible`.

### Improvements

- **Wall zoom modifier** - **Shift**+scroll is now the primary zoom control on the wall (Ctrl/Cmd+scroll and trackpad pinch still zoom).
- **Wall canvas mode preference** - The Select/Pan toggle is remembered globally in the browser and applies to every project's wall.

### Documentation

- **`docs/wall.md`** - Shift+scroll zoom, global canvas-mode preference.

## [3.17.5] - 2026-06-02

### Improvements
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<p align="center">
<img width="372" src="internal/httpapi/web/githublogo.png" alt="scrumboy logo" />
<br />
<img src="https://img.shields.io/badge/version-v3.17.5-blue" alt="version" />
<img src="https://img.shields.io/badge/version-v3.17.6-blue" alt="version" />
<a href="LICENSE">
<img src="https://img.shields.io/badge/license-AGPL--v3-orange" alt="license" />
</a>
Expand Down
4 changes: 2 additions & 2 deletions docs/wall.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ Sticky-note board for durable projects. Open it from the board topbar (desktop o
- **Exit multi-select on canvas** - **Click** empty space (no drag).
- **Move a group** - With multiple notes selected, drag one of them; all selected notes move together. Selection clears when you release the drag.
- **Delete several at once** - Drag the group over the trash; one confirmation lists how many notes will be deleted.
- **Canvas mode toggle** - The button left of **Fit view** switches between **Select** mode (dashed-square icon) and **Pan** mode (hand icon). The wall always opens in Select mode.
- **Canvas mode toggle** - The button left of **Fit view** switches between **Select** mode (dashed-square icon) and **Pan** mode (hand icon). Your choice is remembered globally in the browser and applies to every project's wall (defaults to Select until you change it).
- **Select mode** - Empty-canvas drag draws the marquee box to select notes.
- **Pan mode** - Empty-canvas mouse drag or touch swipe pans the wall. Two-finger touch pinch zooms the wall.
- **Pan the canvas** - Scroll wheel, **middle-mouse drag**, hold **Space** and drag on empty canvas, or use the **arrow keys** (hold **Shift** for larger steps).
- **Zoom** - **Ctrl**+scroll (Windows/Linux) or **⌘**+scroll (Mac). Pinch-to-zoom on trackpads uses the same modifier.
- **Zoom** - **Shift**+scroll. **Ctrl**+scroll (Windows/Linux) or **⌘**+scroll (Mac) also zoom, and pinch-to-zoom on trackpads uses that modifier.
- **Fit view** - Click the **⊡** button (top-right, beside close) or press **F** while the wall is open. Recenters on all notes (or origin when empty). Your pan/zoom per board is remembered in the browser.

## Disabling the wall
Expand Down
38 changes: 37 additions & 1 deletion internal/httpapi/web/dist/dialogs/wall-canvas-mode.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,53 @@
// Global (not per-project) canvas-mode preference. The wall is per-project but
// the Select/Pan toggle is remembered across every board's wall via a single
// localStorage key. Mirrors the resilient try/catch storage pattern used by
// wall-viewport.ts.
const CANVAS_MODE_STORAGE_KEY = "scrumboy.wall.canvasMode";
let wallCanvasMode = "select";
export function getWallCanvasMode() {
return wallCanvasMode;
}
/** Coerce arbitrary input to a valid mode; anything but "pan" falls to "select". */
export function normalizeWallCanvasMode(raw) {
return raw === "pan" ? "pan" : "select";
}
function saveWallCanvasMode(mode) {
try {
localStorage.setItem(CANVAS_MODE_STORAGE_KEY, mode);
}
catch {
// private mode / quota / disabled — ignore
}
}
export function setWallCanvasMode(mode) {
wallCanvasMode = mode === "pan" ? "pan" : "select";
wallCanvasMode = normalizeWallCanvasMode(mode);
saveWallCanvasMode(wallCanvasMode);
}
export function toggleWallCanvasMode() {
wallCanvasMode = wallCanvasMode === "select" ? "pan" : "select";
saveWallCanvasMode(wallCanvasMode);
return wallCanvasMode;
}
export function isWallPanMode() {
return wallCanvasMode === "pan";
}
/** Load the persisted global preference into memory and return it. */
export function loadWallCanvasMode() {
try {
wallCanvasMode = normalizeWallCanvasMode(localStorage.getItem(CANVAS_MODE_STORAGE_KEY));
}
catch {
wallCanvasMode = "select";
}
return wallCanvasMode;
}
/** Test-only: reset in-memory mode and clear the persisted preference. */
export function resetWallCanvasMode() {
wallCanvasMode = "select";
try {
localStorage.removeItem(CANVAS_MODE_STORAGE_KEY);
}
catch {
// ignore
}
}
16 changes: 12 additions & 4 deletions internal/httpapi/web/dist/dialogs/wall-rendering.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,21 @@ export function ensureEdgeOverlay(surface) {
svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("id", EDGE_OVERLAY_ID);
svg.setAttribute("class", "wall-edge-overlay");
// Cover the entire surface; SVG itself ignores pointer events, only
// hit-line children opt back in.
// The overlay lives inside `.wall-content`, which is intentionally 0x0 (it
// is only a transform anchor; notes are absolutely positioned). Edges are
// drawn in canvas coordinates and rely on `overflow: visible` to paint
// outside that box. Chromium will NOT paint SVG geometry that lies outside a
// *zero-sized* outer <svg> viewport (Firefox/WebKit tolerate it), so a
// `width/height: 100%` (== 0 here) overlay renders every connection line
// invisibly in Chrome/Edge. Give the SVG a non-zero box so painting is
// enabled; `overflow: visible` then lets lines extend to any canvas coord
// (including negative) in all engines. See wall-rendering.test.ts.
svg.style.position = "absolute";
svg.style.left = "0";
svg.style.top = "0";
svg.style.width = "100%";
svg.style.height = "100%";
svg.style.width = "1px";
svg.style.height = "1px";
svg.style.overflow = "visible";
svg.style.pointerEvents = "none";
// Postbaby parity: lines paint *under* notes (.wall-note is z-index: 2 in
// styles.css). This overlay uses z-index: 0 via .wall-edge-overlay; a
Expand Down
17 changes: 15 additions & 2 deletions internal/httpapi/web/dist/dialogs/wall-viewport-nav.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,17 @@ function onWheel(ev) {
return;
ev.preventDefault();
const { dx, dy } = wheelPixels(ev);
if (ev.ctrlKey || ev.metaKey) {
const factor = dy < 0 ? WHEEL_ZOOM_FACTOR : 1 / WHEEL_ZOOM_FACTOR;
// Shift is the primary zoom modifier; Ctrl/Cmd also zoom because browsers
// deliver trackpad pinch-to-zoom as synthetic Ctrl+wheel events.
if (ev.shiftKey || ev.ctrlKey || ev.metaKey) {
// On Windows, Shift+wheel is remapped to horizontal scroll, so the delta
// arrives in dx (dy ~ 0). Fall back to dx so zoom direction stays correct.
const zoomDelta = dy !== 0 ? dy : dx;
// A zero-delta wheel event (some inertia/end events, synthetic devices)
// carries no direction; do nothing rather than zooming out by default.
if (zoomDelta === 0)
return;
const factor = zoomDelta < 0 ? WHEEL_ZOOM_FACTOR : 1 / WHEEL_ZOOM_FACTOR;
zoomAround(ev.clientX, ev.clientY, factor);
return;
}
Expand Down Expand Up @@ -429,6 +438,10 @@ export function isSpacePanArmed() {
export function __onArrowKeyDownForTest(ev, isOpen = () => true) {
onArrowKeyDown(ev, isOpen);
}
/** For tests: drive the wheel pan/zoom handler directly. */
export function __onWheelForTest(ev) {
onWheel(ev);
}
/** For tests: whether space-to-pan is armed. */
export function __isSpaceHeldForTest() {
return spaceHeld;
Expand Down
5 changes: 2 additions & 3 deletions internal/httpapi/web/dist/dialogs/wall.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import { beginEdit as beginEditController } from "./wall-edit-controller.js";
import { openWallNoteContextMenu } from "./wall-note-context-menu.js";
import { clampCanvasCoord, ensureWallContent, fitToNotes, getWallContent, initWallViewport, screenToCanvas, teardownWallViewport, } from "./wall-viewport.js";
import { bindWallNavigation, cancelWallNavigationGestures, isSpacePanArmed, } from "./wall-viewport-nav.js";
import { getWallCanvasMode, isWallPanMode, resetWallCanvasMode, toggleWallCanvasMode, } from "./wall-canvas-mode.js";
import { getWallCanvasMode, isWallPanMode, loadWallCanvasMode, toggleWallCanvasMode, } from "./wall-canvas-mode.js";
const TEARDOWN_MARKER = Symbol("wallMounted");
const SELECT_MODE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-dashed-icon lucide-square-dashed"><path d="M5 3a2 2 0 0 0-2 2"/><path d="M19 3a2 2 0 0 1 2 2"/><path d="M21 19a2 2 0 0 1-2 2"/><path d="M5 21a2 2 0 0 1-2-2"/><path d="M9 3h1"/><path d="M9 21h1"/><path d="M14 3h1"/><path d="M14 21h1"/><path d="M3 9v1"/><path d="M21 9v1"/><path d="M3 14v1"/><path d="M21 14v1"/></svg>`;
const PAN_MODE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-hand-icon lucide-hand"><path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg>`;
Expand Down Expand Up @@ -106,7 +106,7 @@ export async function openWallDialog(opts) {
};
setMounted(state);
dialog[TEARDOWN_MARKER] = true;
resetWallCanvasMode();
loadWallCanvasMode();
wallSurface.classList.toggle("wall-surface--readonly", !canEdit);
const content = ensureWallContent(wallSurface);
initWallViewport(wallSurface, content, opts.slug);
Expand Down Expand Up @@ -176,7 +176,6 @@ function teardown() {
state.transient.clear();
state.selected.clear();
cancelWallNavigationGestures();
resetWallCanvasMode();
syncWallCanvasModeUi();
state.abort.abort();
teardownWallViewport();
Expand Down
59 changes: 56 additions & 3 deletions internal/httpapi/web/modules/dialogs/wall-canvas-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,20 +200,30 @@ describe("wall canvas mode toggle", () => {
expect(wallSurfaceEl.classList.contains("wall-surface--pan-mode")).toBe(false);
});

it("resets to Select mode after close and reopen", async () => {
it("persists Pan mode globally after close and reopen", async () => {
await openWall();
const btn = getModeBtn();
btn.click();
await flushPromises();
expect(btn.getAttribute("aria-pressed")).toBe("true");
expect(localStorage.getItem("scrumboy.wall.canvasMode")).toBe("pan");

wallDialogEl.close();
await flushPromises();

// Reopen a different project's wall: the global preference still applies.
await openWall();
const reopened = getModeBtn();
expect(reopened.getAttribute("aria-pressed")).toBe("false");
expect(reopened.innerHTML).toContain("lucide-square-dashed");
expect(reopened.getAttribute("aria-pressed")).toBe("true");
expect(reopened.innerHTML).toContain("lucide-hand");
expect(wallSurfaceEl.classList.contains("wall-surface--pan-mode")).toBe(true);
});

it("opens in Select mode when no saved preference exists", async () => {
await openWall();
const btn = getModeBtn();
expect(btn.getAttribute("aria-pressed")).toBe("false");
expect(btn.innerHTML).toContain("lucide-square-dashed");
expect(wallSurfaceEl.classList.contains("wall-surface--pan-mode")).toBe(false);
});

Expand Down Expand Up @@ -415,3 +425,46 @@ describe("wall canvas mode toggle", () => {
);
});
});

describe("wall-canvas-mode persistence", () => {
beforeEach(() => {
vi.resetModules();
localStorage.clear();
});

afterEach(() => {
localStorage.clear();
});

it("normalizeWallCanvasMode rejects garbage and defaults to select", async () => {
const { normalizeWallCanvasMode } = await import("./wall-canvas-mode.js");
expect(normalizeWallCanvasMode("pan")).toBe("pan");
expect(normalizeWallCanvasMode("select")).toBe("select");
expect(normalizeWallCanvasMode("nonsense")).toBe("select");
expect(normalizeWallCanvasMode(null)).toBe("select");
expect(normalizeWallCanvasMode(undefined)).toBe("select");
expect(normalizeWallCanvasMode(42)).toBe("select");
});

it("loadWallCanvasMode reads a saved pan preference", async () => {
localStorage.setItem("scrumboy.wall.canvasMode", "pan");
const { loadWallCanvasMode, getWallCanvasMode } = await import("./wall-canvas-mode.js");
expect(loadWallCanvasMode()).toBe("pan");
expect(getWallCanvasMode()).toBe("pan");
});

it("loadWallCanvasMode defaults to select when unset or invalid", async () => {
const { loadWallCanvasMode } = await import("./wall-canvas-mode.js");
expect(loadWallCanvasMode()).toBe("select");
localStorage.setItem("scrumboy.wall.canvasMode", "bogus");
expect(loadWallCanvasMode()).toBe("select");
});

it("toggleWallCanvasMode and setWallCanvasMode write to localStorage", async () => {
const { toggleWallCanvasMode, setWallCanvasMode } = await import("./wall-canvas-mode.js");
expect(toggleWallCanvasMode()).toBe("pan");
expect(localStorage.getItem("scrumboy.wall.canvasMode")).toBe("pan");
setWallCanvasMode("select");
expect(localStorage.getItem("scrumboy.wall.canvasMode")).toBe("select");
});
});
39 changes: 38 additions & 1 deletion internal/httpapi/web/modules/dialogs/wall-canvas-mode.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,61 @@
export type WallCanvasMode = "select" | "pan";

// Global (not per-project) canvas-mode preference. The wall is per-project but
// the Select/Pan toggle is remembered across every board's wall via a single
// localStorage key. Mirrors the resilient try/catch storage pattern used by
// wall-viewport.ts.
const CANVAS_MODE_STORAGE_KEY = "scrumboy.wall.canvasMode";

let wallCanvasMode: WallCanvasMode = "select";

export function getWallCanvasMode(): WallCanvasMode {
return wallCanvasMode;
}

/** Coerce arbitrary input to a valid mode; anything but "pan" falls to "select". */
export function normalizeWallCanvasMode(raw: unknown): WallCanvasMode {
return raw === "pan" ? "pan" : "select";
}

function saveWallCanvasMode(mode: WallCanvasMode): void {
try {
localStorage.setItem(CANVAS_MODE_STORAGE_KEY, mode);
} catch {
// private mode / quota / disabled — ignore
}
}

export function setWallCanvasMode(mode: WallCanvasMode): void {
wallCanvasMode = mode === "pan" ? "pan" : "select";
wallCanvasMode = normalizeWallCanvasMode(mode);
saveWallCanvasMode(wallCanvasMode);
}

export function toggleWallCanvasMode(): WallCanvasMode {
wallCanvasMode = wallCanvasMode === "select" ? "pan" : "select";
saveWallCanvasMode(wallCanvasMode);
return wallCanvasMode;
}

export function isWallPanMode(): boolean {
return wallCanvasMode === "pan";
}

/** Load the persisted global preference into memory and return it. */
export function loadWallCanvasMode(): WallCanvasMode {
try {
wallCanvasMode = normalizeWallCanvasMode(localStorage.getItem(CANVAS_MODE_STORAGE_KEY));
} catch {
wallCanvasMode = "select";
}
return wallCanvasMode;
}

/** Test-only: reset in-memory mode and clear the persisted preference. */
export function resetWallCanvasMode(): void {
wallCanvasMode = "select";
try {
localStorage.removeItem(CANVAS_MODE_STORAGE_KEY);
} catch {
// ignore
}
}
16 changes: 16 additions & 0 deletions internal/httpapi/web/modules/dialogs/wall-rendering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,22 @@ describe('wall edge overlay', () => {
expect(surface.querySelectorAll('svg').length).toBe(1);
});

it('ensureEdgeOverlay is non-zero sized with overflow:visible (Chromium paints out-of-box edges)', () => {
// Regression guard: the overlay lives inside the 0x0 `.wall-content`
// transform anchor. If the SVG itself is sized 100% (== 0 here) Chromium
// refuses to paint any connection line that lies outside the zero-sized
// viewport, so Shift-drag edges become invisible in Chrome/Edge while the
// edge data is still created. A non-zero box + overflow:visible fixes it.
const surface = document.createElement('div');
document.body.appendChild(surface);
const svg = ensureEdgeOverlay(surface);
expect(svg.style.width).not.toBe('100%');
expect(svg.style.height).not.toBe('100%');
expect(parseFloat(svg.style.width)).toBeGreaterThan(0);
expect(parseFloat(svg.style.height)).toBeGreaterThan(0);
expect(svg.style.overflow).toBe('visible');
});

it('renderEdges draws hit + visible lines between note centers', () => {
const { surface } = mountSurfaceWithNotes();
renderEdges(surface, [{ id: 'e1', from: 'na', to: 'nb' }]);
Expand Down
16 changes: 12 additions & 4 deletions internal/httpapi/web/modules/dialogs/wall-rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,13 +183,21 @@ export function ensureEdgeOverlay(surface: HTMLElement): SVGSVGElement {
svg = document.createElementNS(SVG_NS, "svg") as SVGSVGElement;
svg.setAttribute("id", EDGE_OVERLAY_ID);
svg.setAttribute("class", "wall-edge-overlay");
// Cover the entire surface; SVG itself ignores pointer events, only
// hit-line children opt back in.
// The overlay lives inside `.wall-content`, which is intentionally 0x0 (it
// is only a transform anchor; notes are absolutely positioned). Edges are
// drawn in canvas coordinates and rely on `overflow: visible` to paint
// outside that box. Chromium will NOT paint SVG geometry that lies outside a
// *zero-sized* outer <svg> viewport (Firefox/WebKit tolerate it), so a
// `width/height: 100%` (== 0 here) overlay renders every connection line
// invisibly in Chrome/Edge. Give the SVG a non-zero box so painting is
// enabled; `overflow: visible` then lets lines extend to any canvas coord
// (including negative) in all engines. See wall-rendering.test.ts.
svg.style.position = "absolute";
svg.style.left = "0";
svg.style.top = "0";
svg.style.width = "100%";
svg.style.height = "100%";
svg.style.width = "1px";
svg.style.height = "1px";
svg.style.overflow = "visible";
svg.style.pointerEvents = "none";
// Postbaby parity: lines paint *under* notes (.wall-note is z-index: 2 in
// styles.css). This overlay uses z-index: 0 via .wall-edge-overlay; a
Expand Down
Loading
Loading