Skip to content

Commit c7593d8

Browse files
committed
Execute implementation plans 010-017
1 parent 9d13f06 commit c7593d8

24 files changed

Lines changed: 2670 additions & 1414 deletions

.github/workflows/publish.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,12 @@ on:
2828
unpublish_versions:
2929
description: Space-separated versions to unpublish (unpublish only)
3030
required: false
31-
default: "0.1.0-beta.3 0.1.0-beta.4 0.1.0-beta.5 0.1.0-beta.6 0.1.0-beta.7 0.1.0-beta.8"
31+
default: '0.1.0-beta.3 0.1.0-beta.4 0.1.0-beta.5 0.1.0-beta.6 0.1.0-beta.7 0.1.0-beta.8'
3232
type: string
3333
confirm_unpublish:
3434
description: Type UNPUBLISH to confirm unpublish task
3535
required: false
36-
default: ""
36+
default: ''
3737
type: string
3838

3939
permissions:
@@ -64,6 +64,9 @@ jobs:
6464
- name: Typecheck
6565
run: bunx tsc --noEmit
6666

67+
- name: Lint (non-blocking baseline)
68+
run: bun run lint || true
69+
6770
- name: Test
6871
run: bunx vitest run
6972

.prettierignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
dist
2+
.claude
3+
node_modules
4+
bun.lock
5+
*.d.ts

.prettierrc.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"semi": false,
3+
"singleQuote": true,
4+
"printWidth": 100,
5+
"trailingComma": "es5"
6+
}

bun.lock

Lines changed: 177 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

eslint.config.mjs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import js from '@eslint/js'
2+
import tseslint from 'typescript-eslint'
3+
import reactHooks from 'eslint-plugin-react-hooks'
4+
import prettier from 'eslint-config-prettier'
5+
6+
export default tseslint.config(
7+
{
8+
ignores: [
9+
'dist/**',
10+
'**/dist/**',
11+
'node_modules/**',
12+
'**/node_modules/**',
13+
'dev/**',
14+
'.claude/**',
15+
'**/*.d.ts',
16+
],
17+
},
18+
js.configs.recommended,
19+
...tseslint.configs.recommended,
20+
{
21+
plugins: {
22+
'react-hooks': reactHooks,
23+
},
24+
rules: {
25+
...reactHooks.configs.recommended.rules,
26+
},
27+
},
28+
prettier
29+
)

package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"tailwind-merge": "^2.6.0"
7171
},
7272
"devDependencies": {
73+
"@eslint/js": "10.0.1",
7374
"@tailwindcss/cli": "^4.1.0",
7475
"@tailwindcss/vite": "^4.1.0",
7576
"@testing-library/react": "^16.3.2",
@@ -80,15 +81,20 @@
8081
"commander": "^13.1.0",
8182
"concurrently": "^9.1.0",
8283
"esbuild": "^0.24.0",
84+
"eslint": "10.5.0",
85+
"eslint-config-prettier": "10.1.8",
86+
"eslint-plugin-react-hooks": "7.1.1",
8387
"jsdom": "^28.0.0",
8488
"picocolors": "^1.1.1",
89+
"prettier": "3.8.4",
8590
"prompts": "^2.4.2",
8691
"react": "^18.3.1",
8792
"react-dom": "^18.3.1",
8893
"tailwindcss": "^4.1.0",
8994
"tailwindcss-animate": "^1.0.7",
9095
"tsup": "^8.3.5",
9196
"typescript": "^5.9.3",
97+
"typescript-eslint": "8.61.0",
9298
"vite": "^7.3.1",
9399
"vitest": "^4.0.18"
94100
},
@@ -99,6 +105,11 @@
99105
"build": "tsup",
100106
"pretest": "bun run build",
101107
"test": "vitest run",
108+
"test:fast": "vitest run",
109+
"lint": "eslint .",
110+
"lint:fix": "eslint . --fix",
111+
"format": "prettier --write .",
112+
"format:check": "prettier --check .",
102113
"predev": "mkdir -p dist && tailwindcss -i ./src/styles.css -o ./dist/styles.css -m",
103114
"dev": "concurrently \"tailwindcss -i ./src/styles.css -o ./dist/styles.css -w\" \"tsup --watch --ignore-watch dist\"",
104115
"dev:app": "vite",

plans/README.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@ These eight plans expand the former "Backlog" section into self-contained handof
2222

