diff --git a/apps/web/src/lib/tests/Canvas.svelte.test.ts b/apps/web/src/lib/tests/Canvas.svelte.test.ts
index 40119f4..2183c16 100644
--- a/apps/web/src/lib/tests/Canvas.svelte.test.ts
+++ b/apps/web/src/lib/tests/Canvas.svelte.test.ts
@@ -170,7 +170,7 @@ describe('Canvas component', () => {
const { container } = renderCanvas();
const canvas = container.querySelector('canvas') as HTMLCanvasElement;
- expect(window.getComputedStyle(canvas).cursor).toBe('default');
+ expect(window.getComputedStyle(canvas).cursor).toContain('data:image/svg+xml');
window.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', code: 'Space' }));
await vi.waitFor(() => expect(canvas.style.cursor).toBe('grab'));
@@ -199,6 +199,6 @@ describe('Canvas component', () => {
expect(canvas.style.cursor).toBe('grab');
window.dispatchEvent(new KeyboardEvent('keyup', { key: ' ', code: 'Space' }));
- expect(canvas.style.cursor).toBe('default');
+ expect(canvas.style.cursor).toBe('');
});
});
diff --git a/apps/web/src/routes/docs/+page.svelte b/apps/web/src/routes/docs/+page.svelte
index 7944fe6..7ba5ded 100644
--- a/apps/web/src/routes/docs/+page.svelte
+++ b/apps/web/src/routes/docs/+page.svelte
@@ -79,15 +79,19 @@
Draw or automate.
@@ -379,18 +383,26 @@
0 12px 32px color-mix(in srgb, var(--ink-shadow-color) 8%, transparent),
inset 0 0 0 1px color-mix(in srgb, var(--ink-border) 30%, transparent);
text-decoration: none;
- transition-property: background-color, box-shadow, translate;
- transition-duration: 180ms;
+ transform: translateY(0) rotate(0deg);
+ transform-origin: 50% 85%;
+ transition-property: background-color, box-shadow, color, transform, scale;
+ transition-duration: 220ms;
transition-timing-function: var(--ink-ease-out);
}
- .feature-card:hover {
+ .feature-card:hover,
+ .feature-card:focus-visible {
color: var(--ink-text);
- background: color-mix(in srgb, var(--ink-canvas) 75%, var(--ink-accent) 7%);
+ background: color-mix(in srgb, var(--ink-canvas) 88%, var(--ink-accent) 12%);
box-shadow:
- 0 18px 40px color-mix(in srgb, var(--ink-shadow-color) 14%, transparent),
- inset 0 0 0 1px color-mix(in srgb, var(--ink-accent) 45%, transparent);
- translate: 0 -5px;
+ 7px 10px 0 color-mix(in srgb, var(--ink-shadow-color) 88%, transparent),
+ 0 24px 44px color-mix(in srgb, var(--ink-shadow-color) 18%, transparent),
+ inset 0 0 0 2px color-mix(in srgb, var(--ink-border-strong) 78%, transparent);
+ transform: translate(-2px, -8px) rotate(var(--card-tilt));
+ }
+
+ .feature-card:active {
+ scale: 0.96;
}
.feature-icon {
@@ -501,6 +513,7 @@
.secondary-action:hover,
.feature-card:hover {
translate: 0;
+ transform: none;
}
}
diff --git a/packages/core/src/tools.test.ts b/packages/core/src/tools.test.ts
index a1f073c..585daca 100644
--- a/packages/core/src/tools.test.ts
+++ b/packages/core/src/tools.test.ts
@@ -261,6 +261,46 @@ describe("SelectTool", () => {
expect(movedShape2.y).toBe(100);
});
+ it("snaps the lead shape origin and preserves selection offsets", () => {
+ const snappingTool = new SelectTool(undefined, (point) => ({
+ x: Math.round(point.x / 25) * 25,
+ y: Math.round(point.y / 25) * 25,
+ }));
+ const offGridShape1 = { ...shape1, x: 7, y: 8 };
+ const offGridShape2 = { ...shape2, x: 207, y: 8 };
+ const state = {
+ ...initialState,
+ doc: {
+ ...initialState.doc,
+ shapes: { ...initialState.doc.shapes, [shape1.id]: offGridShape1, [shape2.id]: offGridShape2 },
+ },
+ ui: { ...initialState.ui, selectionIds: [shape1.id, shape2.id] },
+ };
+
+ let result = snappingTool.onAction(
+ state,
+ Action.pointerDown(
+ { x: 20, y: 20 },
+ { x: 20, y: 20 },
+ 0,
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+ result = snappingTool.onAction(
+ result,
+ Action.pointerMove(
+ { x: 50, y: 50 },
+ { x: 50, y: 50 },
+ PointerButtons.create(true, false, false),
+ Modifiers.create(),
+ ),
+ );
+
+ expect(result.doc.shapes[shape1.id]).toMatchObject({ x: 25, y: 50 });
+ expect(result.doc.shapes[shape2.id]).toMatchObject({ x: 225, y: 50 });
+ });
+
it("should reset drag state on pointer up", () => {
const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id] } };
@@ -387,11 +427,10 @@ describe("SelectTool", () => {
expect(result.ui.selectionIds).toEqual([]);
});
- it.each([{ description: "Delete key removes selected shapes", key: "Delete", code: "Delete" }, {
- description: "Backspace key removes selected shapes",
- key: "Backspace",
- code: "Backspace",
- }])("should handle $description", ({ key, code }) => {
+ it.each([
+ { description: "Delete key removes selected shapes", key: "Delete", code: "Delete" },
+ { description: "Backspace key removes selected shapes", key: "Backspace", code: "Backspace" },
+ ])("should handle $description", ({ key, code }) => {
const state = { ...initialState, ui: { ...initialState.ui, selectionIds: [shape1.id, shape2.id] } };
const result = tool.onAction(state, Action.keyDown(key, code, Modifiers.create()));
diff --git a/packages/core/src/tools/select.ts b/packages/core/src/tools/select.ts
index 17c3953..5223c31 100644
--- a/packages/core/src/tools/select.ts
+++ b/packages/core/src/tools/select.ts
@@ -63,9 +63,17 @@ export class SelectTool implements Tool {
readonly id: ToolId = 'select';
private toolState: SelectToolState;
private readonly marqueeListener?: (bounds: Box2 | null) => void;
+ private readonly snapPosition?: (point: Vec2) => Vec2;
- constructor(onMarqueeChange?: (bounds: Box2 | null) => void) {
+ /**
+ * Creates a selection tool.
+ *
+ * The optional position snapper aligns the lead shape during a drag. Other
+ * selected shapes keep their original offset from that shape.
+ */
+ constructor(onMarqueeChange?: (bounds: Box2 | null) => void, snapPosition?: (point: Vec2) => Vec2) {
this.marqueeListener = onMarqueeChange;
+ this.snapPosition = snapPosition;
this.toolState = {
isDragging: false,
dragStartWorld: null,
@@ -356,7 +364,12 @@ export class SelectTool implements Tool {
private handleDragMove(state: EditorState, action: Action): EditorState {
if (action.type !== 'pointer-move' || !this.toolState.dragStartWorld) return state;
- const delta = Vec2Ops.sub(action.world, this.toolState.dragStartWorld);
+ let delta = Vec2Ops.sub(action.world, this.toolState.dragStartWorld);
+ const leadPosition = this.toolState.initialShapePositions.values().next().value;
+ if (leadPosition && this.snapPosition) {
+ const candidate = Vec2Ops.add(leadPosition, delta);
+ delta = Vec2Ops.sub(this.snapPosition(candidate), leadPosition);
+ }
const newShapes = { ...state.doc.shapes };
diff --git a/packages/renderer/src/index.ts b/packages/renderer/src/index.ts
index 7529f90..d423923 100644
--- a/packages/renderer/src/index.ts
+++ b/packages/renderer/src/index.ts
@@ -311,18 +311,19 @@ function applyCameraTransform(context: CanvasRenderingContext2D, camera: Camera,
const DEFAULT_GRID_SIZE = 25;
/**
- * Draw grid/graph paper background
+ * Draw a dot-grid background at the same world-space positions used by snapping.
*
- * Draws a subtle grid that helps with spatial awareness and alignment.
- * The grid adapts to zoom level to maintain visual clarity.
+ * At distant zoom levels, the renderer skips intermediate dots so the grid
+ * stays legible without changing the snapping interval.
*/
function drawGrid(context: CanvasRenderingContext2D, camera: Camera, viewport: Viewport, snapSettings?: SnapSettings) {
if (snapSettings && !snapSettings.gridEnabled) {
return;
}
const gridSize = snapSettings?.gridSize ?? DEFAULT_GRID_SIZE;
- const minorGridColor = 'rgba(128, 128, 128, 0.1)';
- const majorGridColor = 'rgba(128, 128, 128, 0.2)';
+ const minimumScreenSpacing = 10;
+ const stepMultiplier = Math.max(1, Math.ceil(minimumScreenSpacing / (gridSize * camera.zoom)));
+ const visibleGridSize = gridSize * stepMultiplier;
const topLeft = {
x: camera.x - viewport.width / (2 * camera.zoom),
@@ -333,30 +334,21 @@ function drawGrid(context: CanvasRenderingContext2D, camera: Camera, viewport: V
y: camera.y + viewport.height / (2 * camera.zoom)
};
- const startX = Math.floor(topLeft.x / gridSize) * gridSize;
- const endX = Math.ceil(bottomRight.x / gridSize) * gridSize;
- const startY = Math.floor(topLeft.y / gridSize) * gridSize;
- const endY = Math.ceil(bottomRight.y / gridSize) * gridSize;
+ const startX = Math.floor(topLeft.x / visibleGridSize) * visibleGridSize;
+ const endX = Math.ceil(bottomRight.x / visibleGridSize) * visibleGridSize;
+ const startY = Math.floor(topLeft.y / visibleGridSize) * visibleGridSize;
+ const endY = Math.ceil(bottomRight.y / visibleGridSize) * visibleGridSize;
+ const dotRadius = 1 / camera.zoom;
- context.lineWidth = 1 / camera.zoom;
-
- for (let x = startX; x <= endX; x += gridSize) {
- const isMajor = x % (gridSize * 5) === 0;
- context.strokeStyle = isMajor ? majorGridColor : minorGridColor;
- context.beginPath();
- context.moveTo(x, startY);
- context.lineTo(x, endY);
- context.stroke();
- }
-
- for (let y = startY; y <= endY; y += gridSize) {
- const isMajor = y % (gridSize * 5) === 0;
- context.strokeStyle = isMajor ? majorGridColor : minorGridColor;
- context.beginPath();
- context.moveTo(startX, y);
- context.lineTo(endX, y);
- context.stroke();
+ context.fillStyle = 'rgba(128, 128, 128, 0.24)';
+ context.beginPath();
+ for (let x = startX; x <= endX; x += visibleGridSize) {
+ for (let y = startY; y <= endY; y += visibleGridSize) {
+ context.moveTo(x + dotRadius, y);
+ context.arc(x, y, dotRadius, 0, Math.PI * 2);
+ }
}
+ context.fill();
}
function drawSnapGuides(
diff --git a/packages/renderer/tests/index.test.ts b/packages/renderer/tests/index.test.ts
index 460687b..2035e17 100644
--- a/packages/renderer/tests/index.test.ts
+++ b/packages/renderer/tests/index.test.ts
@@ -179,7 +179,7 @@ describe('Renderer', () => {
const renderer = createRenderer(canvas, store);
scheduledFrames.shift()?.(0);
expect(alphaWrites).toContain(0.4);
- expect(context.fill).toHaveBeenCalledTimes(1);
+ expect(context.fill).toHaveBeenCalledTimes(2);
expect(context.save).toHaveBeenCalledTimes(vi.mocked(context.restore).mock.calls.length);
renderer.dispose();
});
@@ -233,7 +233,7 @@ describe('Renderer', () => {
expect(alphaWrites).toContain(0.2);
expect(alphaWrites).toContain(0.4);
expect(strokeAlphas).toContain(0.4);
- expect(context.fill).toHaveBeenCalledOnce();
+ expect(context.fill).toHaveBeenCalledTimes(2);
renderer.dispose();
});
@@ -291,7 +291,7 @@ describe('Renderer', () => {
const renderer = createRenderer(canvas, store);
scheduledFrames.shift()?.(0);
- expect(context.fill).toHaveBeenCalledTimes(1);
+ expect(context.fill).toHaveBeenCalledTimes(2);
expect(context.strokeRect).toHaveBeenCalledWith(0, 0, 50, 50);
expect(context.translate).toHaveBeenCalledWith(10_000, 10_000);
renderer.dispose();
diff --git a/packages/ui/src/lib/editor/canvas/Canvas.svelte b/packages/ui/src/lib/editor/canvas/Canvas.svelte
index 21540e4..00f50f9 100644
--- a/packages/ui/src/lib/editor/canvas/Canvas.svelte
+++ b/packages/ui/src/lib/editor/canvas/Canvas.svelte
@@ -374,7 +374,10 @@
height: 100%;
display: block;
touch-action: none;
- cursor: default;
+ cursor:
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 28 28'%3E%3Cpath d='M4 2.75 22.2 16.1l-8.05 1.15 4.2 7.25-4.2 2.4-4.05-7.2-5.35 6.1z' fill='%23171928' stroke='%2388edc4' stroke-width='2.25' stroke-linejoin='round'/%3E%3C/svg%3E")
+ 4 3,
+ default;
}
.proposal-ghost-layer {
diff --git a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts
index 7aae204..35c6e94 100644
--- a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts
+++ b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts
@@ -156,7 +156,7 @@ export function createCanvasController(
{ hover: handleState.hover, active: handleState.active },
runtime.getInteractionState().pointerDown
);
- canvas.style.cursor = cursor;
+ canvas.style.cursor = cursor === 'default' ? '' : cursor;
}
function setActiveBoardId(boardId: string) {
@@ -180,7 +180,21 @@ export function createCanvasController(
const handleMarqueeChange = (bounds: Box2 | null) => void updateMarquee(bounds);
- const selectTool = new SelectTool(handleMarqueeChange);
+ const selectTool = new SelectTool(handleMarqueeChange, (point) => {
+ const snap = snapStore.get();
+ if (
+ !snap.snapEnabled ||
+ !snap.gridEnabled ||
+ !Number.isFinite(snap.gridSize) ||
+ snap.gridSize <= 0
+ ) {
+ return point;
+ }
+ return {
+ x: Math.round(point.x / snap.gridSize) * snap.gridSize,
+ y: Math.round(point.y / snap.gridSize) * snap.gridSize
+ };
+ });
const rectTool = new RectTool();
const ellipseTool = new EllipseTool();
const lineTool = new LineTool();
diff --git a/packages/ui/src/lib/editor/components/HistoryViewer.svelte b/packages/ui/src/lib/editor/components/HistoryViewer.svelte
index e0566b4..e609e83 100644
--- a/packages/ui/src/lib/editor/components/HistoryViewer.svelte
+++ b/packages/ui/src/lib/editor/components/HistoryViewer.svelte
@@ -92,6 +92,7 @@