@peaske7/readit 0.2.0 → 0.3.0-rc.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 (179) hide show
  1. package/.claude/CLAUDE.md +118 -76
  2. package/.claude/commands/review.md +1 -1
  3. package/.claude/roadmap.md +32 -9
  4. package/.claude/user-stories.md +100 -15
  5. package/AGENTS.md +30 -26
  6. package/Makefile +32 -0
  7. package/README.md +90 -2
  8. package/biome.json +18 -8
  9. package/bun.lock +426 -568
  10. package/bunfig.toml +2 -0
  11. package/docs/perf-baseline.md +56 -1
  12. package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
  13. package/e2e/comments.spec.ts +14 -58
  14. package/e2e/document-load.spec.ts +1 -23
  15. package/e2e/export.spec.ts +4 -4
  16. package/e2e/perf/add-comment.spec.ts +9 -11
  17. package/e2e/perf/fixtures/generate.ts +1 -5
  18. package/e2e/perf/screenshot-final.png +0 -0
  19. package/e2e/perf/utils/metrics.ts +73 -9
  20. package/e2e/persistence-file.spec.ts +41 -26
  21. package/e2e/utils/selection.ts +17 -73
  22. package/go/cmd/readit/main.go +416 -0
  23. package/go/go.mod +20 -0
  24. package/go/go.sum +41 -0
  25. package/go/internal/server/anchor.go +302 -0
  26. package/go/internal/server/anchor_test.go +111 -0
  27. package/go/internal/server/comments.go +390 -0
  28. package/go/internal/server/documents.go +113 -0
  29. package/go/internal/server/embed.go +17 -0
  30. package/go/internal/server/headings.go +33 -0
  31. package/go/internal/server/headings_test.go +75 -0
  32. package/go/internal/server/htmltext.go +123 -0
  33. package/go/internal/server/markdown.go +157 -0
  34. package/go/internal/server/markdown_bench_test.go +42 -0
  35. package/go/internal/server/markdown_test.go +79 -0
  36. package/go/internal/server/server.go +453 -0
  37. package/go/internal/server/server_bench_test.go +122 -0
  38. package/go/internal/server/settings.go +110 -0
  39. package/go/internal/server/sse.go +140 -0
  40. package/go/internal/server/storage.go +275 -0
  41. package/go/internal/server/storage_test.go +152 -0
  42. package/go/internal/server/template.go +66 -0
  43. package/go/internal/server/types.go +101 -0
  44. package/go/internal/server/watcher.go +74 -0
  45. package/index.html +4 -14
  46. package/nvim-readit/lua/readit/health.lua +64 -0
  47. package/nvim-readit/lua/readit/init.lua +463 -0
  48. package/nvim-readit/plugin/readit.lua +19 -0
  49. package/package.json +20 -28
  50. package/shell/_readit +158 -0
  51. package/shell/readit.zsh +87 -0
  52. package/src/App.svelte +890 -0
  53. package/src/cli.ts +183 -21
  54. package/src/components/ActionsMenu.svelte +95 -0
  55. package/src/components/CommentBadge.svelte +67 -0
  56. package/src/components/CommentErrorBanner.svelte +33 -0
  57. package/src/components/CommentInput.svelte +75 -0
  58. package/src/components/CommentListItem.svelte +95 -0
  59. package/src/components/CommentManager.svelte +129 -0
  60. package/src/components/CommentNav.svelte +109 -0
  61. package/src/components/DocumentViewer.svelte +233 -0
  62. package/src/components/FloatingComment.svelte +107 -0
  63. package/src/components/Header.svelte +76 -0
  64. package/src/components/InlineEditor.svelte +72 -0
  65. package/src/components/MarginNote.svelte +167 -0
  66. package/src/components/MarginNotesContainer.svelte +33 -0
  67. package/src/components/MermaidEnhancer.svelte +218 -0
  68. package/src/components/MermaidModal.svelte +67 -0
  69. package/src/components/RawModal.svelte +126 -0
  70. package/src/components/ReanchorConfirm.svelte +30 -0
  71. package/src/components/SettingsModal.svelte +220 -0
  72. package/src/components/ShortcutCapture.svelte +82 -0
  73. package/src/components/ShortcutList.svelte +145 -0
  74. package/src/components/TabBar.svelte +52 -0
  75. package/src/components/TableOfContents.svelte +125 -0
  76. package/src/components/ui/ActionLink.svelte +40 -0
  77. package/src/components/ui/{Button.tsx → Button.svelte} +19 -20
  78. package/src/components/ui/Dialog.svelte +97 -0
  79. package/src/components/ui/DropdownMenu.svelte +85 -0
  80. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  81. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  82. package/src/components/ui/{Text.tsx → Text.svelte} +18 -23
  83. package/src/env.d.ts +6 -0
  84. package/src/index.css +141 -166
  85. package/src/lib/__fixtures__/bench-data.ts +0 -13
  86. package/src/lib/anchor.bench.ts +1 -12
  87. package/src/lib/anchor.test.ts +0 -8
  88. package/src/lib/anchor.ts +0 -4
  89. package/src/lib/comment-storage.bench.ts +49 -0
  90. package/src/lib/comment-storage.test.ts +103 -33
  91. package/src/lib/comment-storage.ts +25 -18
  92. package/src/lib/export.bench.ts +21 -0
  93. package/src/lib/export.ts +0 -1
  94. package/src/lib/fetch-or-throw.test.ts +59 -0
  95. package/src/lib/fetch-or-throw.ts +12 -0
  96. package/src/{hooks/useHeadings.test.ts → lib/headings.test.ts} +10 -24
  97. package/src/{hooks/useHeadings.ts → lib/headings.ts} +11 -13
  98. package/src/lib/highlight/core.test.ts +0 -5
  99. package/src/lib/highlight/dom.ts +52 -216
  100. package/src/lib/highlight/highlight-registry.ts +221 -0
  101. package/src/lib/highlight/highlight.bench.ts +92 -0
  102. package/src/lib/highlight/highlighter.ts +112 -132
  103. package/src/lib/highlight/resolver.ts +5 -79
  104. package/src/lib/highlight/types.ts +0 -5
  105. package/src/lib/html-text.test.ts +162 -0
  106. package/src/lib/html-text.ts +161 -0
  107. package/src/lib/i18n/en.ts +34 -0
  108. package/src/lib/i18n/ja.ts +34 -0
  109. package/src/lib/i18n/types.ts +33 -0
  110. package/src/lib/key-lock.test.ts +104 -0
  111. package/src/lib/key-lock.ts +23 -0
  112. package/src/lib/margin-layout.bench.ts +61 -0
  113. package/src/lib/margin-layout.ts +0 -7
  114. package/src/lib/markdown-renderer.test.ts +154 -0
  115. package/src/lib/markdown-renderer.ts +178 -0
  116. package/src/lib/mermaid-config.ts +38 -0
  117. package/src/lib/mermaid-renderer.ts +162 -0
  118. package/src/lib/mermaid-worker.ts +60 -0
  119. package/src/lib/positions.ts +31 -24
  120. package/src/lib/shortcut-registry.ts +244 -0
  121. package/src/lib/utils.ts +0 -29
  122. package/src/main.ts +16 -0
  123. package/src/schema.ts +16 -5
  124. package/src/server.ts +355 -95
  125. package/src/stores/app.svelte.ts +231 -0
  126. package/src/stores/locale.svelte.ts +46 -0
  127. package/src/stores/settings.svelte.ts +90 -0
  128. package/src/stores/shortcuts.svelte.ts +104 -0
  129. package/src/stores/ui.svelte.ts +12 -0
  130. package/src/template.ts +104 -0
  131. package/src/test-setup.ts +47 -0
  132. package/svelte.config.js +5 -0
  133. package/tsconfig.json +2 -2
  134. package/vite.config.ts +23 -3
  135. package/vscode-readit/.mcp.json +7 -0
  136. package/vscode-readit/.vscodeignore +7 -0
  137. package/vscode-readit/bun.lock +78 -0
  138. package/vscode-readit/icon.svg +10 -0
  139. package/vscode-readit/package.json +110 -0
  140. package/vscode-readit/src/extension.ts +117 -0
  141. package/vscode-readit/src/server-manager.ts +272 -0
  142. package/vscode-readit/src/webview-provider.ts +204 -0
  143. package/vscode-readit/tsconfig.json +20 -0
  144. package/e2e/fixtures/sample.html +0 -13
  145. package/src/App.tsx +0 -368
  146. package/src/components/ActionsMenu.tsx +0 -91
  147. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  148. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -230
  149. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -136
  150. package/src/components/Header.tsx +0 -54
  151. package/src/components/InlineEditor.tsx +0 -74
  152. package/src/components/MarginNote.tsx +0 -185
  153. package/src/components/MarginNotes.tsx +0 -23
  154. package/src/components/RawModal.tsx +0 -144
  155. package/src/components/ReanchorConfirm.tsx +0 -36
  156. package/src/components/SettingsModal.tsx +0 -232
  157. package/src/components/TabBar.tsx +0 -60
  158. package/src/components/TableOfContents.tsx +0 -108
  159. package/src/components/comments/CommentBadge.tsx +0 -49
  160. package/src/components/comments/CommentInput.tsx +0 -86
  161. package/src/components/comments/CommentListItem.tsx +0 -90
  162. package/src/components/comments/CommentManager.tsx +0 -129
  163. package/src/components/comments/CommentNav.tsx +0 -109
  164. package/src/components/ui/ActionLink.tsx +0 -28
  165. package/src/components/ui/Dialog.tsx +0 -116
  166. package/src/components/ui/DropdownMenu.tsx +0 -158
  167. package/src/contexts/CommentContext.tsx +0 -198
  168. package/src/contexts/LocaleContext.tsx +0 -76
  169. package/src/contexts/PositionsContext.tsx +0 -16
  170. package/src/contexts/SettingsContext.tsx +0 -133
  171. package/src/hooks/useClickOutside.ts +0 -31
  172. package/src/hooks/useCommentNavigation.ts +0 -107
  173. package/src/hooks/useComments.ts +0 -311
  174. package/src/hooks/useDocument.ts +0 -157
  175. package/src/hooks/useScrollSpy.ts +0 -77
  176. package/src/hooks/useTextSelection.ts +0 -86
  177. package/src/lib/highlight/worker.ts +0 -45
  178. package/src/main.tsx +0 -13
  179. package/src/store.ts +0 -222
