@antigenic-oss/paint 0.2.7 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,28 +1,35 @@
1
1
  # pAInt
2
2
 
3
- [![License](https://img.shields.io/github/license/Antigenic-OSS/pAInt)](https://github.com/Antigenic-OSS/pAInt/blob/main/LICENSE)
4
- [![Stars](https://img.shields.io/github/stars/Antigenic-OSS/pAInt?style=social)](https://github.com/Antigenic-OSS/pAInt/stargazers)
5
- [![Issues](https://img.shields.io/github/issues/Antigenic-OSS/pAInt)](https://github.com/Antigenic-OSS/pAInt/issues)
6
- [![npm version](https://img.shields.io/npm/v/@antigenic-oss/paint)](https://www.npmjs.com/package/@antigenic-oss/paint)
7
- [![npm downloads](https://img.shields.io/npm/dm/@antigenic-oss/paint)](https://www.npmjs.com/package/@antigenic-oss/paint)
8
- [![Bun](https://img.shields.io/badge/local%20dev-Bun-000000)](https://bun.sh)
9
- [![Node.js](https://img.shields.io/badge/runtime-Node.js-5FA04E)](https://nodejs.org)
10
- [![Next.js](https://img.shields.io/badge/framework-Next.js-000000)](https://nextjs.org)
11
- [![TypeScript](https://img.shields.io/badge/language-TypeScript-3178C6)](https://www.typescriptlang.org)
3
+ <p>
4
+ <a href="https://www.npmjs.com/package/@antigenic-oss/paint"><img alt="npm version" src="https://img.shields.io/npm/v/@antigenic-oss/paint" /></a>
5
+ <a href="https://www.npmjs.com/package/@antigenic-oss/paint"><img alt="npm downloads" src="https://img.shields.io/npm/dm/@antigenic-oss/paint" /></a>
6
+ <a href="https://github.com/Antigenic-OSS/pAInt/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/Antigenic-OSS/pAInt" /></a>
7
+ <a href="https://github.com/Antigenic-OSS/pAInt/issues"><img alt="Issues" src="https://img.shields.io/github/issues/Antigenic-OSS/pAInt" /></a>
8
+ <br />
9
+ <a href="https://github.com/Antigenic-OSS/pAInt/stargazers"><img alt="Stars" src="https://img.shields.io/github/stars/Antigenic-OSS/pAInt?style=social" /></a>
10
+ <a href="https://bun.sh"><img alt="Bun" src="https://img.shields.io/badge/local%20dev-Bun-000000" /></a>
11
+ <a href="https://nodejs.org"><img alt="Node.js" src="https://img.shields.io/badge/runtime-Node.js-5FA04E" /></a>
12
+ <a href="https://nextjs.org"><img alt="Next.js" src="https://img.shields.io/badge/framework-Next.js-000000" /></a>
13
+ <a href="https://www.typescriptlang.org"><img alt="TypeScript" src="https://img.shields.io/badge/language-TypeScript-3178C6" /></a>
14
+ </p>
12
15
 
13
16
  pAInt is a visual editor for localhost web projects. It helps you inspect elements, edit styles, manage CSS variables, and export changelogs for [Claude Code](https://claude.ai/claude-code).
14
17
 
18
+ Built by [Antigenic](https://antigenic.org).
19
+
15
20
  ## Table of Contents
16
21
 
17
22
  - [Project Status](#project-status)
18
23
  - [Global CLI](#global-cli)
19
24
  - [What You Can Do](#what-you-can-do)
25
+ - [Why pAInt Saves AI Tokens](#why-paint-saves-ai-tokens)
20
26
  - [Prerequisites](#prerequisites)
21
27
  - [Quick Start](#quick-start)
22
28
  - [Connection Modes](#connection-modes)
23
29
  - [Core Workflow](#core-workflow)
24
30
  - [Interface Layout](#interface-layout)
25
31
  - [Commands](#commands)
32
+ - [Bridge vs Server vs Terminal](#bridge-vs-server-vs-terminal)
26
33
  - [Architecture Summary](#architecture-summary)
27
34
  - [Security Notes](#security-notes)
28
35
  - [Documentation](#documentation)
@@ -101,6 +108,15 @@ Terminal WS (when started): `ws://localhost:4001/ws`
101
108
  - Track every change and export a structured changelog
102
109
  - Send changelogs to Claude Code for source-file application
103
110
 
111
+ ## Why pAInt Saves AI Tokens
112
+
113
+ pAInt helps you use fewer AI tokens by turning broad prompts into precise, structured change data:
114
+
115
+ - You edit visually first, so you do not need long back-and-forth prompt iterations
116
+ - Exported changelogs include exact selectors, properties, and before/after values
117
+ - Claude Code receives focused instructions, reducing token-heavy ambiguity
118
+ - You review only real diffs instead of asking the model to rediscover page context each time
119
+
104
120
  ## Prerequisites
105
121
 
106
122
  - Local repository development: Bun `>=1.3`
@@ -172,6 +188,20 @@ bun run start # Production server (port 4000)
172
188
  bun run lint # Biome check
173
189
  ```
174
190
 
191
+ ## Bridge vs Server vs Terminal
192
+
193
+ These three pieces have different jobs:
194
+
195
+ - `pAInt server` (`paint start`): runs the main pAInt web app UI (default `http://127.0.0.1:4000`)
196
+ - `bridge server` (`paint bridge start`): local HTTP bridge used when pAInt UI is hosted remotely (for example Vercel) but still needs safe local machine access (`http://127.0.0.1:4002` by default)
197
+ - `terminal server` (`paint terminal start`): local WebSocket PTY service for the in-app Terminal tab (`ws://localhost:4001/ws` by default)
198
+
199
+ When to run each:
200
+
201
+ - Local-only workflow: run `paint start` (and optionally `paint terminal start` if you want in-app terminal streaming)
202
+ - Hosted UI + local project workflow: run `paint bridge start` so the hosted UI can reach local-only capabilities
203
+ - Claude/CLI visibility workflow: run `paint terminal start` to see command output and progress inside pAInt
204
+
175
205
  ## Architecture Summary
176
206
 
177
207
  - Next.js App Router frontend (develop locally with Bun, run globally via Node.js CLI)
@@ -201,15 +231,12 @@ See `CONTRIBUTING.md` for setup, workflow, and pull request expectations.
201
231
 
202
232
  ## Release Automation
203
233
 
204
- - Versioning and release PRs are managed with Changesets.
234
+ - Versioning is manual (update `package.json` before merging to `main`).
205
235
  - CI workflow: `.github/workflows/ci.yml`
206
236
  - Release workflow: `.github/workflows/release.yml`
207
237
  - Publishing uses npm Trusted Publishing (OIDC) from the `release` GitHub Environment.
208
- - To queue a release, add a changeset:
209
-
210
- ```bash
211
- bun run changeset
212
- ```
238
+ - On every push to `main`, the release workflow runs build/lint/smoke checks.
239
+ - Publish happens automatically only when `package.json` version differs from the currently published npm version.
213
240
 
214
241
  ## License
215
242
 
package/bin/paint.js CHANGED
@@ -22,7 +22,7 @@ const APP_VERSION = (() => {
22
22
  const RUNNING_FROM_NODE_MODULES = APP_ROOT.includes(
23
23
  `${path.sep}node_modules${path.sep}`,
24
24
  )
25
- const RUNTIME_SCHEMA_VERSION = 2
25
+ const RUNTIME_SCHEMA_VERSION = 4
26
26
  const RUNTIME_ROOT = RUNNING_FROM_NODE_MODULES
27
27
  ? path.join(STATE_DIR, 'runtime', APP_VERSION)
28
28
  : APP_ROOT
@@ -68,11 +68,42 @@ function ensureStateDir() {
68
68
  fs.mkdirSync(STATE_DIR, { recursive: true })
69
69
  }
70
70
 
71
+ function findNodeModulesRootFromResolvedPath(resolvedPath) {
72
+ const needle = `${path.sep}node_modules${path.sep}`
73
+ const idx = resolvedPath.lastIndexOf(needle)
74
+ if (idx === -1) return null
75
+ // Keep ".../node_modules" (without trailing slash)
76
+ return resolvedPath.slice(0, idx + needle.length - 1)
77
+ }
78
+
79
+ function resolveDependencyNodeModulesRoot() {
80
+ const probes = [
81
+ 'zustand/package.json',
82
+ '@xterm/xterm/package.json',
83
+ 'react/package.json',
84
+ 'next/package.json',
85
+ ]
86
+
87
+ for (const probe of probes) {
88
+ try {
89
+ const resolved = require.resolve(probe, { paths: [APP_ROOT] })
90
+ const root = findNodeModulesRootFromResolvedPath(resolved)
91
+ if (root) return root
92
+ } catch {
93
+ // keep probing
94
+ }
95
+ }
96
+
97
+ // Last resort: local node_modules under package root.
98
+ return path.join(APP_ROOT, 'node_modules')
99
+ }
100
+
71
101
  function ensureRuntimeRoot() {
72
102
  if (!RUNNING_FROM_NODE_MODULES) return APP_ROOT
73
103
 
74
104
  const stampFile = path.join(RUNTIME_ROOT, '.paint-runtime-stamp.json')
75
105
  let needsRefresh = true
106
+ const moduleRoot = resolveDependencyNodeModulesRoot()
76
107
 
77
108
  if (fs.existsSync(stampFile)) {
78
109
  try {
@@ -81,10 +112,18 @@ function ensureRuntimeRoot() {
81
112
  const hasLegacyConfigTs = fs.existsSync(
82
113
  path.join(RUNTIME_ROOT, 'next.config.ts'),
83
114
  )
115
+ const runtimeNodeModules = path.join(RUNTIME_ROOT, 'node_modules')
116
+ let linkedToExpectedRoot = false
117
+ try {
118
+ linkedToExpectedRoot = fs.realpathSync(runtimeNodeModules) === moduleRoot
119
+ } catch {
120
+ linkedToExpectedRoot = false
121
+ }
84
122
  needsRefresh =
85
123
  stamp?.schemaVersion !== RUNTIME_SCHEMA_VERSION ||
86
124
  !hasConfigMjs ||
87
- hasLegacyConfigTs
125
+ hasLegacyConfigTs ||
126
+ !linkedToExpectedRoot
88
127
  } catch {
89
128
  needsRefresh = true
90
129
  }
@@ -124,7 +163,7 @@ function ensureRuntimeRoot() {
124
163
  }
125
164
 
126
165
  const runtimeNodeModules = path.join(RUNTIME_ROOT, 'node_modules')
127
- fs.symlinkSync(path.join(APP_ROOT, 'node_modules'), runtimeNodeModules, 'dir')
166
+ fs.symlinkSync(moduleRoot, runtimeNodeModules, 'dir')
128
167
 
129
168
  fs.writeFileSync(
130
169
  stampFile,
@@ -133,6 +172,7 @@ function ensureRuntimeRoot() {
133
172
  schemaVersion: RUNTIME_SCHEMA_VERSION,
134
173
  version: APP_VERSION,
135
174
  sourceRoot: APP_ROOT,
175
+ moduleRoot,
136
176
  preparedAt: now(),
137
177
  },
138
178
  null,
@@ -31,9 +31,17 @@ function main() {
31
31
  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'paint-packed-smoke-'))
32
32
  const packDir = path.join(tempRoot, 'pack')
33
33
  const extractDir = path.join(tempRoot, 'extract')
34
+ const globalRoot = path.join(tempRoot, 'global')
35
+ const globalNodeModules = path.join(globalRoot, 'node_modules')
36
+ const scopedPackageRoot = path.join(
37
+ globalNodeModules,
38
+ '@antigenic-oss',
39
+ 'paint',
40
+ )
34
41
  const fakeHome = path.join(tempRoot, 'home')
35
42
  fs.mkdirSync(packDir, { recursive: true })
36
43
  fs.mkdirSync(extractDir, { recursive: true })
44
+ fs.mkdirSync(globalNodeModules, { recursive: true })
37
45
  fs.mkdirSync(fakeHome, { recursive: true })
38
46
 
39
47
  runOrFail('npm', ['pack', '--silent', '--pack-destination', packDir])
@@ -49,9 +57,26 @@ function main() {
49
57
  runOrFail('tar', ['-xzf', path.join(packDir, tarball), '-C', extractDir])
50
58
 
51
59
  const packedRoot = path.join(extractDir, 'package')
52
- const packedNodeModules = path.join(packedRoot, 'node_modules')
53
- if (!fs.existsSync(packedNodeModules)) {
54
- fs.symlinkSync(path.join(APP_ROOT, 'node_modules'), packedNodeModules, 'dir')
60
+
61
+ // Emulate global install layout:
62
+ // <global>/node_modules/@antigenic-oss/paint (package)
63
+ // <global>/node_modules/<deps> (shared deps), no package-local node_modules.
64
+ fs.mkdirSync(path.dirname(scopedPackageRoot), { recursive: true })
65
+ fs.cpSync(packedRoot, scopedPackageRoot, { recursive: true, force: true })
66
+ fs.rmSync(path.join(scopedPackageRoot, 'node_modules'), {
67
+ recursive: true,
68
+ force: true,
69
+ })
70
+
71
+ const sourceNodeModules = path.join(APP_ROOT, 'node_modules')
72
+ const entries = fs.readdirSync(sourceNodeModules, { withFileTypes: true })
73
+ for (const entry of entries) {
74
+ if (entry.name === '.bin') continue
75
+ if (entry.name === '@antigenic-oss') continue
76
+ const src = path.join(sourceNodeModules, entry.name)
77
+ const dst = path.join(globalNodeModules, entry.name)
78
+ if (fs.existsSync(dst)) continue
79
+ fs.symlinkSync(src, dst, 'dir')
55
80
  }
56
81
 
57
82
  const env = {
@@ -63,15 +88,15 @@ function main() {
63
88
 
64
89
  let started = false
65
90
  try {
66
- runOrFail('node', [path.join(packedRoot, 'bin', 'paint.js'), 'start', '--rebuild', '--port', PORT], {
67
- cwd: packedRoot,
91
+ runOrFail('node', [path.join(scopedPackageRoot, 'bin', 'paint.js'), 'start', '--rebuild', '--port', PORT], {
92
+ cwd: scopedPackageRoot,
68
93
  env,
69
94
  })
70
95
  started = true
71
96
  } finally {
72
97
  if (started) {
73
- run('node', [path.join(packedRoot, 'bin', 'paint.js'), 'stop'], {
74
- cwd: packedRoot,
98
+ run('node', [path.join(scopedPackageRoot, 'bin', 'paint.js'), 'stop'], {
99
+ cwd: scopedPackageRoot,
75
100
  env,
76
101
  })
77
102
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antigenic-oss/paint",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "Visual editor for localhost web projects with a global paint CLI",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/Antigenic-OSS/pAInt",
@@ -13,10 +13,16 @@
13
13
  },
14
14
  "keywords": [
15
15
  "paint",
16
+ "pAInt",
16
17
  "visual-editor",
17
18
  "nextjs",
18
19
  "css",
19
- "localhost"
20
+ "localhost",
21
+ "bridge-server",
22
+ "terminal",
23
+ "claude-code",
24
+ "ai-coding",
25
+ "developer-tools"
20
26
  ],
21
27
  "bin": {
22
28
  "paint": "bin/paint.js"
@@ -53,10 +59,7 @@
53
59
  "lint:fix": "biome lint --write .",
54
60
  "format": "biome format --write .",
55
61
  "smoke:packed-global": "node ./bin/smoke-packed-global.js",
56
- "prepublishOnly": "npm run smoke:packed-global",
57
- "changeset": "changeset",
58
- "version-packages": "changeset version",
59
- "release": "changeset publish"
62
+ "prepublishOnly": "npm run smoke:packed-global"
60
63
  },
61
64
  "dependencies": {
62
65
  "@tailwindcss/postcss": "4.2.1",
@@ -77,7 +80,6 @@
77
80
  "zustand": "^5.0.11"
78
81
  },
79
82
  "devDependencies": {
80
- "@biomejs/biome": "2.4.5",
81
- "@changesets/cli": "^2.29.7"
83
+ "@biomejs/biome": "2.4.5"
82
84
  }
83
85
  }
@@ -1330,6 +1330,11 @@ function getInspectorCode(): string {
1330
1330
  }
1331
1331
  var meNewSelector = generateSelectorPath(meEl);
1332
1332
  var meActualIndex = Array.from(meNewParent.children).indexOf(meEl);
1333
+ var meAttrs = {};
1334
+ for (var mai = 0; mai < meEl.attributes.length; mai++) {
1335
+ var ma = meEl.attributes[mai];
1336
+ meAttrs[ma.name] = ma.value;
1337
+ }
1333
1338
  send({
1334
1339
  type: 'ELEMENT_MOVED',
1335
1340
  payload: {
@@ -1338,7 +1343,13 @@ function getInspectorCode(): string {
1338
1343
  oldParentSelectorPath: meOldParentSelector,
1339
1344
  newParentSelectorPath: msg.payload.newParentSelectorPath,
1340
1345
  oldIndex: meOldIndex,
1341
- newIndex: meActualIndex
1346
+ newIndex: meActualIndex,
1347
+ tagName: meEl.tagName.toLowerCase(),
1348
+ className: meEl.className && typeof meEl.className === 'string' ? meEl.className : null,
1349
+ elementId: meEl.id || null,
1350
+ innerText: (meEl.innerText || '').substring(0, 500) || null,
1351
+ attributes: meAttrs,
1352
+ computedStyles: getComputedStylesForElement(meEl)
1342
1353
  }
1343
1354
  });
1344
1355
  if (selectedElement === meEl) {
@@ -10,9 +10,29 @@ import {
10
10
  } from './DocsClient'
11
11
 
12
12
  export const metadata: Metadata = {
13
- title: 'pAInt — Setup Guide',
13
+ title: 'Setup Guide',
14
14
  description:
15
- 'Framework-specific setup instructions for connecting pAInt to your localhost project.',
15
+ 'Framework-specific setup instructions for connecting pAInt to localhost projects, including proxy, bridge, and terminal-assisted workflows.',
16
+ keywords: [
17
+ 'pAInt setup',
18
+ 'localhost visual editor setup',
19
+ 'bridge server setup',
20
+ 'terminal server setup',
21
+ 'Next.js inspector script',
22
+ 'Claude Code changelog workflow',
23
+ ],
24
+ openGraph: {
25
+ title: 'pAInt Setup Guide',
26
+ description:
27
+ 'Connect pAInt to your localhost app with framework-specific instructions and efficient AI handoff workflows.',
28
+ type: 'article',
29
+ },
30
+ twitter: {
31
+ card: 'summary',
32
+ title: 'pAInt Setup Guide',
33
+ description:
34
+ 'Configure pAInt quickly for localhost projects and ship cleaner AI-assisted edits.',
35
+ },
16
36
  }
17
37
 
18
38
  const SCRIPT_TAG =
@@ -2,8 +2,54 @@ import type { Metadata } from 'next'
2
2
  import './globals.css'
3
3
 
4
4
  export const metadata: Metadata = {
5
- title: 'pAInt',
6
- description: 'Visual design editor for localhost projects',
5
+ metadataBase: new URL('https://dev-editor-flow.vercel.app'),
6
+ title: {
7
+ default: 'pAInt | Visual Editor for Localhost Projects',
8
+ template: '%s | pAInt',
9
+ },
10
+ description:
11
+ 'pAInt is a visual editor for localhost apps with bridge, server, and terminal workflows. Edit first, export precise changelogs, and save AI tokens with focused Claude Code handoff.',
12
+ keywords: [
13
+ 'pAInt',
14
+ 'visual editor',
15
+ 'localhost web editor',
16
+ 'bridge server',
17
+ 'terminal server',
18
+ 'Claude Code',
19
+ 'AI token efficiency',
20
+ 'CSS visual editing',
21
+ 'Next.js visual editor',
22
+ 'developer tooling',
23
+ ],
24
+ alternates: {
25
+ canonical: '/',
26
+ },
27
+ openGraph: {
28
+ type: 'website',
29
+ url: '/',
30
+ siteName: 'pAInt',
31
+ title: 'pAInt | Visual Editor for Localhost Projects',
32
+ description:
33
+ 'Inspect, edit, and export structured UI changes. Use bridge/server/terminal modes to ship faster and reduce AI token usage.',
34
+ },
35
+ twitter: {
36
+ card: 'summary_large_image',
37
+ title: 'pAInt | Visual Editor for Localhost Projects',
38
+ description:
39
+ 'Visual-first editing plus focused changelogs for Claude Code means fewer tokens and faster delivery.',
40
+ },
41
+ robots: {
42
+ index: true,
43
+ follow: true,
44
+ googleBot: {
45
+ index: true,
46
+ follow: true,
47
+ 'max-image-preview': 'large',
48
+ 'max-snippet': -1,
49
+ 'max-video-preview': -1,
50
+ },
51
+ },
52
+ category: 'developer tools',
7
53
  }
8
54
 
9
55
  export default function RootLayout({
@@ -1198,7 +1198,12 @@ export function ChangesPanel() {
1198
1198
  ])
1199
1199
 
1200
1200
  const handleAiScan = useCallback(() => {
1201
- if (!targetUrl || !projectRoot || breakpointChanges.length === 0) return
1201
+ if (!targetUrl || breakpointChanges.length === 0) return
1202
+ if (!projectRoot) {
1203
+ showToast('error', 'Set a project folder first — click the folder icon in the Claude tab')
1204
+ setActiveRightTab('claude')
1205
+ return
1206
+ }
1202
1207
 
1203
1208
  setAiScanStatus('scanning')
1204
1209
  setAiScanError(null)
@@ -1251,6 +1256,7 @@ export function ChangesPanel() {
1251
1256
  setAiScanResult,
1252
1257
  showToast,
1253
1258
  setActiveLeftTab,
1259
+ setActiveRightTab,
1254
1260
  ])
1255
1261
 
1256
1262
  const handleSendToClaudeCode = useCallback(
@@ -261,6 +261,32 @@ export function performRevertAll() {
261
261
  }
262
262
  }
263
263
 
264
+ // Module-level singletons for auto-persist and load — prevents duplicate
265
+ // subscriptions and stale-data overwrites when multiple components call
266
+ // useChangeTracker() and some of them remount (e.g. LayerNode after tree updates).
267
+ let persistSubscribed = false
268
+ let persistTimeout: ReturnType<typeof setTimeout> | null = null
269
+ let prevChangesRef: unknown = null
270
+ let lastLoadedUrl: string | null = null
271
+
272
+ function ensurePersistSubscription() {
273
+ if (persistSubscribed) return
274
+ persistSubscribed = true
275
+ useEditorStore.subscribe((state) => {
276
+ const ref = state.styleChanges
277
+ if (ref === prevChangesRef) return
278
+ prevChangesRef = ref
279
+
280
+ const url = state.targetUrl
281
+ if (!url) return
282
+
283
+ if (persistTimeout) clearTimeout(persistTimeout)
284
+ persistTimeout = setTimeout(() => {
285
+ useEditorStore.getState().persistChanges(url)
286
+ }, 300)
287
+ })
288
+ }
289
+
264
290
  /**
265
291
  * Hook that tracks style changes, sends PREVIEW_CHANGE to inspector,
266
292
  * and auto-persists changes to localStorage.
@@ -273,36 +299,18 @@ export function useChangeTracker() {
273
299
  const pushUndo = useEditorStore((s) => s.pushUndo)
274
300
  const { sendToInspector } = usePostMessage()
275
301
 
276
- // Auto-persist changes when they update (count OR content)
277
- const persistTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
278
- const prevChangesRef = useRef<unknown>(null)
279
-
302
+ // Set up singleton auto-persist subscription (once globally, not per component)
280
303
  useEffect(() => {
281
- const unsubscribe = useEditorStore.subscribe((state) => {
282
- // Trigger on any styleChanges or elementSnapshots reference change
283
- const ref = state.styleChanges
284
- if (ref === prevChangesRef.current) return
285
- prevChangesRef.current = ref
286
-
287
- const url = state.targetUrl
288
- if (!url) return
289
-
290
- // Debounce persistence
291
- if (persistTimeoutRef.current) clearTimeout(persistTimeoutRef.current)
292
- persistTimeoutRef.current = setTimeout(() => {
293
- useEditorStore.getState().persistChanges(url)
294
- }, 300)
295
- })
296
-
297
- return () => {
298
- unsubscribe()
299
- if (persistTimeoutRef.current) clearTimeout(persistTimeoutRef.current)
300
- }
304
+ ensurePersistSubscription()
301
305
  }, [])
302
306
 
303
- // Load persisted changes when target URL changes
307
+ // Load persisted changes only when target URL actually changes,
308
+ // not on every component mount. This prevents remounting components
309
+ // (e.g. LayerNode after tree updates) from overwriting in-memory
310
+ // changes with stale localStorage data.
304
311
  useEffect(() => {
305
- if (targetUrl) {
312
+ if (targetUrl && targetUrl !== lastLoadedUrl) {
313
+ lastLoadedUrl = targetUrl
306
314
  useEditorStore.getState().loadPersistedChanges(targetUrl)
307
315
  }
308
316
  }, [targetUrl])
@@ -450,6 +450,12 @@ function handleMessage(event: MessageEvent) {
450
450
  newParentSelectorPath: mvNewParent,
451
451
  oldIndex: mvOldIndex,
452
452
  newIndex: mvNewIndex,
453
+ tagName: mvTag,
454
+ className: mvClass,
455
+ elementId: mvId,
456
+ innerText: mvText,
457
+ attributes: mvAttrs,
458
+ computedStyles: mvStyles,
453
459
  } = msg.payload
454
460
  const mvProperty = '__element_moved__'
455
461
 
@@ -464,6 +470,20 @@ function handleMessage(event: MessageEvent) {
464
470
  changeScope: store.changeScope,
465
471
  })
466
472
 
473
+ // Save element snapshot so move appears in Changes panel
474
+ store.saveElementSnapshot({
475
+ selectorPath: mvNewSelector,
476
+ tagName: mvTag || 'unknown',
477
+ className: mvClass ?? null,
478
+ elementId: mvId ?? null,
479
+ attributes: mvAttrs || {},
480
+ innerText: mvText,
481
+ computedStyles: mvStyles ? { ...mvStyles } : {},
482
+ pagePath: store.currentPagePath,
483
+ changeScope: store.changeScope,
484
+ sourceInfo: null,
485
+ })
486
+
467
487
  // Track the move
468
488
  store.addStyleChange({
469
489
  id: generateId(),
@@ -337,6 +337,12 @@ export interface ElementMovedMessage {
337
337
  newParentSelectorPath: string
338
338
  oldIndex: number
339
339
  newIndex: number
340
+ tagName: string
341
+ className: string | null
342
+ elementId: string | null
343
+ innerText: string | null
344
+ attributes: Record<string, string>
345
+ computedStyles: Record<string, string>
340
346
  }
341
347
  }
342
348