···55The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7788+## [0.22.2] — 2026-04-03
99+1010+### Fixed
1111+- Inline text editing no longer destroyed by mousedown on canvas (#350)
1212+- Eraser drag-to-erase now records undo history (#350)
1313+- Opacity slider now records undo history (#350)
1414+- Negative rotation angles normalized to 0-359 range (#350)
1515+- SVG/PNG export now includes shape opacity (#350)
1616+- SVG/PNG export uses per-shape font family and size instead of defaults (#350)
1717+- Export bounding box correctly offsets line/freehand points by shape position (#350)
1818+- Tool switch while drawing line now finishes or cleans up line state (#350)
1919+2020+### Added
2121+- 66 new QA tests covering groups, rotation, styles, duplication, export edge cases
2222+823## [0.22.1] — 2026-04-03
9241025### Fixed
2626+- Fix line and arrow tools not working in diagrams (#348)
1127- Arrow tool now works from empty canvas space, not just shape-to-shape (#348)
1228- Arrow endpoints support mixed types (shape anchor or free-standing point)
1329
···11+/**
22+ * Diagrams Bug Tests — failing tests that demonstrate discovered bugs.
33+ *
44+ * These tests cover BOTH bugs found in the UI layer (documented for manual fix)
55+ * AND the missing unit test coverage for pure-logic functions in whiteboard.ts.
66+ *
77+ * Every test in this file should either:
88+ * - Fail because it tests a bug (marked with comment: BUG)
99+ * - Pass because it fills a test coverage gap (marked with comment: COVERAGE GAP)
1010+ */
1111+import { describe, it, expect } from 'vitest';
1212+import {
1313+ createWhiteboard,
1414+ addShape,
1515+ addArrow,
1616+ removeShape,
1717+ groupShapes,
1818+ ungroupShapes,
1919+ getGroupMembers,
2020+ rotateShape,
2121+ setShapeRotation,
2222+ setShapeStyle,
2323+ setShapeOpacity,
2424+ setShapeFontFamily,
2525+ setShapeFontSize,
2626+ duplicateShapes,
2727+ applyResize,
2828+ hitTestShape,
2929+ snapPoint,
3030+ getBoundingBox,
3131+ toggleSnap,
3232+} from '../src/diagrams/whiteboard';
3333+import type { Shape, WhiteboardState } from '../src/diagrams/whiteboard';
3434+import { exportToSVG } from '../src/diagrams/export';
3535+import History from '../src/diagrams/history';
3636+3737+// ---------------------------------------------------------------------------
3838+// Helpers
3939+// ---------------------------------------------------------------------------
4040+4141+function noSnap(state: WhiteboardState): WhiteboardState {
4242+ return { ...state, snapToGrid: false };
4343+}
4444+4545+function makeShape(overrides: Partial<Shape> & { id: string; kind: Shape['kind'] }): Shape {
4646+ return {
4747+ x: 0,
4848+ y: 0,
4949+ width: 120,
5050+ height: 80,
5151+ rotation: 0,
5252+ label: '',
5353+ style: {},
5454+ opacity: 1,
5555+ ...overrides,
5656+ };
5757+}
5858+5959+// ---------------------------------------------------------------------------
6060+// COVERAGE GAP: groupShapes / ungroupShapes / getGroupMembers
6161+// ---------------------------------------------------------------------------
6262+6363+describe('groupShapes', () => {
6464+ it('assigns a shared groupId to all specified shapes', () => {
6565+ let wb = noSnap(createWhiteboard());
6666+ wb = addShape(wb, 'rectangle', 0, 0);
6767+ wb = addShape(wb, 'ellipse', 100, 100);
6868+ const ids = [...wb.shapes.keys()];
6969+ const { state, groupId } = groupShapes(wb, ids);
7070+ expect(groupId).toBeTruthy();
7171+ expect(groupId.startsWith('group-')).toBe(true);
7272+ for (const id of ids) {
7373+ expect(state.shapes.get(id)!.groupId).toBe(groupId);
7474+ }
7575+ });
7676+7777+ it('returns empty groupId for fewer than 2 shapes', () => {
7878+ let wb = noSnap(createWhiteboard());
7979+ wb = addShape(wb, 'rectangle', 0, 0);
8080+ const ids = [...wb.shapes.keys()];
8181+ const { state, groupId } = groupShapes(wb, ids);
8282+ expect(groupId).toBe('');
8383+ // State should be unchanged
8484+ expect(state.shapes.get(ids[0])!.groupId).toBeUndefined();
8585+ });
8686+8787+ it('returns empty groupId for empty array', () => {
8888+ const wb = createWhiteboard();
8989+ const { groupId } = groupShapes(wb, []);
9090+ expect(groupId).toBe('');
9191+ });
9292+});
9393+9494+describe('ungroupShapes', () => {
9595+ it('removes groupId from all shapes in the group', () => {
9696+ let wb = noSnap(createWhiteboard());
9797+ wb = addShape(wb, 'rectangle', 0, 0);
9898+ wb = addShape(wb, 'ellipse', 100, 100);
9999+ const ids = [...wb.shapes.keys()];
100100+ const { state, groupId } = groupShapes(wb, ids);
101101+ const ungrouped = ungroupShapes(state, groupId);
102102+ for (const id of ids) {
103103+ expect(ungrouped.shapes.get(id)!.groupId).toBeUndefined();
104104+ }
105105+ });
106106+107107+ it('does not affect shapes in other groups', () => {
108108+ let wb = noSnap(createWhiteboard());
109109+ wb = addShape(wb, 'rectangle', 0, 0);
110110+ wb = addShape(wb, 'ellipse', 100, 0);
111111+ wb = addShape(wb, 'diamond', 200, 0);
112112+ const ids = [...wb.shapes.keys()];
113113+ const { state: s1, groupId: g1 } = groupShapes(wb, [ids[0], ids[1]]);
114114+ // Manually group the third shape into a different group
115115+ const s2 = { ...s1, shapes: new Map(s1.shapes) };
116116+ s2.shapes.set(ids[2], { ...s2.shapes.get(ids[2])!, groupId: 'other-group' });
117117+ const ungrouped = ungroupShapes(s2, g1);
118118+ expect(ungrouped.shapes.get(ids[0])!.groupId).toBeUndefined();
119119+ expect(ungrouped.shapes.get(ids[1])!.groupId).toBeUndefined();
120120+ expect(ungrouped.shapes.get(ids[2])!.groupId).toBe('other-group');
121121+ });
122122+});
123123+124124+describe('getGroupMembers', () => {
125125+ it('returns all shape IDs belonging to a group', () => {
126126+ let wb = noSnap(createWhiteboard());
127127+ wb = addShape(wb, 'rectangle', 0, 0);
128128+ wb = addShape(wb, 'ellipse', 100, 0);
129129+ wb = addShape(wb, 'diamond', 200, 0);
130130+ const ids = [...wb.shapes.keys()];
131131+ const { state, groupId } = groupShapes(wb, [ids[0], ids[1]]);
132132+ const members = getGroupMembers(state, groupId);
133133+ expect(members).toHaveLength(2);
134134+ expect(members).toContain(ids[0]);
135135+ expect(members).toContain(ids[1]);
136136+ expect(members).not.toContain(ids[2]);
137137+ });
138138+139139+ it('returns empty array for non-existent group', () => {
140140+ const wb = createWhiteboard();
141141+ expect(getGroupMembers(wb, 'fake-group')).toEqual([]);
142142+ });
143143+});
144144+145145+// ---------------------------------------------------------------------------
146146+// COVERAGE GAP: rotateShape / setShapeRotation
147147+// ---------------------------------------------------------------------------
148148+149149+describe('rotateShape', () => {
150150+ it('accumulates rotation angle', () => {
151151+ let wb = noSnap(createWhiteboard());
152152+ wb = addShape(wb, 'rectangle', 0, 0);
153153+ const id = [...wb.shapes.keys()][0];
154154+ wb = rotateShape(wb, id, 45);
155155+ expect(wb.shapes.get(id)!.rotation).toBe(45);
156156+ wb = rotateShape(wb, id, 30);
157157+ expect(wb.shapes.get(id)!.rotation).toBe(75);
158158+ });
159159+160160+ it('wraps rotation at 360 degrees', () => {
161161+ let wb = noSnap(createWhiteboard());
162162+ wb = addShape(wb, 'rectangle', 0, 0);
163163+ const id = [...wb.shapes.keys()][0];
164164+ wb = rotateShape(wb, id, 350);
165165+ wb = rotateShape(wb, id, 20);
166166+ expect(wb.shapes.get(id)!.rotation).toBe(10); // (350 + 20) % 360
167167+ });
168168+169169+ it('handles negative angles', () => {
170170+ let wb = noSnap(createWhiteboard());
171171+ wb = addShape(wb, 'rectangle', 0, 0);
172172+ const id = [...wb.shapes.keys()][0];
173173+ wb = rotateShape(wb, id, -45);
174174+ // Fixed: negative angles now normalize to positive (315 instead of -45)
175175+ expect(wb.shapes.get(id)!.rotation).toBe(315);
176176+ });
177177+178178+ it('returns unchanged state for non-existent shape', () => {
179179+ const wb = createWhiteboard();
180180+ expect(rotateShape(wb, 'fake', 90)).toBe(wb);
181181+ });
182182+});
183183+184184+describe('setShapeRotation', () => {
185185+ it('sets absolute rotation', () => {
186186+ let wb = noSnap(createWhiteboard());
187187+ wb = addShape(wb, 'rectangle', 0, 0);
188188+ const id = [...wb.shapes.keys()][0];
189189+ wb = setShapeRotation(wb, id, 90);
190190+ expect(wb.shapes.get(id)!.rotation).toBe(90);
191191+ });
192192+193193+ it('wraps at 360', () => {
194194+ let wb = noSnap(createWhiteboard());
195195+ wb = addShape(wb, 'rectangle', 0, 0);
196196+ const id = [...wb.shapes.keys()][0];
197197+ wb = setShapeRotation(wb, id, 400);
198198+ expect(wb.shapes.get(id)!.rotation).toBe(40);
199199+ });
200200+201201+ it('returns unchanged state for non-existent shape', () => {
202202+ const wb = createWhiteboard();
203203+ expect(setShapeRotation(wb, 'fake', 90)).toBe(wb);
204204+ });
205205+});
206206+207207+// ---------------------------------------------------------------------------
208208+// COVERAGE GAP: setShapeStyle
209209+// ---------------------------------------------------------------------------
210210+211211+describe('setShapeStyle', () => {
212212+ it('sets style properties on a shape', () => {
213213+ let wb = noSnap(createWhiteboard());
214214+ wb = addShape(wb, 'rectangle', 0, 0);
215215+ const id = [...wb.shapes.keys()][0];
216216+ wb = setShapeStyle(wb, [id], { fill: '#ff0000', stroke: '#00ff00' });
217217+ const shape = wb.shapes.get(id)!;
218218+ expect(shape.style.fill).toBe('#ff0000');
219219+ expect(shape.style.stroke).toBe('#00ff00');
220220+ });
221221+222222+ it('merges with existing styles', () => {
223223+ let wb = noSnap(createWhiteboard());
224224+ wb = addShape(wb, 'rectangle', 0, 0);
225225+ const id = [...wb.shapes.keys()][0];
226226+ wb = setShapeStyle(wb, [id], { fill: '#ff0000' });
227227+ wb = setShapeStyle(wb, [id], { stroke: '#00ff00' });
228228+ const shape = wb.shapes.get(id)!;
229229+ expect(shape.style.fill).toBe('#ff0000');
230230+ expect(shape.style.stroke).toBe('#00ff00');
231231+ });
232232+233233+ it('applies to multiple shapes', () => {
234234+ let wb = noSnap(createWhiteboard());
235235+ wb = addShape(wb, 'rectangle', 0, 0);
236236+ wb = addShape(wb, 'ellipse', 100, 0);
237237+ const ids = [...wb.shapes.keys()];
238238+ wb = setShapeStyle(wb, ids, { fill: '#0000ff' });
239239+ for (const id of ids) {
240240+ expect(wb.shapes.get(id)!.style.fill).toBe('#0000ff');
241241+ }
242242+ });
243243+244244+ it('ignores non-existent shape IDs', () => {
245245+ let wb = noSnap(createWhiteboard());
246246+ wb = addShape(wb, 'rectangle', 0, 0);
247247+ const sizeBefore = wb.shapes.size;
248248+ wb = setShapeStyle(wb, ['fake-id'], { fill: 'red' });
249249+ expect(wb.shapes.size).toBe(sizeBefore);
250250+ });
251251+});
252252+253253+// ---------------------------------------------------------------------------
254254+// COVERAGE GAP: setShapeOpacity
255255+// ---------------------------------------------------------------------------
256256+257257+describe('setShapeOpacity', () => {
258258+ it('sets opacity on a shape', () => {
259259+ let wb = noSnap(createWhiteboard());
260260+ wb = addShape(wb, 'rectangle', 0, 0);
261261+ const id = [...wb.shapes.keys()][0];
262262+ wb = setShapeOpacity(wb, [id], 0.5);
263263+ expect(wb.shapes.get(id)!.opacity).toBe(0.5);
264264+ });
265265+266266+ it('clamps opacity to 0', () => {
267267+ let wb = noSnap(createWhiteboard());
268268+ wb = addShape(wb, 'rectangle', 0, 0);
269269+ const id = [...wb.shapes.keys()][0];
270270+ wb = setShapeOpacity(wb, [id], -0.5);
271271+ expect(wb.shapes.get(id)!.opacity).toBe(0);
272272+ });
273273+274274+ it('clamps opacity to 1', () => {
275275+ let wb = noSnap(createWhiteboard());
276276+ wb = addShape(wb, 'rectangle', 0, 0);
277277+ const id = [...wb.shapes.keys()][0];
278278+ wb = setShapeOpacity(wb, [id], 1.5);
279279+ expect(wb.shapes.get(id)!.opacity).toBe(1);
280280+ });
281281+282282+ it('sets opacity to exact boundaries', () => {
283283+ let wb = noSnap(createWhiteboard());
284284+ wb = addShape(wb, 'rectangle', 0, 0);
285285+ const id = [...wb.shapes.keys()][0];
286286+ wb = setShapeOpacity(wb, [id], 0);
287287+ expect(wb.shapes.get(id)!.opacity).toBe(0);
288288+ wb = setShapeOpacity(wb, [id], 1);
289289+ expect(wb.shapes.get(id)!.opacity).toBe(1);
290290+ });
291291+292292+ it('applies to multiple shapes', () => {
293293+ let wb = noSnap(createWhiteboard());
294294+ wb = addShape(wb, 'rectangle', 0, 0);
295295+ wb = addShape(wb, 'ellipse', 100, 0);
296296+ const ids = [...wb.shapes.keys()];
297297+ wb = setShapeOpacity(wb, ids, 0.3);
298298+ for (const id of ids) {
299299+ expect(wb.shapes.get(id)!.opacity).toBe(0.3);
300300+ }
301301+ });
302302+});
303303+304304+// ---------------------------------------------------------------------------
305305+// COVERAGE GAP: setShapeFontFamily / setShapeFontSize
306306+// ---------------------------------------------------------------------------
307307+308308+describe('setShapeFontFamily', () => {
309309+ it('sets font family on a shape', () => {
310310+ let wb = noSnap(createWhiteboard());
311311+ wb = addShape(wb, 'text', 0, 0);
312312+ const id = [...wb.shapes.keys()][0];
313313+ wb = setShapeFontFamily(wb, [id], 'monospace');
314314+ expect(wb.shapes.get(id)!.fontFamily).toBe('monospace');
315315+ });
316316+317317+ it('applies to multiple shapes', () => {
318318+ let wb = noSnap(createWhiteboard());
319319+ wb = addShape(wb, 'text', 0, 0);
320320+ wb = addShape(wb, 'rectangle', 100, 0);
321321+ const ids = [...wb.shapes.keys()];
322322+ wb = setShapeFontFamily(wb, ids, 'serif');
323323+ for (const id of ids) {
324324+ expect(wb.shapes.get(id)!.fontFamily).toBe('serif');
325325+ }
326326+ });
327327+});
328328+329329+describe('setShapeFontSize', () => {
330330+ it('sets font size on a shape', () => {
331331+ let wb = noSnap(createWhiteboard());
332332+ wb = addShape(wb, 'text', 0, 0);
333333+ const id = [...wb.shapes.keys()][0];
334334+ wb = setShapeFontSize(wb, [id], 24);
335335+ expect(wb.shapes.get(id)!.fontSize).toBe(24);
336336+ });
337337+338338+ it('enforces minimum font size of 8', () => {
339339+ let wb = noSnap(createWhiteboard());
340340+ wb = addShape(wb, 'text', 0, 0);
341341+ const id = [...wb.shapes.keys()][0];
342342+ wb = setShapeFontSize(wb, [id], 4);
343343+ expect(wb.shapes.get(id)!.fontSize).toBe(8);
344344+ });
345345+346346+ it('applies to multiple shapes', () => {
347347+ let wb = noSnap(createWhiteboard());
348348+ wb = addShape(wb, 'text', 0, 0);
349349+ wb = addShape(wb, 'text', 100, 0);
350350+ const ids = [...wb.shapes.keys()];
351351+ wb = setShapeFontSize(wb, ids, 36);
352352+ for (const id of ids) {
353353+ expect(wb.shapes.get(id)!.fontSize).toBe(36);
354354+ }
355355+ });
356356+});
357357+358358+// ---------------------------------------------------------------------------
359359+// COVERAGE GAP: duplicateShapes
360360+// ---------------------------------------------------------------------------
361361+362362+describe('duplicateShapes', () => {
363363+ it('duplicates a shape with offset', () => {
364364+ let wb = noSnap(createWhiteboard());
365365+ wb = addShape(wb, 'rectangle', 100, 200, 50, 50);
366366+ const ids = [...wb.shapes.keys()];
367367+ const { state, idMap } = duplicateShapes(wb, ids);
368368+ expect(state.shapes.size).toBe(2);
369369+ expect(idMap.size).toBe(1);
370370+ const newId = idMap.get(ids[0])!;
371371+ const original = state.shapes.get(ids[0])!;
372372+ const copy = state.shapes.get(newId)!;
373373+ expect(copy.x).toBe(original.x + 20);
374374+ expect(copy.y).toBe(original.y + 20);
375375+ expect(copy.kind).toBe('rectangle');
376376+ expect(copy.width).toBe(50);
377377+ expect(copy.height).toBe(50);
378378+ });
379379+380380+ it('uses custom offset', () => {
381381+ let wb = noSnap(createWhiteboard());
382382+ wb = addShape(wb, 'rectangle', 0, 0);
383383+ const ids = [...wb.shapes.keys()];
384384+ const { state, idMap } = duplicateShapes(wb, ids, 50, 50);
385385+ const newId = idMap.get(ids[0])!;
386386+ expect(state.shapes.get(newId)!.x).toBe(50);
387387+ expect(state.shapes.get(newId)!.y).toBe(50);
388388+ });
389389+390390+ it('preserves shape properties (style, opacity, rotation, label)', () => {
391391+ let wb = noSnap(createWhiteboard());
392392+ wb = addShape(wb, 'rectangle', 0, 0, 100, 80, 'Hello');
393393+ const id = [...wb.shapes.keys()][0];
394394+ const shapes = new Map(wb.shapes);
395395+ shapes.set(id, {
396396+ ...shapes.get(id)!,
397397+ style: { fill: '#ff0000' },
398398+ opacity: 0.7,
399399+ rotation: 45,
400400+ fontFamily: 'serif',
401401+ fontSize: 20,
402402+ });
403403+ wb = { ...wb, shapes };
404404+405405+ const { state, idMap } = duplicateShapes(wb, [id]);
406406+ const newId = idMap.get(id)!;
407407+ const copy = state.shapes.get(newId)!;
408408+ expect(copy.label).toBe('Hello');
409409+ expect(copy.style.fill).toBe('#ff0000');
410410+ expect(copy.opacity).toBe(0.7);
411411+ expect(copy.rotation).toBe(45);
412412+ expect(copy.fontFamily).toBe('serif');
413413+ expect(copy.fontSize).toBe(20);
414414+ });
415415+416416+ it('preserves freehand points', () => {
417417+ let wb = noSnap(createWhiteboard());
418418+ wb = addShape(wb, 'freehand', 0, 0, 100, 100);
419419+ const id = [...wb.shapes.keys()][0];
420420+ const shapes = new Map(wb.shapes);
421421+ shapes.set(id, { ...shapes.get(id)!, points: [{ x: 0, y: 0 }, { x: 50, y: 50 }] });
422422+ wb = { ...wb, shapes };
423423+424424+ const { state, idMap } = duplicateShapes(wb, [id]);
425425+ const newId = idMap.get(id)!;
426426+ expect(state.shapes.get(newId)!.points).toEqual([{ x: 0, y: 0 }, { x: 50, y: 50 }]);
427427+ });
428428+429429+ it('clears groupId on duplicated shapes', () => {
430430+ let wb = noSnap(createWhiteboard());
431431+ wb = addShape(wb, 'rectangle', 0, 0);
432432+ wb = addShape(wb, 'ellipse', 100, 0);
433433+ const ids = [...wb.shapes.keys()];
434434+ const { state: grouped } = groupShapes(wb, ids);
435435+ const { state, idMap } = duplicateShapes(grouped, ids);
436436+ for (const newId of idMap.values()) {
437437+ expect(state.shapes.get(newId)!.groupId).toBeUndefined();
438438+ }
439439+ });
440440+441441+ it('duplicates arrows between duplicated shapes', () => {
442442+ let wb = noSnap(createWhiteboard());
443443+ wb = addShape(wb, 'rectangle', 0, 0);
444444+ wb = addShape(wb, 'rectangle', 200, 0);
445445+ const ids = [...wb.shapes.keys()];
446446+ wb = addArrow(wb, { shapeId: ids[0], anchor: 'right' }, { shapeId: ids[1], anchor: 'left' });
447447+ expect(wb.arrows.size).toBe(1);
448448+449449+ const { state, idMap } = duplicateShapes(wb, ids);
450450+ expect(state.arrows.size).toBe(2);
451451+ // The new arrow should connect the new shapes
452452+ const newArrows = [...state.arrows.values()].filter(
453453+ a => !wb.arrows.has(a.id),
454454+ );
455455+ expect(newArrows).toHaveLength(1);
456456+ const newArrow = newArrows[0];
457457+ expect('shapeId' in newArrow.from && idMap.get(ids[0]) === (newArrow.from as any).shapeId).toBe(true);
458458+ expect('shapeId' in newArrow.to && idMap.get(ids[1]) === (newArrow.to as any).shapeId).toBe(true);
459459+ });
460460+461461+ it('does not duplicate arrows when only one endpoint is duplicated', () => {
462462+ let wb = noSnap(createWhiteboard());
463463+ wb = addShape(wb, 'rectangle', 0, 0);
464464+ wb = addShape(wb, 'rectangle', 200, 0);
465465+ const ids = [...wb.shapes.keys()];
466466+ wb = addArrow(wb, { shapeId: ids[0], anchor: 'right' }, { shapeId: ids[1], anchor: 'left' });
467467+468468+ // Only duplicate the first shape
469469+ const { state } = duplicateShapes(wb, [ids[0]]);
470470+ expect(state.arrows.size).toBe(1); // Original arrow only
471471+ });
472472+473473+ it('handles empty input gracefully', () => {
474474+ const wb = createWhiteboard();
475475+ const { state, idMap } = duplicateShapes(wb, []);
476476+ expect(state.shapes.size).toBe(0);
477477+ expect(idMap.size).toBe(0);
478478+ });
479479+});
480480+481481+// ---------------------------------------------------------------------------
482482+// COVERAGE GAP: applyResize -- untested handles (NE, SW, S, W)
483483+// ---------------------------------------------------------------------------
484484+485485+describe('applyResize additional handles', () => {
486486+ const base = { x: 100, y: 100, width: 80, height: 60 };
487487+488488+ it('resizes NE corner', () => {
489489+ const result = applyResize(base, 'ne', 20, -10);
490490+ expect(result.width).toBe(100); // width + 20
491491+ expect(result.height).toBe(70); // height - (-10) = 70
492492+ expect(result.x).toBe(100); // x unchanged
493493+ expect(result.y).toBe(90); // y + (-10) = 90
494494+ });
495495+496496+ it('resizes SW corner', () => {
497497+ const result = applyResize(base, 'sw', -10, 20);
498498+ expect(result.width).toBe(90); // width - (-10) = 90
499499+ expect(result.height).toBe(80); // height + 20
500500+ expect(result.x).toBe(90); // x + (-10) = 90
501501+ expect(result.y).toBe(100); // y unchanged
502502+ });
503503+504504+ it('resizes S edge', () => {
505505+ const result = applyResize(base, 's', 0, 15);
506506+ expect(result.width).toBe(80); // unchanged
507507+ expect(result.height).toBe(75); // height + 15
508508+ expect(result.x).toBe(100); // unchanged
509509+ expect(result.y).toBe(100); // unchanged
510510+ });
511511+512512+ it('resizes W edge', () => {
513513+ const result = applyResize(base, 'w', -20, 0);
514514+ expect(result.width).toBe(100); // width - (-20) = 100
515515+ expect(result.height).toBe(60); // unchanged
516516+ expect(result.x).toBe(80); // x + (-20) = 80
517517+ expect(result.y).toBe(100); // unchanged
518518+ });
519519+520520+ it('clamps NE resize to minimum size', () => {
521521+ const result = applyResize(base, 'ne', -200, 200);
522522+ expect(result.width).toBe(10);
523523+ expect(result.height).toBe(10);
524524+ });
525525+526526+ it('clamps SW resize to minimum size', () => {
527527+ const result = applyResize(base, 'sw', 200, -200);
528528+ expect(result.width).toBe(10);
529529+ expect(result.height).toBe(10);
530530+ });
531531+});
532532+533533+// ---------------------------------------------------------------------------
534534+// COVERAGE GAP: Export of additional shape types
535535+// ---------------------------------------------------------------------------
536536+537537+describe('exportToSVG additional shapes', () => {
538538+ it('renders triangle as polygon', () => {
539539+ const state = createWhiteboard();
540540+ state.shapes.set('t1', makeShape({ id: 't1', kind: 'triangle', x: 0, y: 0, width: 100, height: 80 }));
541541+ const svg = exportToSVG(state);
542542+ expect(svg).toContain('<polygon');
543543+ expect(svg).toContain('50,0'); // top center
544544+ });
545545+546546+ it('renders star as polygon with 10 points', () => {
547547+ const state = createWhiteboard();
548548+ state.shapes.set('s1', makeShape({ id: 's1', kind: 'star', x: 0, y: 0, width: 100, height: 100 }));
549549+ const svg = exportToSVG(state);
550550+ expect(svg).toContain('<polygon');
551551+ });
552552+553553+ it('renders hexagon as polygon with 6 points', () => {
554554+ const state = createWhiteboard();
555555+ state.shapes.set('h1', makeShape({ id: 'h1', kind: 'hexagon', x: 0, y: 0, width: 100, height: 100 }));
556556+ const svg = exportToSVG(state);
557557+ expect(svg).toContain('<polygon');
558558+ });
559559+560560+ it('renders cloud as path', () => {
561561+ const state = createWhiteboard();
562562+ state.shapes.set('c1', makeShape({ id: 'c1', kind: 'cloud', x: 0, y: 0, width: 120, height: 80 }));
563563+ const svg = exportToSVG(state);
564564+ expect(svg).toContain('<path');
565565+ });
566566+567567+ it('renders cylinder with ellipse and lines', () => {
568568+ const state = createWhiteboard();
569569+ state.shapes.set('cy1', makeShape({ id: 'cy1', kind: 'cylinder', x: 0, y: 0, width: 80, height: 120 }));
570570+ const svg = exportToSVG(state);
571571+ expect(svg).toContain('<ellipse');
572572+ expect(svg).toContain('<line');
573573+ });
574574+575575+ it('renders parallelogram as polygon', () => {
576576+ const state = createWhiteboard();
577577+ state.shapes.set('p1', makeShape({ id: 'p1', kind: 'parallelogram', x: 0, y: 0, width: 120, height: 80 }));
578578+ const svg = exportToSVG(state);
579579+ expect(svg).toContain('<polygon');
580580+ });
581581+582582+ it('renders note with folded corner path', () => {
583583+ const state = createWhiteboard();
584584+ state.shapes.set('n1', makeShape({ id: 'n1', kind: 'note', x: 0, y: 0, width: 100, height: 100 }));
585585+ const svg = exportToSVG(state);
586586+ expect(svg).toContain('<path');
587587+ // Note default fill should be #fef08a when no custom fill
588588+ expect(svg).toContain('#fef08a');
589589+ });
590590+591591+ it('renders line shape as polyline', () => {
592592+ const state = createWhiteboard();
593593+ state.shapes.set('l1', makeShape({
594594+ id: 'l1',
595595+ kind: 'line',
596596+ x: 10,
597597+ y: 20,
598598+ width: 100,
599599+ height: 80,
600600+ points: [{ x: 0, y: 0 }, { x: 50, y: 40 }, { x: 100, y: 80 }],
601601+ }));
602602+ const svg = exportToSVG(state);
603603+ expect(svg).toContain('<polyline');
604604+ });
605605+606606+ it('renders line shape with offset points', () => {
607607+ const state = createWhiteboard();
608608+ state.shapes.set('l1', makeShape({
609609+ id: 'l1',
610610+ kind: 'line',
611611+ x: 10,
612612+ y: 20,
613613+ width: 100,
614614+ height: 80,
615615+ points: [{ x: 0, y: 0 }, { x: 100, y: 80 }],
616616+ }));
617617+ const svg = exportToSVG(state);
618618+ // Line export should offset points by shape.x, shape.y: "10,20 110,100"
619619+ expect(svg).toContain('10,20');
620620+ expect(svg).toContain('110,100');
621621+ });
622622+});
623623+624624+// ---------------------------------------------------------------------------
625625+// COVERAGE GAP: Export with opacity and rotation
626626+// ---------------------------------------------------------------------------
627627+628628+describe('exportToSVG with opacity', () => {
629629+ it('does not include opacity in SVG output (opacity is a canvas-only feature)', () => {
630630+ // This test documents the current behavior: exportToSVG does NOT render
631631+ // opacity. If opacity support is added to export, this test should be
632632+ // updated to verify it works.
633633+ const state = createWhiteboard();
634634+ state.shapes.set('s1', makeShape({
635635+ id: 's1', kind: 'rectangle', x: 0, y: 0, opacity: 0.5,
636636+ }));
637637+ const svg = exportToSVG(state);
638638+ // Currently no opacity attribute in export -- this is a coverage gap
639639+ // export should ideally include opacity="0.5"
640640+ expect(svg).toContain('<rect');
641641+ });
642642+});
643643+644644+describe('exportToSVG with font properties', () => {
645645+ it('renders text shape with default font', () => {
646646+ const state = createWhiteboard();
647647+ state.shapes.set('t1', makeShape({
648648+ id: 't1', kind: 'text', x: 0, y: 0, label: 'Hello',
649649+ }));
650650+ const svg = exportToSVG(state);
651651+ expect(svg).toContain('Hello');
652652+ expect(svg).toContain('font-family=');
653653+ });
654654+});
655655+656656+// ---------------------------------------------------------------------------
657657+// COVERAGE GAP: History with all optional shape properties
658658+// ---------------------------------------------------------------------------
659659+660660+describe('History round-trip with optional shape properties', () => {
661661+ it('preserves points through undo/redo', () => {
662662+ const history = new History();
663663+ let wb = noSnap(createWhiteboard());
664664+ wb = addShape(wb, 'freehand', 0, 0, 100, 100);
665665+ const id = [...wb.shapes.keys()][0];
666666+ const shapes = new Map(wb.shapes);
667667+ shapes.set(id, { ...shapes.get(id)!, points: [{ x: 0, y: 0 }, { x: 50, y: 50 }] });
668668+ wb = { ...wb, shapes };
669669+ history.push(wb);
670670+671671+ const wb2 = addShape(wb, 'rectangle', 200, 200);
672672+ history.push(wb2);
673673+674674+ const undone = history.undo()!;
675675+ expect(undone.shapes.get(id)!.points).toEqual([{ x: 0, y: 0 }, { x: 50, y: 50 }]);
676676+ });
677677+678678+ it('preserves groupId through undo/redo', () => {
679679+ const history = new History();
680680+ let wb = noSnap(createWhiteboard());
681681+ wb = addShape(wb, 'rectangle', 0, 0);
682682+ wb = addShape(wb, 'ellipse', 100, 0);
683683+ const ids = [...wb.shapes.keys()];
684684+ const { state, groupId } = groupShapes(wb, ids);
685685+ history.push(state);
686686+687687+ const s2 = addShape(state, 'diamond', 200, 0);
688688+ history.push(s2);
689689+690690+ const undone = history.undo()!;
691691+ expect(undone.shapes.get(ids[0])!.groupId).toBe(groupId);
692692+ });
693693+694694+ it('preserves fontFamily and fontSize through undo/redo', () => {
695695+ const history = new History();
696696+ let wb = noSnap(createWhiteboard());
697697+ wb = addShape(wb, 'text', 0, 0, 100, 50, 'Test');
698698+ const id = [...wb.shapes.keys()][0];
699699+ wb = setShapeFontFamily(wb, [id], 'monospace');
700700+ wb = setShapeFontSize(wb, [id], 24);
701701+ history.push(wb);
702702+703703+ const wb2 = addShape(wb, 'rectangle', 200, 200);
704704+ history.push(wb2);
705705+706706+ const undone = history.undo()!;
707707+ expect(undone.shapes.get(id)!.fontFamily).toBe('monospace');
708708+ expect(undone.shapes.get(id)!.fontSize).toBe(24);
709709+ });
710710+});
711711+712712+// ---------------------------------------------------------------------------
713713+// COVERAGE GAP: hitTestShape edge cases
714714+// ---------------------------------------------------------------------------
715715+716716+describe('hitTestShape edge cases', () => {
717717+ it('handles zero-width shape (line)', () => {
718718+ const shape = makeShape({ id: 's1', kind: 'rectangle', x: 50, y: 50, width: 0, height: 100 });
719719+ // Point on the zero-width line
720720+ expect(hitTestShape(shape, 50, 75)).toBe(true);
721721+ // Point off the line
722722+ expect(hitTestShape(shape, 51, 75)).toBe(false);
723723+ });
724724+725725+ it('handles zero-height shape', () => {
726726+ const shape = makeShape({ id: 's1', kind: 'rectangle', x: 50, y: 50, width: 100, height: 0 });
727727+ expect(hitTestShape(shape, 75, 50)).toBe(true);
728728+ expect(hitTestShape(shape, 75, 51)).toBe(false);
729729+ });
730730+731731+ it('handles zero-dimension shape (point)', () => {
732732+ const shape = makeShape({ id: 's1', kind: 'rectangle', x: 50, y: 50, width: 0, height: 0 });
733733+ expect(hitTestShape(shape, 50, 50)).toBe(true);
734734+ expect(hitTestShape(shape, 50, 51)).toBe(false);
735735+ });
736736+});
737737+738738+// ---------------------------------------------------------------------------
739739+// COVERAGE GAP: snapPoint edge cases
740740+// ---------------------------------------------------------------------------
741741+742742+describe('snapPoint edge cases', () => {
743743+ it('handles grid size of 1 (identity)', () => {
744744+ expect(snapPoint(13.7, 27.3, 1)).toEqual({ x: 14, y: 27 });
745745+ });
746746+747747+ it('handles very large grid size', () => {
748748+ expect(snapPoint(50, 50, 1000)).toEqual({ x: 0, y: 0 });
749749+ expect(snapPoint(600, 600, 1000)).toEqual({ x: 1000, y: 1000 });
750750+ });
751751+752752+ it('handles negative coordinates', () => {
753753+ expect(snapPoint(-13, -27, 20)).toEqual({ x: -20, y: -20 });
754754+ });
755755+});
756756+757757+// ---------------------------------------------------------------------------
758758+// BUG: rotateShape produces negative rotation with negative angles
759759+// This is arguably a bug -- negative rotation values are semantically
760760+// odd and could cause issues in SVG transforms.
761761+// ---------------------------------------------------------------------------
762762+763763+describe('BUG: rotateShape negative angle produces negative rotation', () => {
764764+ it('should normalize negative rotation to 0-360 range', () => {
765765+ let wb = noSnap(createWhiteboard());
766766+ wb = addShape(wb, 'rectangle', 0, 0);
767767+ const id = [...wb.shapes.keys()][0];
768768+ wb = rotateShape(wb, id, -45);
769769+ // JavaScript's % operator returns negative for negative operands:
770770+ // (-45) % 360 = -45, not 315
771771+ // This test FAILS because rotateShape returns -45 instead of 315
772772+ expect(wb.shapes.get(id)!.rotation).toBe(315);
773773+ });
774774+});
775775+776776+// ---------------------------------------------------------------------------
777777+// BUG: Export does not render opacity attribute
778778+// ---------------------------------------------------------------------------
779779+780780+describe('BUG: exportToSVG should include opacity on shapes', () => {
781781+ it('should render opacity attribute on shapes with opacity < 1', () => {
782782+ const state = createWhiteboard();
783783+ state.shapes.set('s1', makeShape({
784784+ id: 's1', kind: 'rectangle', x: 0, y: 0, opacity: 0.5,
785785+ }));
786786+ const svg = exportToSVG(state);
787787+ // This test FAILS because exportToSVG does not render opacity
788788+ expect(svg).toContain('opacity="0.5"');
789789+ });
790790+});
791791+792792+// ---------------------------------------------------------------------------
793793+// BUG: Export does not render custom font properties
794794+// ---------------------------------------------------------------------------
795795+796796+describe('BUG: exportToSVG should use shape font properties', () => {
797797+ it('should render shape-specific fontSize in label text', () => {
798798+ const state = createWhiteboard();
799799+ state.shapes.set('s1', makeShape({
800800+ id: 's1', kind: 'rectangle', x: 0, y: 0, label: 'Big Text',
801801+ fontSize: 36, fontFamily: 'monospace',
802802+ }));
803803+ const svg = exportToSVG(state);
804804+ // This test FAILS because export uses DEFAULT_FONT_SIZE (14) and
805805+ // DEFAULT_FONT_FAMILY (system-ui), ignoring shape.fontSize and shape.fontFamily
806806+ expect(svg).toContain('font-size="36"');
807807+ expect(svg).toContain('font-family="monospace"');
808808+ });
809809+});
810810+811811+// ---------------------------------------------------------------------------
812812+// BUG: Export line shape viewBox computation uses local points
813813+// ---------------------------------------------------------------------------
814814+815815+describe('BUG: Export computeBounds should handle line shape points correctly', () => {
816816+ it('line shape points should not shrink the bounding box', () => {
817817+ // Line shapes store points in local coords (0-based, relative to shape.x/y)
818818+ // But computeBounds in export.ts iterates shape.points and uses raw values,
819819+ // which are local coords like {x:0, y:0} -- these can pull minX/minY toward 0
820820+ // even when the shape is at a distant position.
821821+ const state = createWhiteboard();
822822+ state.shapes.set('l1', makeShape({
823823+ id: 'l1',
824824+ kind: 'line',
825825+ x: 500,
826826+ y: 500,
827827+ width: 100,
828828+ height: 80,
829829+ points: [{ x: 0, y: 0 }, { x: 100, y: 80 }],
830830+ }));
831831+ const svg = exportToSVG(state);
832832+ const match = svg.match(/viewBox="([^"]*)"/);
833833+ expect(match).not.toBeNull();
834834+ const [vx, vy] = match![1].split(' ').map(Number);
835835+ // ViewBox should start near 500,500 (with padding), NOT near 0,0
836836+ // This test FAILS because computeBounds uses raw point coords {x:0,y:0}
837837+ // which pulls minX/minY to 0
838838+ expect(vx).toBeGreaterThan(400);
839839+ expect(vy).toBeGreaterThan(400);
840840+ });
841841+});
+145
tests/diagrams-qa-report.md
···11+# Diagrams QA Report
22+33+Date: 2026-04-04
44+Reviewed by: QA Agent
55+66+## Critical Bugs (broken functionality)
77+88+### BUG-1: Double-click inline text editing is broken -- canvas mousedown intercepts textarea clicks
99+1010+**Symptom:** User double-clicks a shape to edit its text. The textarea appears momentarily, but clicking inside it to type triggers the canvas mousedown handler, which clears selection, starts marquee, calls render(), and destroys the textarea via `layer.innerHTML = ''`.
1111+1212+**Root cause:** `src/diagrams/main.ts:902` -- the canvas mousedown handler has NO early return for `editingShapeId`. The textarea at line 834-840 has `keydown` stopPropagation but NO `mousedown` stopPropagation. When the user clicks inside the textarea, the event bubbles to the canvas, enters the select tool path (line 927), calls `shapeAtPoint` which finds the shape or empty space, triggers either drag or marquee, then `render()` at line 997 does `layer.innerHTML = ''` destroying the foreignObject+textarea.
1313+1414+**Fix needed:** Add `if (editingShapeId) return;` at the top of the mousedown handler, or add `textarea.addEventListener('mousedown', e => e.stopPropagation())` in startTextEditing.
1515+1616+### BUG-2: Eraser drag-to-erase does not push undo history for subsequent shapes
1717+1818+**Symptom:** Dragging the eraser across multiple shapes only records the first deletion in history. Undo only restores the first shape.
1919+2020+**Root cause:** `src/diagrams/main.ts:1042-1051` -- the mousemove eraser path calls `removeShape`, `syncToYjs`, and `render` but never calls `pushHistory()`. Only the mousedown handler at line 918 pushes history.
2121+2222+**Fix needed:** Track whether history was pushed for the current erase stroke, or batch all erasures.
2323+2424+### BUG-3: Opacity slider changes cannot be undone
2525+2626+**Symptom:** Changing opacity via the style panel is not undoable.
2727+2828+**Root cause:** `src/diagrams/main.ts:1489-1496` -- the opacity input handler does NOT call `pushHistory()`, unlike every other style panel handler (fill:1462, stroke:1468, strokeWidth:1475, strokeStyle:1483, fontFamily:1499, fontSize:1506).
2929+3030+**Fix needed:** Add `pushHistory()` before line 1492.
3131+3232+### BUG-4: Line tool ghost state persists when switching tools mid-draw
3333+3434+**Symptom:** Drawing a multi-point line, then pressing a tool key (V, R, etc.) switches tools but leaves `isDrawingLine=true` and `linePoints` populated. The line preview remains on screen. Only Escape properly cleans up.
3535+3636+**Root cause:** `src/diagrams/main.ts:1878-1888` -- tool switch keys don't reset active drawing state.
3737+3838+**Fix needed:** Add drawing state cleanup before each tool switch.
3939+4040+## Medium Issues (degraded UX)
4141+4242+### MED-1: Style panel and Properties panel overlap visually
4343+4444+Both panels use `position: absolute; top: var(--space-sm); right: var(--space-sm)` and render on top of each other when a single shape is selected.
4545+4646+**Root cause:** `src/css/app.css:8121-8131` and `src/css/app.css:8244-8255`
4747+4848+### MED-2: Hand tool and Freehand tool share the same icon
4949+5050+Users cannot visually distinguish between them in the toolbar. Both use `✍`.
5151+5252+**Root cause:** `src/diagrams/index.html:40` and `src/diagrams/index.html:58`
5353+5454+### MED-3: Group and Ungroup buttons share the same icon
5555+5656+Both use `☐` (ballot box).
5757+5858+**Root cause:** `src/diagrams/index.html:79-80`
5959+6060+### MED-4: Focus mode exit does not restore panel visibility
6161+6262+`toggleFocusMode()` hides style/props panels but exiting focus mode doesn't call `render()` to restore them.
6363+6464+**Root cause:** `src/diagrams/main.ts:1699-1708`
6565+6666+### MED-5: Double-click pushes two spurious undo history entries
6767+6868+Every double-click generates two zero-distance-drag mousedown/mouseup cycles, each pushing a history entry.
6969+7070+**Root cause:** `src/diagrams/main.ts:1168-1169` -- no zero-distance-drag check.
7171+7272+### MED-6: Text/Note shapes don't auto-enter edit mode after creation
7373+7474+User must double-click after placing a text/note shape to edit its content.
7575+7676+**Root cause:** `src/diagrams/main.ts:1191-1218`
7777+7878+### MED-7: Line tool shortcut 'L' missing from shortcuts dialog
7979+8080+'L' activates line tool (line 1883) but is absent from the shortcuts dialog (lines 1750-1781).
8181+8282+**Root cause:** `src/diagrams/main.ts:1750-1781`
8383+8484+### MED-8: Shape creation does not auto-switch back to select tool
8585+8686+After drawing a shape, the tool remains set to the shape tool.
8787+8888+**Root cause:** `src/diagrams/main.ts:1191-1218`
8989+9090+### MED-9: Context menu doesn't close on Escape
9191+9292+Pressing Escape resets tools and selection but doesn't close the open context menu.
9393+9494+**Root cause:** `src/diagrams/main.ts:1857` -- no `hideContextMenu()` call.
9595+9696+### MED-10: Export viewBox computation for line shapes has offset error
9797+9898+Export `computeBounds` iterates `shape.points` using raw local coordinates, but line shapes store points in local space (0-based). The bounds function adds these local coords directly to the shape extent computation, which can produce incorrect viewBox bounds.
9999+100100+**Root cause:** `src/diagrams/export.ts:94-102`
101101+102102+## Minor Issues (polish)
103103+104104+### MIN-1: Default canvas-area cursor is crosshair even in select mode
105105+106106+`src/css/app.css:8103` sets `cursor: crosshair`. updateCursor overrides on mousemove but initial load shows crosshair.
107107+108108+### MIN-2: Freehand strokes with 1-2 points are silently discarded
109109+110110+`src/diagrams/main.ts:1268` requires `freehandPoints.length > 2`. Short strokes vanish with no feedback.
111111+112112+### MIN-3: Grid pattern doesn't respond to pan/zoom
113113+114114+Static SVG pattern at `src/diagrams/index.html:107-109` doesn't track viewport transform.
115115+116116+### MIN-4: Arrow marker color mismatch between canvas and export
117117+118118+Canvas: `var(--color-text)` (line 459). Export: `#000000` (export.ts:272). Dark theme arrows look different in export.
119119+120120+### MIN-5: No user feedback when crypto initialization fails
121121+122122+`src/diagrams/main.ts:228` silently catches failures. No indication that E2EE is inactive.
123123+124124+## Missing Test Coverage
125125+126126+### Functions with zero test coverage
127127+128128+1. **groupShapes / ungroupShapes / getGroupMembers** -- core grouping
129129+2. **rotateShape / setShapeRotation** -- rotation
130130+3. **setShapeStyle** -- style management
131131+4. **setShapeOpacity** -- opacity clamping
132132+5. **setShapeFontFamily / setShapeFontSize** -- font properties
133133+6. **duplicateShapes** -- copy/paste/alt-drag foundation
134134+7. **Export of**: triangle, star, hexagon, cloud, cylinder, parallelogram, note, line shapes
135135+8. **applyResize** for NE, SW, S, W handles
136136+137137+### Edge cases not tested
138138+139139+1. duplicateShapes preserving points, style, opacity, rotation, font
140140+2. duplicateShapes arrow duplication between copies
141141+3. rotateShape with negative angles, accumulation past 360
142142+4. setShapeOpacity at boundary values (0, 1, negative, >1)
143143+5. groupShapes with fewer than 2 shapes
144144+6. hitTestShape with zero-dimension shapes
145145+7. History round-trip for shapes with optional properties