@@ -6,6 +6,7 @@ export interface LoadMetrics {
6
6
  fcp: number | null;
7
7
  domContentLoaded: number;
8
8
  allHighlightsPainted: number;
9
+ pageReady: number | null;
9
10
  highlightCount: number;
10
11
  }
11
12
 
@@ -45,11 +46,10 @@ export async function waitForHighlightCount(
45
46
  let lastChangeTime = performance.now();
46
47
 
47
48
  const check = () => {
48
- const marks = document.querySelectorAll("mark[data-comment-id]");
49
- const uniqueIds = new Set(
50
- [...marks].map((m) => m.getAttribute("data-comment-id")),
51
- );
52
- const count = uniqueIds.size;
49
+ // Use the CSS Custom Highlight API observability hook
50
+ const highlights = (window as unknown as Record<string, unknown>)
51
+ .__readitHighlights as { commentIds: string[] } | undefined;
52
+ const count = highlights?.commentIds?.length ?? 0;
53
53
 
54
54
  // Exact target reached
55
55
  if (count >= expected) {
@@ -88,6 +88,48 @@ export async function waitForHighlightCount(
88
88
  );
89
89
  }
90
90
 
91
+ // ─── Wait for positions ─────────────────────────────────────────────
92
+
93
+ /**
94
+ * Waits until Positions.cache() has run AFTER highlights were painted.
95
+ * The `afterTimestamp` ensures we don't capture the initial empty cache()
96
+ * that runs before highlights exist.
97
+ */
98
+ async function waitForPositionsReady(
99
+ page: Page,
100
+ afterTimestamp: number,
101
+ timeoutMs = 60_000,
102
+ ): Promise<number> {
103
+ return page.evaluate(
104
+ ({ after, timeout }) => {
105
+ return new Promise<number>((resolve, reject) => {
106
+ const deadline = performance.now() + timeout;
107
+
108
+ const check = () => {
109
+ const ts = (window as unknown as Record<string, unknown>)
110
+ .__readitPositionsReady as number | undefined;
111
+
112
+ // Only resolve if positions were computed AFTER highlights painted
113
+ if (ts !== undefined && ts > after) {
114
+ resolve(ts);
115
+ return;
116
+ }
117
+
118
+ if (performance.now() > deadline) {
119
+ reject(new Error("Timed out waiting for positions to be ready"));
120
+ return;
121
+ }
122
+
123
+ requestAnimationFrame(check);
124
+ };
125
+
126
+ requestAnimationFrame(check);
127
+ });
128
+ },
129
+ { after: afterTimestamp, timeout: timeoutMs },
130
+ );
131
+ }
132
+
91
133
  // ─── Collect load metrics ────────────────────────────────────────────
92
134
 
93
135
  /**
@@ -105,16 +147,36 @@ export async function collectLoadMetrics(
105
147
  expectedComments,
106
148
  );
107
149
 
150
+ // Wait for positions to be computed AFTER highlights
151
+ // (captures the forced reflow cost from getBoundingClientRect)
152
+ let pageReadyTimestamp: number | null;
153
+ try {
154
+ pageReadyTimestamp = await waitForPositionsReady(
155
+ page,
156
+ highlightTimestamp,
157
+ 10_000,
158
+ );
159
+ } catch (error) {
160
+ if (
161
+ !(error instanceof Error) ||
162
+ !error.message.includes("Timed out waiting for positions")
163
+ ) {
164
+ throw error;
165
+ }
166
+ // Positions may not fire in all configurations
167
+ pageReadyTimestamp = null;
168
+ }
169
+
108
170
  const navMetrics = await page.evaluate(() => {
109
171
  const nav = performance.getEntriesByType(
110
172
  "navigation",
111
173
  )[0] as PerformanceNavigationTiming;
112
174
  const paintEntries = performance.getEntriesByType("paint");
113
175
  const fcp = paintEntries.find((e) => e.name === "first-contentful-paint");
114
- const marks = document.querySelectorAll("mark[data-comment-id]");
115
- const actualCount = new Set(
116
- [...marks].map((m) => m.getAttribute("data-comment-id")),
117
- ).size;
176
+ // Use CSS Custom Highlight API observability hook
177
+ const highlights = (window as unknown as Record<string, unknown>)
178
+ .__readitHighlights as { commentIds: string[] } | undefined;
179
+ const actualCount = highlights?.commentIds?.length ?? 0;
118
180
 
119
181
  return {
120
182
  fcp: fcp ? fcp.startTime : null,
@@ -127,6 +189,7 @@ export async function collectLoadMetrics(
127
189
  fcp: navMetrics.fcp,
128
190
  domContentLoaded: navMetrics.domContentLoaded,
129
191
  allHighlightsPainted: highlightTimestamp,
192
+ pageReady: pageReadyTimestamp,
130
193
  highlightCount: navMetrics.actualCount,
131
194
  };
132
195
  }
@@ -240,6 +303,7 @@ export function reportLoadMetrics(
240
303
  ` FCP: ${metrics.fcp !== null ? `${Math.round(metrics.fcp)}ms` : "N/A"}`,
241
304
  ` DOM Content Loaded: ${Math.round(metrics.domContentLoaded)}ms`,
242
305
  ` All highlights painted: ${Math.round(metrics.allHighlightsPainted)}ms`,
306
+ ` Page ready (+ layout): ${metrics.pageReady !== null ? `${Math.round(metrics.pageReady)}ms` : "N/A (positions timed out)"}`,
243
307
  ` Highlights found: ${metrics.highlightCount}`,
244
308
  ];
245
309
  console.log(lines.join("\n"));
@@ -57,7 +57,7 @@ test.describe("File-Based Comment Persistence", () => {
57
57
  await page.goto(url);
58
58
 
59
59
  // Wait for document to load
60
- const article = page.locator("article");
60
+ const article = page.locator("article#document-content");
61
61
  await expect(article).toBeVisible();
62
62
 
63
63
  // Add a comment
@@ -96,7 +96,7 @@ test.describe("File-Based Comment Persistence", () => {
96
96
  try {
97
97
  await page.goto(url);
98
98
 
99
- const article = page.locator("article");
99
+ const article = page.locator("article#document-content");
100
100
  await expect(article).toBeVisible();
101
101
 
102
102
  // Add a comment
@@ -119,8 +119,14 @@ test.describe("File-Based Comment Persistence", () => {
119
119
  await expect(page.locator("body")).toContainText(commentText);
120
120
 
121
121
  // Verify highlight still exists
122
- const highlight = article.locator("mark[data-comment-id]").first();
123
- await expect(highlight).toBeVisible();
122
+ await page.waitForFunction(
123
+ () => {
124
+ const h = (window as unknown as Record<string, unknown>)
125
+ .__readitHighlights as { commentIds: string[] } | undefined;
126
+ return h && h.commentIds.length > 0;
127
+ },
128
+ { timeout: 10_000 },
129
+ );
124
130
  } finally {
125
131
  await cleanup();
126
132
  }
@@ -140,7 +146,7 @@ test.describe("File-Based Comment Persistence", () => {
140
146
  try {
141
147
  await page.goto(url1);
142
148
 
143
- const article = page.locator("article");
149
+ const article = page.locator("article#document-content");
144
150
  await expect(article).toBeVisible();
145
151
 
146
152
  await selectTextInArticle(page, "testing text selection");
@@ -164,15 +170,21 @@ test.describe("File-Based Comment Persistence", () => {
164
170
  try {
165
171
  await page.goto(url2);
166
172
 
167
- const article = page.locator("article");
173
+ const article = page.locator("article#document-content");
168
174
  await expect(article).toBeVisible();
169
175
 
170
176
  // Comment should still exist from previous session
171
177
  await expect(page.locator("body")).toContainText(commentText);
172
178
 
173
179
  // Highlight should still exist
174
- const highlight = article.locator("mark[data-comment-id]").first();
175
- await expect(highlight).toBeVisible();
180
+ await page.waitForFunction(
181
+ () => {
182
+ const h = (window as unknown as Record<string, unknown>)
183
+ .__readitHighlights as { commentIds: string[] } | undefined;
184
+ return h && h.commentIds.length > 0;
185
+ },
186
+ { timeout: 10_000 },
187
+ );
176
188
  } finally {
177
189
  await cleanup2();
178
190
  }
@@ -214,15 +226,19 @@ Pre-existing comment to be cleared.
214
226
  try {
215
227
  await page.goto(url);
216
228
 
217
- const article = page.locator("article");
229
+ const article = page.locator("article#document-content");
218
230
  await expect(article).toBeVisible();
219
231
 
220
232
  // Wait for clean operation to complete
221
233
  await page.waitForTimeout(500);
222
234
 
223
- // Verify no comments in UI
224
- const highlight = article.locator("mark[data-comment-id]");
225
- await expect(highlight).toHaveCount(0);
235
+ // Verify no highlights via observability hook
236
+ const highlightCount = await page.evaluate(() => {
237
+ const h = (window as unknown as Record<string, unknown>)
238
+ .__readitHighlights as { commentIds: string[] } | undefined;
239
+ return h?.commentIds?.length ?? 0;
240
+ });
241
+ expect(highlightCount).toBe(0);
226
242
  } finally {
227
243
  await cleanup();
228
244
  }
@@ -237,7 +253,7 @@ Pre-existing comment to be cleared.
237
253
  try {
238
254
  await page.goto(url);
239
255
 
240
- const article = page.locator("article");
256
+ const article = page.locator("article#document-content");
241
257
  await expect(article).toBeVisible();
242
258
 
243
259
  // Add initial comment
@@ -256,26 +272,25 @@ Pre-existing comment to be cleared.
256
272
  let fileContent = readFileSync(commentPath, "utf-8");
257
273
  expect(fileContent).toContain(initialComment);
258
274
 
259
- // Find the margin note containing the selected text (stable identifier)
260
- const marginNote = page
261
- .locator(".group")
262
- .filter({ hasText: textToSelect })
263
- .first();
264
- await marginNote.hover();
275
+ // Find the margin note by data-comment-id (first one)
276
+ const marginNote = page.locator("[data-comment-id]").first();
265
277
 
266
- // Find and click edit button
267
- const editButton = marginNote.locator('button:has-text("Edit")');
268
- await editButton.click();
278
+ // Force-click edit button (may be hidden behind opacity-0 group-hover)
279
+ const editButton = marginNote.getByText("Edit").first();
280
+ await editButton.click({ force: true });
269
281
 
270
- // Wait for edit mode to activate
271
- const textarea = marginNote.locator("textarea");
282
+ // Wait for edit mode find textarea globally since hasText filter breaks in edit mode
283
+ const textarea = page.locator("[data-comment-id] textarea");
272
284
  await expect(textarea).toBeVisible();
273
285
 
274
286
  // Clear and type new text
275
287
  await textarea.fill("Updated comment text");
276
288
 
277
289
  // Save edit
278
- const saveButton = marginNote.locator('button:has-text("Save")');
290
+ const saveButton = page
291
+ .locator("[data-comment-id]")
292
+ .getByText("Save")
293
+ .first();
279
294
  await saveButton.click();
280
295
 
281
296
  // Wait for file to be updated
@@ -298,7 +313,7 @@ Pre-existing comment to be cleared.
298
313
  try {
299
314
  await page.goto(url);
300
315
 
301
- const article = page.locator("article");
316
+ const article = page.locator("article#document-content");
302
317
  await expect(article).toBeVisible();
303
318
 
304
319
  // Add a comment
@@ -1,4 +1,15 @@
1
- import type { FrameLocator, Page } from "@playwright/test";
1
+ import type { Page } from "@playwright/test";
2
+
3
+ /**
4
+ * Wait for the Svelte app to be fully mounted and interactive.
5
+ * The app sets data-readit-ready="true" on <html> after onMount.
6
+ */
7
+ export async function waitForAppReady(page: Page): Promise<void> {
8
+ await page.waitForFunction(
9
+ () => document.documentElement.dataset.readitReady === "true",
10
+ { timeout: 10_000 },
11
+ );
12
+ }
2
13
 
3
14
  /**
4
15
  * Select text within an article element (for markdown documents)
@@ -8,9 +19,12 @@ export async function selectTextInArticle(
8
19
  page: Page,
9
20
  textToSelect: string,
10
21
  ): Promise<void> {
22
+ // Ensure the Svelte app is fully mounted before dispatching events
23
+ await waitForAppReady(page);
24
+
11
25
  // Find text and calculate offsets, then dispatch custom event
12
26
  await page.evaluate((text) => {
13
- const article = document.querySelector("article");
27
+ const article = document.querySelector("article#document-content");
14
28
  if (!article) throw new Error("Article element not found");
15
29
 
16
30
  const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT);
@@ -39,78 +53,10 @@ export async function selectTextInArticle(
39
53
  throw new Error(`Text "${text}" not found in article`);
40
54
  }, textToSelect);
41
55
 
42
- // Wait for React to process the selection
56
+ // Wait for Svelte to process the selection
43
57
  await page.waitForTimeout(100);
44
58
  }
45
59
 
46
- /**
47
- * Select text within an iframe (for HTML documents)
48
- * Uses custom event to trigger selection handler (same as markdown)
49
- */
50
- export async function selectTextInIframe(
51
- page: Page,
52
- _iframe: FrameLocator,
53
- textToSelect: string,
54
- ): Promise<void> {
55
- // Get the actual frame object for evaluation
56
- const frame = page.frame({ url: /^about:srcdoc/ }) || page.frames()[1];
57
- if (!frame) throw new Error("Could not find iframe frame");
58
-
59
- // Calculate offsets in iframe, then dispatch custom event to parent
60
- const offsets = await frame.evaluate((text) => {
61
- const body = document.body;
62
- const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
63
- let currentOffset = 0;
64
-
65
- while (walker.nextNode()) {
66
- const textNode = walker.currentNode as Text;
67
- const content = textNode.textContent || "";
68
- const index = content.indexOf(text);
69
-
70
- if (index !== -1) {
71
- const startOffset = currentOffset + index;
72
- const endOffset = startOffset + text.length;
73
- return { text, startOffset, endOffset };
74
- }
75
-
76
- currentOffset += content.length;
77
- }
78
-
79
- throw new Error(`Text "${text}" not found in iframe`);
80
- }, textToSelect);
81
-
82
- // Dispatch custom event to parent window (IframeContainer listens for this)
83
- await page.evaluate((detail) => {
84
- const event = new CustomEvent("test:select-text", { detail });
85
- window.dispatchEvent(event);
86
- }, offsets);
87
-
88
- // Wait for React to process state update
89
- await page.waitForTimeout(200);
90
-
91
- // Manually send applyHighlights to iframe (workaround for isReadyRef timing)
92
- // This ensures the iframe gets the highlight even if React's useEffect hasn't fired
93
- await page.evaluate(
94
- (selection) => {
95
- const iframe = document.querySelector("iframe");
96
- if (iframe?.contentWindow) {
97
- iframe.contentWindow.postMessage(
98
- {
99
- type: "applyHighlights",
100
- comments: [],
101
- pendingSelection: selection,
102
- },
103
- "*",
104
- );
105
- }
106
- },
107
- { startOffset: offsets.startOffset, endOffset: offsets.endOffset },
108
- );
109
-
110
- // Wait for iframe to apply highlights
111
- await page.waitForTimeout(200);
112
- }
113
-
114
60
  /**
115
61
  * Add a comment to the current selection
116
62
  * Assumes CommentInputArea is visible
@@ -119,8 +65,6 @@ export async function addComment(
119
65
  page: Page,
120
66
  commentText: string,
121
67
  ): Promise<void> {
122
- // Wait for the comment input textarea to appear
123
- // Give more time for iframe postMessage round-trip
124
68
  const textarea = page.locator('textarea[placeholder="Add your comment..."]');
125
69
  await textarea.waitFor({ state: "visible", timeout: 10000 });
126
70