@lowdefy/server-dev 5.6.0 → 6.1.0

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.
Files changed (218) hide show
  1. package/{pages/_app.js → client/App.jsx} +21 -33
  2. package/client/DevStreamContext.js +30 -0
  3. package/client/Inspector.jsx +226 -0
  4. package/{lib/client/Page.js → client/Page.jsx} +4 -8
  5. package/client/Reload.jsx +85 -0
  6. package/client/Routing.jsx +117 -0
  7. package/client/clientBuildImports.test.mjs +61 -0
  8. package/client/feedback/FeedbackMount.jsx +83 -0
  9. package/client/feedback/FeedbackOverlay.jsx +901 -0
  10. package/client/feedback/captureTabScreenshot.js +84 -0
  11. package/client/feedback/copyToClipboard.js +45 -0
  12. package/client/feedback/drawAnnotationsSvg.js +119 -0
  13. package/client/feedback/elementInspect.js +99 -0
  14. package/client/feedback/feedbackStyles.js +301 -0
  15. package/client/feedback/pinResponsiveImages.js +59 -0
  16. package/client/feedback/sendFeedback.js +39 -0
  17. package/client/feedback/useFeedbackToggle.js +70 -0
  18. package/client/main.jsx +42 -0
  19. package/client/openInEditor/OpenInEditorListener.jsx +268 -0
  20. package/client/openInEditor/findBlockLocation.js +51 -0
  21. package/lib/build/app.js +8 -1
  22. package/lib/build/appMeta.js +8 -1
  23. package/lib/build/auth.js +8 -1
  24. package/lib/build/config.js +8 -1
  25. package/lib/build/i18n.js +8 -1
  26. package/lib/build/logger.js +8 -1
  27. package/lib/build/theme.js +8 -1
  28. package/lib/client/auth/{Auth.js → Auth.jsx} +11 -2
  29. package/lib/client/auth/{AuthConfigured.js → AuthConfigured.jsx} +22 -10
  30. package/lib/client/setPageId.js +6 -5
  31. package/lib/client/utils/useMutateCache.js +14 -1
  32. package/lib/client/utils/usePageConfig.js +45 -43
  33. package/lib/client/utils/usePageConfig.test.mjs +62 -0
  34. package/lib/docs/captureAnnotatedScreenshot.js +88 -0
  35. package/lib/docs/checkpointPaths.js +91 -0
  36. package/lib/docs/checkpointStore.js +240 -0
  37. package/lib/docs/checkpointToMocks.js +65 -0
  38. package/lib/docs/clientErrorStore.js +36 -0
  39. package/lib/docs/configCheckpoints.test.mjs +156 -0
  40. package/lib/docs/createConfigCheckpoint.js +92 -0
  41. package/lib/docs/createDocsMcpServer.js +531 -0
  42. package/lib/docs/createDocsMcpServer.test.mjs +141 -0
  43. package/lib/docs/devMockRegistry.js +68 -0
  44. package/lib/docs/docs.test.mjs +448 -0
  45. package/lib/docs/enrichFeedback.js +71 -0
  46. package/lib/docs/evalOperator.js +47 -0
  47. package/lib/docs/evalOperator.test.mjs +157 -0
  48. package/lib/docs/evalOperatorHeadless.js +87 -0
  49. package/lib/docs/evalOperatorHeadless.test.mjs +72 -0
  50. package/lib/docs/evalOperatorInTab.js +48 -0
  51. package/lib/docs/findConfig.js +260 -0
  52. package/lib/docs/formatFeedback.js +121 -0
  53. package/lib/docs/formatFeedback.test.mjs +204 -0
  54. package/lib/docs/getAppMap.js +180 -0
  55. package/lib/docs/getBrowser.js +127 -0
  56. package/lib/docs/getBrowser.test.mjs +122 -0
  57. package/lib/docs/getBuildStatus.js +37 -0
  58. package/lib/docs/getCoreDoc.js +63 -0
  59. package/lib/docs/getDocsManifest.js +40 -0
  60. package/lib/docs/getExamples.js +58 -0
  61. package/lib/docs/getOverview.js +115 -0
  62. package/lib/docs/getPageConfig.js +53 -0
  63. package/lib/docs/getPluginDoc.js +55 -0
  64. package/lib/docs/getSchema.js +65 -0
  65. package/lib/docs/inspectState.js +47 -0
  66. package/lib/docs/inspectState.test.mjs +140 -0
  67. package/lib/docs/inspectStateFromTab.js +40 -0
  68. package/lib/docs/inspectStateHeadless.js +84 -0
  69. package/lib/docs/inspectStateHeadless.test.mjs +60 -0
  70. package/lib/docs/isWriteRequestsAllowed.js +50 -0
  71. package/lib/docs/listConfigCheckpoints.js +55 -0
  72. package/lib/docs/listPlugins.js +80 -0
  73. package/lib/docs/listTypes.js +105 -0
  74. package/lib/docs/loadState.js +152 -0
  75. package/lib/docs/mapPageBuildErrors.js +28 -0
  76. package/lib/docs/normalizeTypeKind.js +40 -0
  77. package/{pages/api/endpoints/[...endpointId].js → lib/docs/readBuildArtifact.js} +15 -12
  78. package/lib/docs/readPageArtifact.js +26 -0
  79. package/lib/docs/resolvePluginDir.js +49 -0
  80. package/lib/docs/resolveSource.js +48 -0
  81. package/lib/docs/resolveSource.test.mjs +100 -0
  82. package/lib/docs/revertConfigCheckpoint.js +73 -0
  83. package/lib/docs/runRequest.js +141 -0
  84. package/lib/docs/saveAnnotatedScreenshot.js +43 -0
  85. package/lib/docs/scaffoldPage.js +75 -0
  86. package/lib/docs/scaffoldPage.test.mjs +97 -0
  87. package/lib/docs/screenshotPage.js +110 -0
  88. package/lib/docs/screenshotPage.test.mjs +59 -0
  89. package/lib/docs/screenshotPageClip.test.mjs +102 -0
  90. package/lib/docs/searchDocs.js +67 -0
  91. package/lib/docs/setupTestFixtures.mjs +335 -0
  92. package/lib/docs/snapshotState.js +59 -0
  93. package/lib/docs/stateCheckpoints.test.mjs +366 -0
  94. package/lib/docs/tabAvailable.js +28 -0
  95. package/lib/docs/tabChannel.js +119 -0
  96. package/lib/docs/tabChannel.test.mjs +130 -0
  97. package/lib/server/auth/{getAuthOptions.js → getAuthConfig.js} +9 -4
  98. package/lib/server/auth/getDevSession.js +70 -0
  99. package/lib/server/auth/getDevSession.test.mjs +138 -0
  100. package/lib/server/auth/getHeadlessUser.js +40 -0
  101. package/lib/server/auth/{getMockSession.js → getMockUser.js} +8 -21
  102. package/lib/server/auth/headlessUser.js +29 -0
  103. package/lib/server/auth/resolveHeadlessUser.js +38 -0
  104. package/lib/server/auth/resolveHeadlessUser.test.mjs +78 -0
  105. package/lib/server/auth/session.js +39 -0
  106. package/lib/server/auth/strategies.js +47 -0
  107. package/lib/server/createLowdefyContext.js +128 -0
  108. package/lib/server/createLowdefyContext.test.mjs +117 -0
  109. package/lib/server/getPageJitEnrichment.test.mjs +105 -0
  110. package/lib/server/jitPageBuilder.js +125 -12
  111. package/lib/server/log/createHandleError.test.mjs +126 -0
  112. package/lib/server/log/createLogger.test.mjs +62 -0
  113. package/lib/server/scrubSecrets.js +23 -0
  114. package/manager/getContext.mjs +3 -7
  115. package/manager/processes/initialBuild.mjs +0 -2
  116. package/manager/processes/installPlugins.mjs +19 -11
  117. package/manager/processes/lowdefyBuild.mjs +29 -7
  118. package/manager/processes/lowdefyBuild.test.mjs +102 -0
  119. package/manager/processes/readDotEnv.mjs +13 -1
  120. package/manager/processes/shutdownServer.mjs +6 -6
  121. package/manager/processes/startProxy.mjs +160 -0
  122. package/manager/processes/startProxy.test.mjs +102 -0
  123. package/manager/processes/startServer.mjs +33 -16
  124. package/manager/processes/startWatchers.mjs +2 -2
  125. package/manager/processes/warnAuthUrlPortMismatch.mjs +47 -0
  126. package/manager/processes/warnAuthUrlPortMismatch.test.mjs +93 -0
  127. package/manager/run.mjs +50 -0
  128. package/manager/utils/createCustomPluginMessagesMap.mjs +8 -1
  129. package/manager/utils/createCustomPluginTypesMap.mjs +1 -0
  130. package/manager/utils/formatNoticeBox.mjs +30 -0
  131. package/manager/utils/{getNextBin.mjs → getViteBin.mjs} +7 -10
  132. package/manager/utils/updatePageTailwindCss.mjs +7 -1
  133. package/manager/utils/writeBuildStatus.mjs +39 -0
  134. package/manager/utils/writeBuildStatus.test.mjs +76 -0
  135. package/manager/watchers/lowdefyBuildWatcher.mjs +0 -1
  136. package/manager/watchers/moduleBuildWatcher.mjs +0 -1
  137. package/manager/watchers/{nextBuildWatcher.mjs → serverArtifactWatcher.mjs} +18 -20
  138. package/package.json +62 -49
  139. package/package.original.json +65 -52
  140. package/postcss.config.cjs +1 -0
  141. package/src/app.js +175 -0
  142. package/src/html/renderDevPage.js +86 -0
  143. package/src/lib/getPathSegments.js +25 -0
  144. package/src/lib/safeScriptJson.js +38 -0
  145. package/src/middleware/apiContext.js +32 -0
  146. package/src/middleware/errorHandler.js +48 -0
  147. package/src/middleware/errorHandler.test.mjs +247 -0
  148. package/src/routes/agent.js +64 -0
  149. package/src/routes/apiPage.js +37 -0
  150. package/src/routes/auth.js +49 -0
  151. package/src/routes/auth.test.mjs +84 -0
  152. package/src/routes/clientError.js +57 -0
  153. package/src/routes/cron.js +47 -0
  154. package/src/routes/cronForward.js +47 -0
  155. package/src/routes/detached.js +47 -0
  156. package/src/routes/devInspect.js +111 -0
  157. package/src/routes/devInspect.test.mjs +119 -0
  158. package/{pages/[[...pageId]].js → src/routes/devTools.js} +4 -2
  159. package/{pages/404.js → src/routes/docs/appMap.js} +6 -2
  160. package/{pages/api/dev-tools.js → src/routes/docs/buildStatus.js} +5 -4
  161. package/src/routes/docs/checkpointsCreate.js +28 -0
  162. package/src/routes/docs/checkpointsList.js +23 -0
  163. package/{pages/api/icons/dynamic.js → src/routes/docs/checkpointsRevert.js} +11 -12
  164. package/src/routes/docs/content.js +31 -0
  165. package/src/routes/docs/evalOperator.js +56 -0
  166. package/src/routes/docs/examples.js +33 -0
  167. package/src/routes/docs/find.js +30 -0
  168. package/src/routes/docs/index.js +23 -0
  169. package/src/routes/docs/inspectState.js +44 -0
  170. package/src/routes/docs/loadState.js +49 -0
  171. package/src/routes/docs/mcp.js +30 -0
  172. package/src/routes/docs/pageConfig.js +36 -0
  173. package/src/routes/docs/parseUserParam.js +52 -0
  174. package/src/routes/docs/parseUserParam.test.mjs +45 -0
  175. package/src/routes/docs/pluginDoc.js +33 -0
  176. package/src/routes/docs/plugins.js +23 -0
  177. package/src/routes/docs/runRequest.js +36 -0
  178. package/{lib/server/auth/getServerSession.js → src/routes/docs/schema.js} +17 -15
  179. package/src/routes/docs/screenshot.js +72 -0
  180. package/src/routes/docs/search.js +27 -0
  181. package/src/routes/docs/snapshotState.js +36 -0
  182. package/src/routes/docs/stateCheckpointsList.js +26 -0
  183. package/src/routes/docs/types.js +33 -0
  184. package/src/routes/endpoints.js +71 -0
  185. package/src/routes/feedback.js +110 -0
  186. package/src/routes/feedback.test.mjs +204 -0
  187. package/src/routes/jitPage.js +86 -0
  188. package/src/routes/mcp.js +34 -0
  189. package/{pages/api → src/routes}/ping.js +4 -2
  190. package/src/routes/reload.js +79 -0
  191. package/src/routes/request.js +74 -0
  192. package/src/routes/request.test.mjs +124 -0
  193. package/{pages/api → src/routes}/root.js +4 -4
  194. package/src/routes/usage.js +46 -0
  195. package/src/routes/websocket.js +33 -0
  196. package/src/websocket/devWebSocket.js +78 -0
  197. package/vite.config.js +111 -0
  198. package/.eslintrc.yaml +0 -1
  199. package/lib/client/App.js +0 -80
  200. package/lib/client/Reload.js +0 -60
  201. package/lib/server/apiWrapper.js +0 -109
  202. package/lib/server/compileCss.js +0 -39
  203. package/manager/processes/compileCss.mjs +0 -54
  204. package/manager/processes/nextBuild.mjs +0 -50
  205. package/next.config.js +0 -25
  206. package/pages/_document.js +0 -109
  207. package/pages/api/agent/[...path].js +0 -86
  208. package/pages/api/auth/[...nextauth].js +0 -48
  209. package/pages/api/client-error.js +0 -45
  210. package/pages/api/js/[env].js +0 -42
  211. package/pages/api/page/[...pageId].js +0 -74
  212. package/pages/api/reload.js +0 -53
  213. package/pages/api/request/[...path].js +0 -41
  214. /package/lib/client/{BuildErrorPage.js → BuildErrorPage.jsx} +0 -0
  215. /package/lib/client/{BuildingPage.js → BuildingPage.jsx} +0 -0
  216. /package/lib/client/{ErrorBar.js → ErrorBar.jsx} +0 -0
  217. /package/lib/client/{InstallingPluginsPage.js → InstallingPluginsPage.jsx} +0 -0
  218. /package/lib/client/{RestartingPage.js → RestartingPage.jsx} +0 -0