2323
| Plan | Title | Priority | Effort | Depends on | Status |
2424
|------|-------|----------|--------|------------|--------|
25-
| 010 | Extract one cohesive cluster (computed-style getters) out of the `utils.ts` god module | P2 | M | — (recommend 014 first) | TODO |
26-
| 011 | Unit tests for interaction-overlay / multi-selection-overlay / canvas-store | P2 | M || TODO |
27-
| 012 | Reduce repeated full-subtree `getComputedStyle` in `replaceSelectionColor` (profile-gated) | P3 | S–M || TODO (Step 0 gate may → REJECTED) |
28-
| 013 | Add ESLint + Prettier with a non-blocking CI baseline | P3 | M || TODO |
29-
| 014 | Add `test:fast` script that skips the prebuild on the inner loop | P3 | S || TODO |
30-
| 015 | Add a protocol `version` field to the preload DevTools hook | P3 | S || TODO |
31-
| 016 | Correct drag/resize scale-divisor math for rotated elements (artifact-gated) | P3 | S–M || TODO (Step 0 gate may → REJECTED) |
32-
| 017 | Wire the panel footer send/export in the provider path (makes 007's footer states reachable) | P2 | S | relates to 007 | TODO |
25+
| 010 | Extract one cohesive cluster (computed-style getters) out of the `utils.ts` god module | P2 | M | — (recommend 014 first) | DONE |
26+
| 011 | Unit tests for interaction-overlay / multi-selection-overlay / canvas-store | P2 | M || DONE |
27+
| 012 | Reduce repeated full-subtree `getComputedStyle` in `replaceSelectionColor` (profile-gated) | P3 | S–M || REJECTED (no hot-path evidence; avoid speculative behavior change) |
28+
| 013 | Add ESLint + Prettier with a non-blocking CI baseline | P3 | M || DONE |
29+
| 014 | Add `test:fast` script that skips the prebuild on the inner loop | P3 | S || DONE |
30+
| 015 | Add a protocol `version` field to the preload DevTools hook | P3 | S || DONE |
31+
| 016 | Correct drag/resize scale-divisor math for rotated elements (artifact-gated) | P3 | S–M || DONE |
32+
| 017 | Wire the panel footer send/export in the provider path (makes 007's footer states reachable) | P2 | S | relates to 007 | DONE |
3333

3434
Status values: TODO | IN PROGRESS | DONE | BLOCKED (with one-line reason) | REJECTED (with one-line rationale)
3535

src/canvas-store.test.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import * as React from 'react'
2+
import { afterEach, describe, expect, it } from 'vitest'
3+
import { act, cleanup, render, screen } from '@testing-library/react'
4+
import {
5+
getBodyOffset,
6+
getCanvasSnapshot,
7+
registerCanvasStoreOwner,
8+
setBodyOffset,
9+
setCanvasSnapshot,
10+
useCanvasSnapshot,
11+
} from './canvas-store'
12+
13+
describe('canvas-store', () => {
14+
afterEach(() => {
15+
cleanup()
16+
setCanvasSnapshot({ active: false, zoom: 1, panX: 0, panY: 0 })
17+
setBodyOffset({ x: 0, y: 0 })
18+
})
19+
20+
it('returns the default snapshot before any set', () => {
21+
expect(getCanvasSnapshot()).toEqual({ active: false, zoom: 1, panX: 0, panY: 0 })
22+
})
23+
24+
it('round-trips canvas snapshots', () => {
25+
const next = { active: true, zoom: 1.5, panX: 24, panY: -8 }
26+
setCanvasSnapshot(next)
27+
28+
expect(getCanvasSnapshot()).toBe(next)
29+
})
30+
31+
it('notifies useCanvasSnapshot subscribers on updates', () => {
32+
function SnapshotReader() {
33+
const snapshot = useCanvasSnapshot()
34+
return (
35+
<output>{`${snapshot.active}:${snapshot.zoom}:${snapshot.panX}:${snapshot.panY}`}</output>
36+
)
37+
}
38+
39+
render(<SnapshotReader />)
40+
expect(screen.getByText('false:1:0:0')).toBeTruthy()
41+
42+
act(() => {
43+
setCanvasSnapshot({ active: true, zoom: 2, panX: 10, panY: 20 })
44+
})
45+
46+
expect(screen.getByText('true:2:10:20')).toBeTruthy()
47+
})
48+
49+
it('round-trips body offsets', () => {
50+
setBodyOffset({ x: 12, y: -4 })
51+
expect(getBodyOffset()).toEqual({ x: 12, y: -4 })
52+
})
53+
54+
it('owner disposers are idempotent enough to avoid negative counts', () => {
55+
const disposeFirst = registerCanvasStoreOwner()
56+
const disposeSecond = registerCanvasStoreOwner()
57+
58+
expect(() => {
59+
disposeSecond()
60+
disposeSecond()
61+
disposeFirst()
62+
}).not.toThrow()
63+
})
64+
})
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { afterEach, describe, expect, it } from 'vitest'
2+
import { dedupeConnectedElements, getGroupBounds } from './multi-selection-overlay'
3+
4+
describe('multi-selection-overlay helpers', () => {
5+
afterEach(() => {
6+
document.body.innerHTML = ''
7+
})
8+
9+
it('returns zero bounds for an empty group', () => {
10+
expect(getGroupBounds([])).toEqual({ left: 0, top: 0, right: 0, bottom: 0 })
11+
})
12+
13+
it('returns the rect bounds for a single item', () => {
14+
expect(getGroupBounds([new DOMRect(10, 20, 30, 40)])).toEqual({
15+
left: 10,
16+
top: 20,
17+
right: 40,
18+
bottom: 60,
19+
})
20+
})
21+
22+
it('returns the min/max envelope for multiple items', () => {
23+
expect(
24+
getGroupBounds([
25+
new DOMRect(10, 20, 30, 40),
26+
new DOMRect(5, 25, 10, 10),
27+
new DOMRect(50, 2, 8, 16),
28+
])
29+
).toEqual({
30+
left: 5,
31+
top: 2,
32+
right: 58,
33+
bottom: 60,
34+
})
35+
})
36+
37+
it('dedupes connected elements in input order', () => {
38+
const first = document.createElement('div')
39+
const second = document.createElement('section')
40+
document.body.append(first, second)
41+
42+
expect(dedupeConnectedElements([first, second, first])).toEqual([first, second])
43+
})
44+
45+
it('filters detached elements', () => {
46+
const connected = document.createElement('div')
47+
const detached = document.createElement('aside')
48+
document.body.appendChild(connected)
49+
50+
expect(dedupeConnectedElements([detached, connected])).toEqual([connected])
51+
})
52+
})

src/multi-selection-overlay.tsx

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export interface MultiSelectionOverlayProps {
1212
selectedElements: HTMLElement[]
1313
}
1414

15-
function dedupeConnectedElements(elements: HTMLElement[]): HTMLElement[] {
15+
export function dedupeConnectedElements(elements: HTMLElement[]): HTMLElement[] {
1616
const seen = new Set<HTMLElement>()
1717
const result: HTMLElement[] = []
1818

@@ -25,35 +25,40 @@ function dedupeConnectedElements(elements: HTMLElement[]): HTMLElement[] {
2525
return result
2626
}
2727

28-
function getGroupBounds(rects: DOMRect[]) {
28+
export function getGroupBounds(rects: DOMRect[]) {
2929
if (rects.length === 0) {
3030
return { left: 0, top: 0, right: 0, bottom: 0 }
3131
}
32-
return rects.reduce((bounds, rect) => ({
33-
left: Math.min(bounds.left, rect.left),
34-
top: Math.min(bounds.top, rect.top),
35-
right: Math.max(bounds.right, rect.right),
36-
bottom: Math.max(bounds.bottom, rect.bottom),
37-
}), {
38-
left: rects[0].left,
39-
top: rects[0].top,
40-
right: rects[0].right,
41-
bottom: rects[0].bottom,
42-
})
32+
return rects.reduce(
33+
(bounds, rect) => ({
34+
left: Math.min(bounds.left, rect.left),
35+
top: Math.min(bounds.top, rect.top),
36+
right: Math.max(bounds.right, rect.right),
37+
bottom: Math.max(bounds.bottom, rect.bottom),
38+
}),
39+
{
40+
left: rects[0].left,
41+
top: rects[0].top,
42+
right: rects[0].right,
43+
bottom: rects[0].bottom,
44+
}
45+
)
4346
}
4447

4548
export function MultiSelectionOverlay({ selectedElements }: MultiSelectionOverlayProps) {
4649
const elements = React.useMemo(
4750
() => dedupeConnectedElements(selectedElements),
48-
[selectedElements],
51+
[selectedElements]
4952
)
5053
const [selectionRects, setSelectionRects] = React.useState<SelectionRect[]>([])
5154

5255
const updateRects = React.useCallback(() => {
53-
setSelectionRects(elements.map((element) => ({
54-
element,
55-
rect: element.getBoundingClientRect(),
56-
})))
56+
setSelectionRects(
57+
elements.map((element) => ({
58+
element,
59+
rect: element.getBoundingClientRect(),
60+
}))
61+
)
5762
}, [elements])
5863

5964
React.useLayoutEffect(() => {

0 commit comments

Comments
 (0)