@@ -0,0 +1,901 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import React, { useEffect, useReducer, useRef, useState } from 'react';
18
+ import { createPortal } from 'react-dom';
19
+
20
+ import { blockAncestorChain, describeElement, nearestBlock } from './elementInspect.js';
21
+ import {
22
+ annotatingPanel,
23
+ buttonRow,
24
+ colors,
25
+ consoleCountRow,
26
+ dangerButton,
27
+ descriptorRow,
28
+ errorRow,
29
+ highlightBox,
30
+ labelChip,
31
+ overlayContainer,
32
+ panelHeading,
33
+ primaryButton,
34
+ reviewItem,
35
+ reviewItemRemove,
36
+ reviewTray,
37
+ secondaryButton,
38
+ selectedHighlightBox,
39
+ selectedLabelChip,
40
+ sentBanner,
41
+ svgLayer,
42
+ textareaStyle,
43
+ toolButtonStyle,
44
+ toolRow,
45
+ injectFeedbackStyleTag,
46
+ removeFeedbackStyleTag,
47
+ } from './feedbackStyles.js';
48
+ import captureTabScreenshot from './captureTabScreenshot.js';
49
+ import copyToClipboard from './copyToClipboard.js';
50
+ import sendFeedback from './sendFeedback.js';
51
+
52
+ const FEEDBACK_ROOT_SELECTOR = '[data-lowdefy-feedback]';
53
+ const FREEHAND_MIN_SAMPLE_DIST = 4;
54
+ const MIN_SHAPE_SIZE = 2;
55
+ const SENT_CLOSE_DELAY_MS = 1200;
56
+
57
+ const TOOL_LABELS = { rect: 'Rectangle', arrow: 'Arrow', freehand: 'Freehand' };
58
+
59
+ function generateId() {
60
+ try {
61
+ return crypto.randomUUID();
62
+ } catch {
63
+ return `fb-${Date.now()}-${Math.random().toString(16).slice(2)}`;
64
+ }
65
+ }
66
+
67
+ // Used internally for CSS positioning (top/left) of highlight boxes.
68
+ function rectToPlain(rect) {
69
+ return { top: rect.top, left: rect.left, width: rect.width, height: rect.height };
70
+ }
71
+
72
+ // Used for the wire contract (x/y), which the dev server's formatFeedback.js
73
+ // and enrichFeedback.js are built against.
74
+ function rectToXY(rect) {
75
+ return { x: rect.left, y: rect.top, width: rect.width, height: rect.height };
76
+ }
77
+
78
+ const initialState = {
79
+ phase: 'picking', // picking | annotating | drawing | sent
80
+ hoverBlock: null, // { blockId, rect }
81
+ selection: null, // { kind, blockId, blockChain, tag, classes, text, rect }
82
+ draftComment: '',
83
+ draftTool: null, // rect | arrow | freehand
84
+ draftShapes: [],
85
+ batch: [],
86
+ includeScreenshot: true,
87
+ sending: false,
88
+ sendError: null,
89
+ };
90
+
91
+ function reducer(state, action) {
92
+ switch (action.type) {
93
+ case 'TOGGLE_SCREENSHOT':
94
+ return { ...state, includeScreenshot: !state.includeScreenshot };
95
+ case 'HOVER_BLOCK':
96
+ return { ...state, hoverBlock: action.hoverBlock };
97
+ case 'LOCK_SELECTION':
98
+ return {
99
+ ...state,
100
+ phase: 'annotating',
101
+ hoverBlock: null,
102
+ selection: action.selection,
103
+ draftComment: '',
104
+ draftTool: null,
105
+ draftShapes: [],
106
+ };
107
+ case 'UPDATE_HOVER_RECT':
108
+ if (!state.hoverBlock) return state;
109
+ return { ...state, hoverBlock: { ...state.hoverBlock, rect: action.rect } };
110
+ case 'UPDATE_SELECTION_RECT':
111
+ if (!state.selection) return state;
112
+ return { ...state, selection: { ...state.selection, rect: action.rect } };
113
+ case 'SET_COMMENT':
114
+ return { ...state, draftComment: action.comment };
115
+ case 'START_DRAWING':
116
+ return { ...state, phase: 'drawing', draftTool: action.tool };
117
+ case 'ADD_SHAPE':
118
+ return {
119
+ ...state,
120
+ phase: 'annotating',
121
+ draftTool: null,
122
+ draftShapes: [...state.draftShapes, action.shape],
123
+ };
124
+ case 'CANCEL_DRAWING':
125
+ return { ...state, phase: 'annotating', draftTool: null };
126
+ // "Add another" — bank the current draft and go straight back to picking;
127
+ // the banked annotations show in the pending tray until sent.
128
+ case 'SAVE_ANNOTATION':
129
+ return {
130
+ ...state,
131
+ phase: 'picking',
132
+ batch: [...state.batch, action.annotation],
133
+ selection: null,
134
+ draftComment: '',
135
+ draftTool: null,
136
+ draftShapes: [],
137
+ };
138
+ case 'DISCARD_DRAFT':
139
+ return {
140
+ ...state,
141
+ phase: 'picking',
142
+ hoverBlock: null,
143
+ selection: null,
144
+ draftComment: '',
145
+ draftTool: null,
146
+ draftShapes: [],
147
+ };
148
+ case 'REMOVE_FROM_BATCH':
149
+ return { ...state, batch: state.batch.filter((annotation) => annotation.id !== action.id) };
150
+ case 'DISCARD_ALL':
151
+ return { ...state, phase: 'picking', batch: [] };
152
+ case 'SEND_START':
153
+ return { ...state, sending: true, sendError: null };
154
+ case 'SEND_SUCCESS':
155
+ return { ...state, sending: false, phase: 'sent' };
156
+ case 'SEND_ERROR':
157
+ return { ...state, sending: false, sendError: action.error };
158
+ default:
159
+ return state;
160
+ }
161
+ }
162
+
163
+ // Matches the dev server's contract exactly (see
164
+ // lib/docs/formatFeedback.js / enrichFeedback.js and their tests):
165
+ // kind is "element" | "region"; target is null for a region (there is no
166
+ // block to attach a config location to); geometry.elementRect uses x/y (a
167
+ // DOMRect's x/y are the same values as its top/left, just renamed for the
168
+ // wire format); shapes carry a uniform `points` array regardless of tool.
169
+ function buildAnnotation(state) {
170
+ const { selection, draftComment, draftShapes } = state;
171
+ const isElement = selection.kind === 'element';
172
+ return {
173
+ id: generateId(),
174
+ kind: selection.kind,
175
+ comment: draftComment,
176
+ target: isElement
177
+ ? {
178
+ blockId: selection.blockId,
179
+ ancestorBlockIds: selection.blockChain,
180
+ tag: selection.tag,
181
+ text: selection.text,
182
+ }
183
+ : null,
184
+ geometry: {
185
+ elementRect: isElement && selection.rect ? rectToXY(selection.rect) : null,
186
+ shapes: draftShapes,
187
+ },
188
+ };
189
+ }
190
+
191
+ function buildBatch({ annotations, includeScreenshot, pageId }) {
192
+ return {
193
+ batchId: generateId(),
194
+ timestamp: new Date().toISOString(),
195
+ pageId,
196
+ url: `${window.location.pathname}${window.location.search}`,
197
+ urlQuery: window.location.search,
198
+ viewport: {
199
+ width: window.innerWidth,
200
+ height: window.innerHeight,
201
+ scrollX: window.scrollX,
202
+ scrollY: window.scrollY,
203
+ dpr: window.devicePixelRatio,
204
+ },
205
+ annotations,
206
+ includeScreenshot,
207
+ stateRef: { captured: true, pageId },
208
+ };
209
+ }
210
+
211
+ function handleEscape({ stateRef, dispatch, onClose }) {
212
+ const { phase, batch } = stateRef.current;
213
+ if (phase === 'drawing') {
214
+ dispatch({ type: 'CANCEL_DRAWING' });
215
+ return;
216
+ }
217
+ if (phase === 'annotating') {
218
+ dispatch({ type: 'DISCARD_DRAFT' });
219
+ return;
220
+ }
221
+ // picking (or sent, though Esc is irrelevant there)
222
+ try {
223
+ if (batch.length > 0) {
224
+ const confirmed = window.confirm(
225
+ `Discard ${batch.length} pending annotation${batch.length === 1 ? '' : 's'}?`
226
+ );
227
+ if (!confirmed) {
228
+ return;
229
+ }
230
+ }
231
+ } catch {
232
+ // If confirm() itself throws (unlikely), fall through and close anyway.
233
+ }
234
+ if (onClose) {
235
+ onClose();
236
+ }
237
+ }
238
+
239
+ // Every shape carries a uniform `points` array (the wire contract's shape),
240
+ // interpreted per type: rect = [topLeft, bottomRight], arrow = [tail, head],
241
+ // freehand = the full sampled path.
242
+ function renderShape(shape, key) {
243
+ if (shape.type === 'rect') {
244
+ const [a, b] = shape.points;
245
+ return (
246
+ <rect
247
+ key={key}
248
+ x={a.x}
249
+ y={a.y}
250
+ width={b.x - a.x}
251
+ height={b.y - a.y}
252
+ stroke={colors.stroke}
253
+ strokeWidth={2}
254
+ fill={colors.strokeBg}
255
+ />
256
+ );
257
+ }
258
+ if (shape.type === 'arrow') {
259
+ const [a, b] = shape.points;
260
+ return (
261
+ <line
262
+ key={key}
263
+ x1={a.x}
264
+ y1={a.y}
265
+ x2={b.x}
266
+ y2={b.y}
267
+ stroke={colors.stroke}
268
+ strokeWidth={2}
269
+ markerEnd="url(#lowdefy-feedback-arrowhead)"
270
+ />
271
+ );
272
+ }
273
+ if (shape.type === 'freehand') {
274
+ return (
275
+ <polyline
276
+ key={key}
277
+ points={shape.points.map((point) => `${point.x},${point.y}`).join(' ')}
278
+ stroke={colors.stroke}
279
+ strokeWidth={2}
280
+ fill="none"
281
+ />
282
+ );
283
+ }
284
+ return null;
285
+ }
286
+
287
+ // The overlay itself. Everything here is dev-only and must never take the
288
+ // app down with it — event handlers are individually try/caught, and the
289
+ // whole render is wrapped so a bad state shape returns null rather than
290
+ // throwing into the app's React tree.
291
+ function FeedbackOverlay({ basePath, pageId, onClose }) {
292
+ const [state, dispatch] = useReducer(reducer, initialState);
293
+ const primarySendRef = useRef(null);
294
+ const stateRef = useRef(state);
295
+ stateRef.current = state;
296
+
297
+ const textareaRef = useRef(null);
298
+ const drawStartRef = useRef(null);
299
+ const freehandPointsRef = useRef([]);
300
+ const [isDrawingShape, setIsDrawingShape] = useState(false);
301
+ const [drawPreview, setDrawPreview] = useState(null);
302
+
303
+ // Style tag lifecycle — installed for the overlay's lifetime only.
304
+ useEffect(() => {
305
+ injectFeedbackStyleTag();
306
+ return () => removeFeedbackStyleTag();
307
+ }, []);
308
+
309
+ // Capture-phase keydown suppressor: blocks every key from reaching the
310
+ // app's own shortcut manager while the overlay is active, except when the
311
+ // developer is typing inside the overlay itself (e.g. the comment
312
+ // textarea). Esc and Enter are handled here first, before deciding
313
+ // whether to suppress, so they always work regardless of focus.
314
+ useEffect(() => {
315
+ function onKeyDown(event) {
316
+ try {
317
+ const insideOverlay =
318
+ event.target instanceof Element && Boolean(event.target.closest(FEEDBACK_ROOT_SELECTOR));
319
+
320
+ if (event.key === 'Escape') {
321
+ event.preventDefault();
322
+ handleEscape({ stateRef, dispatch, onClose });
323
+ event.stopImmediatePropagation();
324
+ return;
325
+ }
326
+
327
+ // Enter drives the primary (blue) action for the current phase —
328
+ // copy for agent, including the in-flight draft. Shift+Enter still
329
+ // inserts a newline in the comment textarea.
330
+ const isPlainEnter = event.key === 'Enter' && !event.shiftKey;
331
+ if (isPlainEnter) {
332
+ const current = stateRef.current;
333
+ const canSendDraft = current.phase === 'annotating' && current.selection;
334
+ const canSendBatch = current.phase === 'picking' && current.batch.length > 0;
335
+ if (!current.sending && (canSendDraft || canSendBatch)) {
336
+ event.preventDefault();
337
+ primarySendRef.current?.();
338
+ event.stopImmediatePropagation();
339
+ return;
340
+ }
341
+ }
342
+
343
+ if (!insideOverlay) {
344
+ event.stopImmediatePropagation();
345
+ }
346
+ } catch {
347
+ // Never let a keyboard-handling bug break the developer's app.
348
+ }
349
+ }
350
+
351
+ window.addEventListener('keydown', onKeyDown, { capture: true });
352
+ return () => window.removeEventListener('keydown', onKeyDown, { capture: true });
353
+ }, [onClose]);
354
+
355
+ // Cursor styling for pick/draw modes.
356
+ useEffect(() => {
357
+ const body = document.body;
358
+ try {
359
+ body.classList.toggle('lowdefy-feedback-picking', state.phase === 'picking');
360
+ body.classList.toggle('lowdefy-feedback-drawing', state.phase === 'drawing');
361
+ } catch {
362
+ // no-op
363
+ }
364
+ return () => {
365
+ try {
366
+ body.classList.remove('lowdefy-feedback-picking', 'lowdefy-feedback-drawing');
367
+ } catch {
368
+ // no-op
369
+ }
370
+ };
371
+ }, [state.phase]);
372
+
373
+ // Picking mode: window-level pointermove drives the hover highlight,
374
+ // capture-phase click locks the selection. The overlay container itself is
375
+ // pointer-events:none so elementFromPoint always resolves to the real app
376
+ // DOM underneath.
377
+ useEffect(() => {
378
+ if (state.phase !== 'picking') {
379
+ return undefined;
380
+ }
381
+
382
+ function onPointerMove(event) {
383
+ try {
384
+ const el = document.elementFromPoint(event.clientX, event.clientY);
385
+ const blockId = nearestBlock(el);
386
+ if (!blockId) {
387
+ dispatch({ type: 'HOVER_BLOCK', hoverBlock: null });
388
+ return;
389
+ }
390
+ const blockEl = document.getElementById(`bl-${blockId}`);
391
+ if (!blockEl) {
392
+ dispatch({ type: 'HOVER_BLOCK', hoverBlock: null });
393
+ return;
394
+ }
395
+ dispatch({
396
+ type: 'HOVER_BLOCK',
397
+ hoverBlock: { blockId, rect: rectToPlain(blockEl.getBoundingClientRect()) },
398
+ });
399
+ } catch {
400
+ // Best-effort hover tracking.
401
+ }
402
+ }
403
+
404
+ function onClickCapture(event) {
405
+ try {
406
+ // Clicks on the overlay's own UI (the pending tray) are real button
407
+ // clicks, not element picks.
408
+ if (event.target instanceof Element && event.target.closest(FEEDBACK_ROOT_SELECTOR)) {
409
+ return;
410
+ }
411
+ event.preventDefault();
412
+ event.stopPropagation();
413
+ const el = document.elementFromPoint(event.clientX, event.clientY);
414
+ const blockId = nearestBlock(el);
415
+
416
+ if (blockId) {
417
+ const blockEl = document.getElementById(`bl-${blockId}`);
418
+ const descriptor = describeElement(blockEl ?? el);
419
+ dispatch({
420
+ type: 'LOCK_SELECTION',
421
+ selection: {
422
+ kind: 'element',
423
+ blockId,
424
+ blockChain: blockAncestorChain(el),
425
+ tag: descriptor.tag,
426
+ classes: descriptor.classes,
427
+ text: descriptor.text,
428
+ rect: blockEl ? rectToPlain(blockEl.getBoundingClientRect()) : null,
429
+ },
430
+ });
431
+ return;
432
+ }
433
+
434
+ // No block under the cursor — start a region annotation instead.
435
+ const descriptor = describeElement(el);
436
+ dispatch({
437
+ type: 'LOCK_SELECTION',
438
+ selection: {
439
+ kind: 'region',
440
+ blockId: null,
441
+ blockChain: [],
442
+ tag: descriptor.tag,
443
+ classes: descriptor.classes,
444
+ text: descriptor.text,
445
+ rect: { top: event.clientY, left: event.clientX, width: 0, height: 0 },
446
+ },
447
+ });
448
+ } catch {
449
+ // Best-effort selection locking.
450
+ }
451
+ }
452
+
453
+ window.addEventListener('pointermove', onPointerMove);
454
+ window.addEventListener('click', onClickCapture, { capture: true });
455
+ return () => {
456
+ window.removeEventListener('pointermove', onPointerMove);
457
+ window.removeEventListener('click', onClickCapture, { capture: true });
458
+ };
459
+ }, [state.phase]);
460
+
461
+ // Keep highlight/selection rects aligned with the app underneath as the
462
+ // developer scrolls or resizes the window.
463
+ useEffect(() => {
464
+ function recompute() {
465
+ try {
466
+ if (stateRef.current.hoverBlock) {
467
+ const el = document.getElementById(`bl-${stateRef.current.hoverBlock.blockId}`);
468
+ if (el) {
469
+ dispatch({ type: 'UPDATE_HOVER_RECT', rect: rectToPlain(el.getBoundingClientRect()) });
470
+ }
471
+ }
472
+ if (stateRef.current.selection && stateRef.current.selection.kind === 'element') {
473
+ const el = document.getElementById(`bl-${stateRef.current.selection.blockId}`);
474
+ if (el) {
475
+ dispatch({
476
+ type: 'UPDATE_SELECTION_RECT',
477
+ rect: rectToPlain(el.getBoundingClientRect()),
478
+ });
479
+ }
480
+ }
481
+ } catch {
482
+ // Best-effort — a stale rect just corrects on the next event.
483
+ }
484
+ }
485
+ window.addEventListener('scroll', recompute, true);
486
+ window.addEventListener('resize', recompute);
487
+ return () => {
488
+ window.removeEventListener('scroll', recompute, true);
489
+ window.removeEventListener('resize', recompute);
490
+ };
491
+ }, []);
492
+
493
+ // Autofocus the comment textarea whenever a fresh selection is locked.
494
+ useEffect(() => {
495
+ if (state.phase === 'annotating' && textareaRef.current) {
496
+ try {
497
+ textareaRef.current.focus();
498
+ } catch {
499
+ // no-op
500
+ }
501
+ }
502
+ }, [state.phase, state.selection]);
503
+
504
+ // Auto-close shortly after a successful send.
505
+ useEffect(() => {
506
+ if (state.phase !== 'sent') {
507
+ return undefined;
508
+ }
509
+ const timer = setTimeout(() => {
510
+ if (onClose) {
511
+ onClose();
512
+ }
513
+ }, SENT_CLOSE_DELAY_MS);
514
+ return () => clearTimeout(timer);
515
+ }, [state.phase, onClose]);
516
+
517
+ function handleDrawPointerDown(event) {
518
+ try {
519
+ event.preventDefault();
520
+ const point = { x: event.clientX, y: event.clientY };
521
+ if (state.draftTool === 'freehand') {
522
+ freehandPointsRef.current = [point];
523
+ setDrawPreview({ type: 'freehand', points: [point] });
524
+ } else if (state.draftTool === 'rect' || state.draftTool === 'arrow') {
525
+ // rect = [topLeft, bottomRight], arrow = [tail, head] — both start
526
+ // as a degenerate two-point shape at the down position.
527
+ drawStartRef.current = point;
528
+ setDrawPreview({ type: state.draftTool, points: [point, point] });
529
+ }
530
+ setIsDrawingShape(true);
531
+ } catch {
532
+ // no-op
533
+ }
534
+ }
535
+
536
+ function handleDrawPointerMove(event) {
537
+ if (!isDrawingShape) {
538
+ return;
539
+ }
540
+ try {
541
+ const point = { x: event.clientX, y: event.clientY };
542
+ if (state.draftTool === 'freehand') {
543
+ const points = freehandPointsRef.current;
544
+ const last = points[points.length - 1];
545
+ if (Math.hypot(point.x - last.x, point.y - last.y) >= FREEHAND_MIN_SAMPLE_DIST) {
546
+ points.push(point);
547
+ setDrawPreview({ type: 'freehand', points: [...points] });
548
+ }
549
+ } else if (state.draftTool === 'rect') {
550
+ const start = drawStartRef.current;
551
+ setDrawPreview({
552
+ type: 'rect',
553
+ points: [
554
+ { x: Math.min(start.x, point.x), y: Math.min(start.y, point.y) },
555
+ { x: Math.max(start.x, point.x), y: Math.max(start.y, point.y) },
556
+ ],
557
+ });
558
+ } else if (state.draftTool === 'arrow') {
559
+ const start = drawStartRef.current;
560
+ setDrawPreview({ type: 'arrow', points: [start, point] });
561
+ }
562
+ } catch {
563
+ // no-op
564
+ }
565
+ }
566
+
567
+ function handleDrawPointerUp() {
568
+ try {
569
+ const shape = drawPreview;
570
+ let keep = Boolean(shape);
571
+ if (shape?.type === 'rect') {
572
+ const [a, b] = shape.points;
573
+ if (Math.abs(b.x - a.x) < MIN_SHAPE_SIZE && Math.abs(b.y - a.y) < MIN_SHAPE_SIZE) {
574
+ keep = false;
575
+ }
576
+ }
577
+ if (shape?.type === 'arrow') {
578
+ const [a, b] = shape.points;
579
+ if (Math.hypot(b.x - a.x, b.y - a.y) < MIN_SHAPE_SIZE) {
580
+ keep = false;
581
+ }
582
+ }
583
+ if (shape?.type === 'freehand' && shape.points.length < 2) {
584
+ keep = false;
585
+ }
586
+ if (keep) {
587
+ dispatch({ type: 'ADD_SHAPE', shape });
588
+ } else {
589
+ dispatch({ type: 'CANCEL_DRAWING' });
590
+ }
591
+ } catch {
592
+ dispatch({ type: 'CANCEL_DRAWING' });
593
+ } finally {
594
+ setIsDrawingShape(false);
595
+ setDrawPreview(null);
596
+ drawStartRef.current = null;
597
+ freehandPointsRef.current = [];
598
+ }
599
+ }
600
+
601
+ function handleAddAnother() {
602
+ try {
603
+ if (!state.selection) {
604
+ return;
605
+ }
606
+ dispatch({ type: 'SAVE_ANNOTATION', annotation: buildAnnotation(state) });
607
+ } catch {
608
+ // no-op
609
+ }
610
+ }
611
+
612
+ async function handleSend() {
613
+ try {
614
+ // The in-flight draft is part of the send — copying from the panel
615
+ // must not require banking the draft as a separate step first.
616
+ const current = stateRef.current;
617
+ const annotations = current.selection
618
+ ? [...current.batch, buildAnnotation(current)]
619
+ : current.batch;
620
+ if (current.sending || annotations.length === 0) {
621
+ return;
622
+ }
623
+ dispatch({ type: 'SEND_START' });
624
+ const batch = buildBatch({
625
+ annotations,
626
+ includeScreenshot: current.includeScreenshot,
627
+ pageId,
628
+ });
629
+ // Capture the annotated screenshot in THIS tab — the pixels the
630
+ // developer is actually looking at (theme, loaded data) — and ship it
631
+ // with the batch for the server to save. On failure the field is
632
+ // absent and the server falls back to its headless capture.
633
+ if (current.includeScreenshot) {
634
+ const screenshot = await captureTabScreenshot({ annotations });
635
+ if (screenshot) {
636
+ batch.screenshot = screenshot;
637
+ }
638
+ }
639
+ // The server enriches annotations with yaml file:line locations and
640
+ // returns the agent-readable text; the clipboard is the delivery
641
+ // channel — the developer pastes it into whichever agent session
642
+ // they choose.
643
+ const formatted = await sendFeedback({ basePath, batch });
644
+ if (formatted === null) {
645
+ dispatch({
646
+ type: 'SEND_ERROR',
647
+ error: 'Could not format annotations. Check the dev server and try again.',
648
+ });
649
+ return;
650
+ }
651
+ const copied = await copyToClipboard({ text: formatted });
652
+ if (copied) {
653
+ dispatch({ type: 'SEND_SUCCESS' });
654
+ } else {
655
+ dispatch({
656
+ type: 'SEND_ERROR',
657
+ error: 'Could not copy to clipboard. Click the page once, then try again.',
658
+ });
659
+ }
660
+ } catch {
661
+ dispatch({
662
+ type: 'SEND_ERROR',
663
+ error: 'Could not copy annotations. Check the dev server and try again.',
664
+ });
665
+ }
666
+ }
667
+ // Enter triggers the send via the capture handler above — a ref keeps the
668
+ // freshest closure without re-binding.
669
+ primarySendRef.current = handleSend;
670
+
671
+ let content = null;
672
+ try {
673
+ content = renderOverlayContent({
674
+ state,
675
+ dispatch,
676
+ textareaRef,
677
+ handleAddAnother,
678
+ handleSend,
679
+ handleDrawPointerDown,
680
+ handleDrawPointerMove,
681
+ handleDrawPointerUp,
682
+ drawPreview,
683
+ });
684
+ } catch {
685
+ content = null;
686
+ }
687
+ // Portal to document.body so the overlay is a top-level sibling of the app
688
+ // root. Rendered inline it inherits the app root's stacking context, and an
689
+ // open antd modal (portalled to body, zIndex ~1000) then paints above the
690
+ // overlay regardless of its own zIndex — making the comment textarea
691
+ // unclickable. As a body sibling its zIndex competes in the root stacking
692
+ // context and wins.
693
+ if (typeof document === 'undefined' || !document.body) {
694
+ return content;
695
+ }
696
+ return createPortal(content, document.body);
697
+ }
698
+
699
+ // Pure render function kept outside the hook-bearing component body so a
700
+ // rendering bug can be caught by the try/catch above without affecting hook
701
+ // call order.
702
+ function renderOverlayContent({
703
+ state,
704
+ dispatch,
705
+ textareaRef,
706
+ handleAddAnother,
707
+ handleSend,
708
+ handleDrawPointerDown,
709
+ handleDrawPointerMove,
710
+ handleDrawPointerUp,
711
+ drawPreview,
712
+ }) {
713
+ const showHighlightHover = state.phase === 'picking' && state.hoverBlock && state.hoverBlock.rect;
714
+ const showSelection =
715
+ (state.phase === 'annotating' || state.phase === 'drawing') &&
716
+ state.selection &&
717
+ state.selection.rect;
718
+ const showAnnotatingPanel =
719
+ (state.phase === 'annotating' || state.phase === 'drawing') && state.selection;
720
+ const showSvg = state.phase === 'drawing';
721
+ // Annotations banked via "Add another" — visible while picking the next
722
+ // element so they stay reviewable/removable without a separate step.
723
+ const showPendingTray = state.phase === 'picking' && state.batch.length > 0;
724
+ const showSent = state.phase === 'sent';
725
+ // The panel's send includes the in-flight draft on top of the banked batch.
726
+ const draftSendCount = state.batch.length + 1;
727
+ let draftSendLabel = draftSendCount > 1 ? `Copy ${draftSendCount} for agent` : 'Copy for agent';
728
+ if (state.sending) {
729
+ draftSendLabel = 'Copying…';
730
+ }
731
+
732
+ return (
733
+ <div data-lowdefy-feedback style={overlayContainer}>
734
+ {showHighlightHover && (
735
+ <div
736
+ style={{
737
+ ...highlightBox,
738
+ top: state.hoverBlock.rect.top,
739
+ left: state.hoverBlock.rect.left,
740
+ width: state.hoverBlock.rect.width,
741
+ height: state.hoverBlock.rect.height,
742
+ }}
743
+ >
744
+ <span style={labelChip}>{state.hoverBlock.blockId}</span>
745
+ </div>
746
+ )}
747
+
748
+ {showSelection && (
749
+ <div
750
+ style={{
751
+ ...selectedHighlightBox,
752
+ top: state.selection.rect.top,
753
+ left: state.selection.rect.left,
754
+ width: state.selection.rect.width,
755
+ height: state.selection.rect.height,
756
+ }}
757
+ >
758
+ <span style={selectedLabelChip}>{state.selection.blockId ?? 'region'}</span>
759
+ </div>
760
+ )}
761
+
762
+ {showSvg && (
763
+ <svg
764
+ style={{ ...svgLayer, pointerEvents: 'auto' }}
765
+ onPointerDown={handleDrawPointerDown}
766
+ onPointerMove={handleDrawPointerMove}
767
+ onPointerUp={handleDrawPointerUp}
768
+ >
769
+ <defs>
770
+ <marker
771
+ id="lowdefy-feedback-arrowhead"
772
+ markerWidth="10"
773
+ markerHeight="10"
774
+ refX="8"
775
+ refY="5"
776
+ orient="auto"
777
+ >
778
+ <polygon points="0,0 10,5 0,10" fill={colors.stroke} />
779
+ </marker>
780
+ </defs>
781
+ {state.draftShapes.map((shape, index) => renderShape(shape, `saved-${index}`))}
782
+ {drawPreview && renderShape(drawPreview, 'preview')}
783
+ </svg>
784
+ )}
785
+
786
+ {showAnnotatingPanel && (
787
+ <div style={annotatingPanel}>
788
+ <div style={panelHeading}>Add feedback</div>
789
+ <div style={descriptorRow}>
790
+ {state.selection.tag ? `<${state.selection.tag}> ` : ''}
791
+ {state.selection.blockId ? `#${state.selection.blockId}` : '(region)'}
792
+ {state.selection.text ? ` — "${state.selection.text}"` : ''}
793
+ </div>
794
+ <textarea
795
+ ref={textareaRef}
796
+ style={textareaStyle}
797
+ placeholder="What's wrong, or what should change?"
798
+ value={state.draftComment}
799
+ onChange={(event) => dispatch({ type: 'SET_COMMENT', comment: event.target.value })}
800
+ />
801
+ <div style={toolRow}>
802
+ {['rect', 'arrow', 'freehand'].map((tool) => (
803
+ <button
804
+ key={tool}
805
+ type="button"
806
+ style={toolButtonStyle(state.phase === 'drawing' && state.draftTool === tool)}
807
+ onClick={() => dispatch({ type: 'START_DRAWING', tool })}
808
+ >
809
+ {TOOL_LABELS[tool]}
810
+ </button>
811
+ ))}
812
+ </div>
813
+ {state.draftShapes.length > 0 && (
814
+ <div style={descriptorRow}>{state.draftShapes.length} shape(s) drawn</div>
815
+ )}
816
+ <label style={consoleCountRow}>
817
+ <input
818
+ checked={state.includeScreenshot}
819
+ onChange={() => dispatch({ type: 'TOGGLE_SCREENSHOT' })}
820
+ style={{ marginRight: 6, verticalAlign: 'middle' }}
821
+ type="checkbox"
822
+ />
823
+ Include annotated screenshot
824
+ </label>
825
+ {state.sendError && <div style={errorRow}>{state.sendError}</div>}
826
+ <div style={buttonRow}>
827
+ <button
828
+ type="button"
829
+ style={secondaryButton}
830
+ onClick={() => dispatch({ type: 'DISCARD_DRAFT' })}
831
+ >
832
+ Cancel
833
+ </button>
834
+ <button type="button" style={secondaryButton} onClick={handleAddAnother}>
835
+ Add another
836
+ </button>
837
+ <button
838
+ type="button"
839
+ style={primaryButton}
840
+ disabled={state.sending}
841
+ onClick={handleSend}
842
+ >
843
+ {draftSendLabel}
844
+ </button>
845
+ </div>
846
+ </div>
847
+ )}
848
+
849
+ {showPendingTray && (
850
+ <div style={reviewTray}>
851
+ <div style={panelHeading}>Pending feedback ({state.batch.length})</div>
852
+ {state.batch.map((annotation, index) => (
853
+ <div key={annotation.id} style={reviewItem}>
854
+ <div>
855
+ <div>
856
+ {index + 1}. {annotation.target?.blockId ?? 'region'}
857
+ </div>
858
+ <div style={{ color: colors.textMuted }}>
859
+ {(annotation.comment ?? '').split('\n')[0].slice(0, 80) || '(no comment)'}
860
+ </div>
861
+ <div style={{ color: colors.textMuted, fontSize: 11 }}>
862
+ {annotation.geometry.shapes.length} shape(s)
863
+ </div>
864
+ </div>
865
+ <button
866
+ type="button"
867
+ style={reviewItemRemove}
868
+ aria-label="Remove annotation"
869
+ onClick={() => dispatch({ type: 'REMOVE_FROM_BATCH', id: annotation.id })}
870
+ >
871
+ ✕
872
+ </button>
873
+ </div>
874
+ ))}
875
+ {state.sendError && <div style={errorRow}>{state.sendError}</div>}
876
+ <div style={buttonRow}>
877
+ <button
878
+ type="button"
879
+ style={dangerButton}
880
+ onClick={() => dispatch({ type: 'DISCARD_ALL' })}
881
+ >
882
+ Discard all
883
+ </button>
884
+ <button
885
+ type="button"
886
+ style={primaryButton}
887
+ disabled={state.sending}
888
+ onClick={handleSend}
889
+ >
890
+ {state.sending ? 'Copying…' : `Copy ${state.batch.length} for agent`}
891
+ </button>
892
+ </div>
893
+ </div>
894
+ )}
895
+
896
+ {showSent && <div style={sentBanner}>Copied to clipboard ✓ — paste into your agent</div>}
897
+ </div>
898
+ );
899
+ }
900
+
901
+ export default FeedbackOverlay;