@peaske7/readit 0.1.8 → 0.2.1

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 (221) 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 -5
  8. package/biome.json +18 -8
  9. package/bun.lock +426 -710
  10. package/bunfig.toml +2 -0
  11. package/docs/perf-baseline.md +130 -0
  12. package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
  13. package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
  14. package/e2e/comments.spec.ts +14 -58
  15. package/e2e/document-load.spec.ts +1 -23
  16. package/e2e/export.spec.ts +4 -4
  17. package/e2e/perf/add-comment.spec.ts +116 -0
  18. package/e2e/perf/fixtures/generate.ts +327 -0
  19. package/e2e/perf/initial-load.spec.ts +49 -0
  20. package/e2e/perf/perf.setup.ts +23 -0
  21. package/e2e/perf/perf.teardown.ts +9 -0
  22. package/e2e/perf/screenshot-final.png +0 -0
  23. package/e2e/perf/scroll.spec.ts +39 -0
  24. package/e2e/perf/tab-switch.spec.ts +69 -0
  25. package/e2e/perf/text-selection.spec.ts +119 -0
  26. package/e2e/perf/utils/metrics.ts +350 -0
  27. package/e2e/perf/utils/perf-cli.ts +86 -0
  28. package/e2e/persistence-file.spec.ts +41 -26
  29. package/e2e/utils/selection.ts +17 -73
  30. package/go/cmd/readit/main.go +416 -0
  31. package/go/go.mod +20 -0
  32. package/go/go.sum +41 -0
  33. package/go/internal/server/anchor.go +302 -0
  34. package/go/internal/server/anchor_test.go +111 -0
  35. package/go/internal/server/comments.go +390 -0
  36. package/go/internal/server/documents.go +113 -0
  37. package/go/internal/server/embed.go +17 -0
  38. package/go/internal/server/headings.go +33 -0
  39. package/go/internal/server/headings_test.go +75 -0
  40. package/go/internal/server/htmltext.go +123 -0
  41. package/go/internal/server/markdown.go +157 -0
  42. package/go/internal/server/markdown_bench_test.go +42 -0
  43. package/go/internal/server/markdown_test.go +79 -0
  44. package/go/internal/server/server.go +453 -0
  45. package/go/internal/server/server_bench_test.go +122 -0
  46. package/go/internal/server/settings.go +110 -0
  47. package/go/internal/server/sse.go +140 -0
  48. package/go/internal/server/storage.go +275 -0
  49. package/go/internal/server/storage_test.go +118 -0
  50. package/go/internal/server/template.go +66 -0
  51. package/go/internal/server/types.go +101 -0
  52. package/go/internal/server/watcher.go +74 -0
  53. package/index.html +4 -14
  54. package/nvim-readit/lua/readit/health.lua +64 -0
  55. package/nvim-readit/lua/readit/init.lua +463 -0
  56. package/nvim-readit/plugin/readit.lua +19 -0
  57. package/package.json +24 -41
  58. package/playwright.config.ts +12 -0
  59. package/shell/_readit +158 -0
  60. package/shell/readit.zsh +87 -0
  61. package/src/App.svelte +881 -0
  62. package/src/{cli/index.ts → cli.ts} +216 -70
  63. package/src/components/ActionsMenu.svelte +95 -0
  64. package/src/components/CommentBadge.svelte +67 -0
  65. package/src/components/CommentErrorBanner.svelte +33 -0
  66. package/src/components/CommentInput.svelte +75 -0
  67. package/src/components/CommentListItem.svelte +95 -0
  68. package/src/components/CommentManager.svelte +129 -0
  69. package/src/components/CommentNav.svelte +109 -0
  70. package/src/components/DocumentViewer.svelte +218 -0
  71. package/src/components/FloatingComment.svelte +107 -0
  72. package/src/components/Header.svelte +76 -0
  73. package/src/components/InlineEditor.svelte +72 -0
  74. package/src/components/MarginNote.svelte +167 -0
  75. package/src/components/MarginNotesContainer.svelte +33 -0
  76. package/src/components/RawModal.svelte +126 -0
  77. package/src/components/ReanchorConfirm.svelte +30 -0
  78. package/src/components/SettingsModal.svelte +220 -0
  79. package/src/components/ShortcutCapture.svelte +82 -0
  80. package/src/components/ShortcutList.svelte +145 -0
  81. package/src/components/TabBar.svelte +52 -0
  82. package/src/components/TableOfContents.svelte +125 -0
  83. package/src/components/ui/ActionLink.svelte +40 -0
  84. package/src/components/ui/Button.svelte +53 -0
  85. package/src/components/ui/Dialog.svelte +97 -0
  86. package/src/components/ui/DropdownMenu.svelte +85 -0
  87. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  88. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  89. package/src/components/ui/Text.svelte +42 -0
  90. package/src/env.d.ts +6 -0
  91. package/src/index.css +36 -166
  92. package/src/lib/__fixtures__/bench-data.ts +1 -54
  93. package/src/lib/anchor.bench.ts +47 -68
  94. package/src/lib/anchor.test.ts +5 -9
  95. package/src/lib/anchor.ts +9 -93
  96. package/src/lib/comment-storage.bench.ts +6 -20
  97. package/src/lib/comment-storage.test.ts +45 -37
  98. package/src/lib/comment-storage.ts +23 -64
  99. package/src/lib/export.bench.ts +9 -23
  100. package/src/lib/export.ts +7 -14
  101. package/src/lib/headings.test.ts +103 -0
  102. package/src/lib/headings.ts +44 -0
  103. package/src/lib/highlight/core.test.ts +1 -6
  104. package/src/lib/highlight/dom.ts +53 -280
  105. package/src/lib/highlight/highlight-registry.ts +221 -0
  106. package/src/lib/highlight/highlight.bench.ts +92 -0
  107. package/src/lib/highlight/highlighter.ts +122 -302
  108. package/src/lib/highlight/{core.ts → resolver.ts} +3 -19
  109. package/src/lib/highlight/types.ts +0 -40
  110. package/src/lib/html-text.test.ts +162 -0
  111. package/src/lib/html-text.ts +161 -0
  112. package/src/lib/i18n/en.ts +13 -36
  113. package/src/lib/i18n/ja.ts +14 -37
  114. package/src/lib/i18n/types.ts +13 -36
  115. package/src/lib/margin-layout.bench.ts +48 -15
  116. package/src/lib/margin-layout.ts +2 -31
  117. package/src/lib/markdown-renderer.test.ts +154 -0
  118. package/src/lib/markdown-renderer.ts +177 -0
  119. package/src/lib/mermaid-config.ts +38 -0
  120. package/src/lib/mermaid-renderer.ts +162 -0
  121. package/src/lib/mermaid-worker.ts +60 -0
  122. package/src/lib/positions.ts +157 -0
  123. package/src/lib/shortcut-registry.ts +138 -103
  124. package/src/lib/utils.ts +2 -48
  125. package/src/main.ts +16 -0
  126. package/src/schema.ts +92 -0
  127. package/src/{server/index.ts → server.ts} +427 -163
  128. package/src/stores/app.svelte.ts +231 -0
  129. package/src/stores/locale.svelte.ts +46 -0
  130. package/src/stores/settings.svelte.ts +90 -0
  131. package/src/stores/shortcuts.svelte.ts +104 -0
  132. package/src/stores/ui.svelte.ts +12 -0
  133. package/src/template.ts +104 -0
  134. package/src/test-setup.ts +47 -0
  135. package/svelte.config.js +5 -0
  136. package/tsconfig.json +2 -2
  137. package/vite.config.ts +31 -3
  138. package/vscode-readit/.mcp.json +7 -0
  139. package/vscode-readit/.vscodeignore +7 -0
  140. package/vscode-readit/bun.lock +78 -0
  141. package/vscode-readit/icon.svg +10 -0
  142. package/vscode-readit/package.json +110 -0
  143. package/vscode-readit/src/extension.ts +117 -0
  144. package/vscode-readit/src/server-manager.ts +272 -0
  145. package/vscode-readit/src/webview-provider.ts +204 -0
  146. package/vscode-readit/tsconfig.json +20 -0
  147. package/e2e/fixtures/sample.html +0 -13
  148. package/src/App.tsx +0 -416
  149. package/src/components/ActionsMenu.tsx +0 -112
  150. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  151. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -259
  152. package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
  153. package/src/components/DocumentViewer/InlineCode.tsx +0 -60
  154. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -137
  155. package/src/components/DocumentViewer/index.ts +0 -1
  156. package/src/components/FloatingTOC.tsx +0 -61
  157. package/src/components/Header.tsx +0 -65
  158. package/src/components/InlineEditor.tsx +0 -74
  159. package/src/components/MarginNote.tsx +0 -207
  160. package/src/components/MarginNotes.tsx +0 -50
  161. package/src/components/RawModal.tsx +0 -143
  162. package/src/components/ReanchorConfirm.tsx +0 -36
  163. package/src/components/SettingsModal.tsx +0 -310
  164. package/src/components/ShortcutCapture.tsx +0 -48
  165. package/src/components/ShortcutList.tsx +0 -198
  166. package/src/components/TabBar.tsx +0 -60
  167. package/src/components/TableOfContents.tsx +0 -108
  168. package/src/components/comments/CommentBadge.tsx +0 -49
  169. package/src/components/comments/CommentInput.tsx +0 -114
  170. package/src/components/comments/CommentListItem.tsx +0 -92
  171. package/src/components/comments/CommentManager.tsx +0 -113
  172. package/src/components/comments/CommentMinimap.tsx +0 -62
  173. package/src/components/comments/CommentNav.tsx +0 -109
  174. package/src/components/ui/ActionBar.tsx +0 -16
  175. package/src/components/ui/ActionLink.tsx +0 -32
  176. package/src/components/ui/Button.tsx +0 -55
  177. package/src/components/ui/Dialog.tsx +0 -156
  178. package/src/components/ui/DropdownMenu.tsx +0 -114
  179. package/src/components/ui/SeparatorDot.tsx +0 -9
  180. package/src/components/ui/Text.tsx +0 -54
  181. package/src/contexts/CommentContext.tsx +0 -229
  182. package/src/contexts/LayoutContext.tsx +0 -88
  183. package/src/contexts/LocaleContext.tsx +0 -35
  184. package/src/hooks/useClickOutside.ts +0 -35
  185. package/src/hooks/useClipboard.ts +0 -82
  186. package/src/hooks/useCommentNavigation.ts +0 -130
  187. package/src/hooks/useComments.ts +0 -323
  188. package/src/hooks/useDocument.ts +0 -156
  189. package/src/hooks/useEditorScheme.ts +0 -51
  190. package/src/hooks/useFontPreference.ts +0 -59
  191. package/src/hooks/useHeadings.test.ts +0 -159
  192. package/src/hooks/useHeadings.ts +0 -129
  193. package/src/hooks/useKeybindings.ts +0 -108
  194. package/src/hooks/useKeyboardShortcuts.ts +0 -63
  195. package/src/hooks/useLayoutMode.ts +0 -44
  196. package/src/hooks/useLocalePreference.ts +0 -42
  197. package/src/hooks/useReanchorMode.ts +0 -33
  198. package/src/hooks/useScrollMetrics.ts +0 -56
  199. package/src/hooks/useScrollSpy.ts +0 -81
  200. package/src/hooks/useTextSelection.ts +0 -123
  201. package/src/hooks/useThemePreference.ts +0 -66
  202. package/src/lib/context.bench.ts +0 -41
  203. package/src/lib/context.test.ts +0 -224
  204. package/src/lib/context.ts +0 -193
  205. package/src/lib/editor-links.ts +0 -59
  206. package/src/lib/highlight/colors.ts +0 -37
  207. package/src/lib/highlight/index.ts +0 -23
  208. package/src/lib/highlight/script-builder.ts +0 -485
  209. package/src/lib/html-processor.test.tsx +0 -170
  210. package/src/lib/html-processor.tsx +0 -95
  211. package/src/lib/i18n/completeness.test.ts +0 -51
  212. package/src/lib/i18n/translations.test.ts +0 -39
  213. package/src/lib/layout-constants.ts +0 -12
  214. package/src/lib/scroll.test.ts +0 -118
  215. package/src/lib/scroll.ts +0 -47
  216. package/src/lib/shortcut-registry.test.ts +0 -173
  217. package/src/lib/utils.test.ts +0 -110
  218. package/src/main.tsx +0 -13
  219. package/src/store/index.test.ts +0 -242
  220. package/src/store/index.ts +0 -254
  221. package/src/types/index.ts +0 -127
@@ -0,0 +1,350 @@
1
+ import type { Page, TestInfo } from "@playwright/test";
2
+
3
+ // ─── Types ───────────────────────────────────────────────────────────
4
+
5
+ export interface LoadMetrics {
6
+ fcp: number | null;
7
+ domContentLoaded: number;
8
+ allHighlightsPainted: number;
9
+ pageReady: number | null;
10
+ highlightCount: number;
11
+ }
12
+
13
+ export interface ScrollMetrics {
14
+ totalTimeMs: number;
15
+ longTaskCount: number;
16
+ longTaskDurations: number[];
17
+ p50: number;
18
+ p95: number;
19
+ p99: number;
20
+ }
21
+
22
+ export interface InteractionMetrics {
23
+ durationMs: number;
24
+ }
25
+
26
+ // ─── Wait for highlights ─────────────────────────────────────────────
27
+
28
+ /**
29
+ * Polls the DOM for comment highlights until either:
30
+ * - The expected count is reached, OR
31
+ * - The count stabilizes (no change for `stableMs` milliseconds)
32
+ *
33
+ * Returns the timestamp when highlights finished painting.
34
+ * Not all comments may resolve to highlights (anchor failures, overlaps).
35
+ */
36
+ export async function waitForHighlightCount(
37
+ page: Page,
38
+ expectedCount: number,
39
+ timeoutMs = 60_000,
40
+ ): Promise<number> {
41
+ return page.evaluate(
42
+ ({ expected, timeout, stableWindow }) => {
43
+ return new Promise<number>((resolve, reject) => {
44
+ const deadline = performance.now() + timeout;
45
+ let lastCount = 0;
46
+ let lastChangeTime = performance.now();
47
+
48
+ const check = () => {
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
+
54
+ // Exact target reached
55
+ if (count >= expected) {
56
+ resolve(performance.now());
57
+ return;
58
+ }
59
+
60
+ // Track changes for stabilization
61
+ if (count !== lastCount) {
62
+ lastCount = count;
63
+ lastChangeTime = performance.now();
64
+ }
65
+
66
+ // Stabilized: count hasn't changed for stableWindow ms and we have > 0 highlights
67
+ if (count > 0 && performance.now() - lastChangeTime > stableWindow) {
68
+ resolve(performance.now());
69
+ return;
70
+ }
71
+
72
+ if (performance.now() > deadline) {
73
+ reject(
74
+ new Error(
75
+ `Timed out: expected ${expected} highlights, found ${count}`,
76
+ ),
77
+ );
78
+ return;
79
+ }
80
+
81
+ requestAnimationFrame(check);
82
+ };
83
+
84
+ requestAnimationFrame(check);
85
+ });
86
+ },
87
+ { expected: expectedCount, timeout: timeoutMs, stableWindow: 2000 },
88
+ );
89
+ }
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
+
133
+ // ─── Collect load metrics ────────────────────────────────────────────
134
+
135
+ /**
136
+ * Navigates to the URL and collects load + highlight timing metrics.
137
+ */
138
+ export async function collectLoadMetrics(
139
+ page: Page,
140
+ url: string,
141
+ expectedComments: number,
142
+ ): Promise<LoadMetrics> {
143
+ await page.goto(url);
144
+
145
+ const highlightTimestamp = await waitForHighlightCount(
146
+ page,
147
+ expectedComments,
148
+ );
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
+
170
+ const navMetrics = await page.evaluate(() => {
171
+ const nav = performance.getEntriesByType(
172
+ "navigation",
173
+ )[0] as PerformanceNavigationTiming;
174
+ const paintEntries = performance.getEntriesByType("paint");
175
+ const fcp = paintEntries.find((e) => e.name === "first-contentful-paint");
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;
180
+
181
+ return {
182
+ fcp: fcp ? fcp.startTime : null,
183
+ domContentLoaded: nav.domContentLoadedEventEnd,
184
+ actualCount,
185
+ };
186
+ });
187
+
188
+ return {
189
+ fcp: navMetrics.fcp,
190
+ domContentLoaded: navMetrics.domContentLoaded,
191
+ allHighlightsPainted: highlightTimestamp,
192
+ pageReady: pageReadyTimestamp,
193
+ highlightCount: navMetrics.actualCount,
194
+ };
195
+ }
196
+
197
+ // ─── Collect scroll metrics ──────────────────────────────────────────
198
+
199
+ /**
200
+ * Scrolls programmatically from top to bottom, collecting Long Task entries.
201
+ */
202
+ export async function collectScrollMetrics(
203
+ page: Page,
204
+ stepPx = 600,
205
+ intervalMs = 150,
206
+ ): Promise<ScrollMetrics> {
207
+ return page.evaluate(
208
+ ({ step, interval }) => {
209
+ return new Promise<{
210
+ totalTimeMs: number;
211
+ longTaskCount: number;
212
+ longTaskDurations: number[];
213
+ p50: number;
214
+ p95: number;
215
+ p99: number;
216
+ }>((resolve) => {
217
+ const longTasks: number[] = [];
218
+
219
+ // Observe Long Tasks during scroll only (no buffered — excludes page load jank)
220
+ const obs = new PerformanceObserver((list) => {
221
+ for (const entry of list.getEntries()) {
222
+ longTasks.push(entry.duration);
223
+ }
224
+ });
225
+ obs.observe({ type: "longtask" });
226
+
227
+ const totalHeight = document.documentElement.scrollHeight;
228
+ const start = performance.now();
229
+ let currentY = 0;
230
+
231
+ const scrollStep = () => {
232
+ currentY += step;
233
+ window.scrollTo(0, currentY);
234
+
235
+ if (currentY < totalHeight) {
236
+ setTimeout(scrollStep, interval);
237
+ } else {
238
+ // Wait a beat for final Long Tasks to fire
239
+ setTimeout(() => {
240
+ obs.disconnect();
241
+ const elapsed = performance.now() - start;
242
+
243
+ const sorted = [...longTasks].sort((a, b) => a - b);
244
+ const pct = (p: number) => {
245
+ if (sorted.length === 0) return 0;
246
+ const idx = Math.ceil((p / 100) * sorted.length) - 1;
247
+ return sorted[Math.max(0, idx)];
248
+ };
249
+
250
+ resolve({
251
+ totalTimeMs: elapsed,
252
+ longTaskCount: sorted.length,
253
+ longTaskDurations: sorted,
254
+ p50: pct(50),
255
+ p95: pct(95),
256
+ p99: pct(99),
257
+ });
258
+ }, 500);
259
+ }
260
+ };
261
+
262
+ // Start scrolling
263
+ window.scrollTo(0, 0);
264
+ setTimeout(scrollStep, interval);
265
+ });
266
+ },
267
+ { step: stepPx, interval: intervalMs },
268
+ );
269
+ }
270
+
271
+ // ─── Measure interaction timing ──────────────────────────────────────
272
+
273
+ /**
274
+ * Sets a performance mark, runs the trigger, waits for condition, returns duration.
275
+ */
276
+ export async function measureInteraction(
277
+ page: Page,
278
+ name: string,
279
+ trigger: () => Promise<void>,
280
+ waitFor: () => Promise<void>,
281
+ ): Promise<number> {
282
+ await page.evaluate((n) => performance.mark(`${n}-start`), name);
283
+
284
+ await trigger();
285
+ await waitFor();
286
+
287
+ return page.evaluate((n) => {
288
+ performance.mark(`${n}-end`);
289
+ const measure = performance.measure(n, `${n}-start`, `${n}-end`);
290
+ return measure.duration;
291
+ }, name);
292
+ }
293
+
294
+ // ─── Reporting ───────────────────────────────────────────────────────
295
+
296
+ export function reportLoadMetrics(
297
+ testInfo: TestInfo,
298
+ label: string,
299
+ metrics: LoadMetrics,
300
+ ): void {
301
+ const lines = [
302
+ `--- ${label} ---`,
303
+ ` FCP: ${metrics.fcp !== null ? `${Math.round(metrics.fcp)}ms` : "N/A"}`,
304
+ ` DOM Content Loaded: ${Math.round(metrics.domContentLoaded)}ms`,
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)"}`,
307
+ ` Highlights found: ${metrics.highlightCount}`,
308
+ ];
309
+ console.log(lines.join("\n"));
310
+
311
+ testInfo.annotations.push({
312
+ type: "perf-metric",
313
+ description: JSON.stringify({ label, ...metrics }),
314
+ });
315
+ }
316
+
317
+ export function reportScrollMetrics(
318
+ testInfo: TestInfo,
319
+ label: string,
320
+ metrics: ScrollMetrics,
321
+ ): void {
322
+ const lines = [
323
+ `--- ${label} ---`,
324
+ ` Total scroll time: ${Math.round(metrics.totalTimeMs)}ms`,
325
+ ` Long tasks (>50ms): ${metrics.longTaskCount}`,
326
+ ` P50 long task: ${Math.round(metrics.p50)}ms`,
327
+ ` P95 long task: ${Math.round(metrics.p95)}ms`,
328
+ ` P99 long task: ${Math.round(metrics.p99)}ms`,
329
+ ];
330
+ console.log(lines.join("\n"));
331
+
332
+ testInfo.annotations.push({
333
+ type: "perf-metric",
334
+ description: JSON.stringify({ label, ...metrics }),
335
+ });
336
+ }
337
+
338
+ export function reportInteraction(
339
+ testInfo: TestInfo,
340
+ label: string,
341
+ durationMs: number,
342
+ ): void {
343
+ console.log(`--- ${label} ---`);
344
+ console.log(` Duration: ${Math.round(durationMs)}ms`);
345
+
346
+ testInfo.annotations.push({
347
+ type: "perf-metric",
348
+ description: JSON.stringify({ label, durationMs }),
349
+ });
350
+ }
@@ -0,0 +1,86 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+ import { spawn } from "node:child_process";
3
+ import { resolve } from "node:path";
4
+
5
+ interface SpawnPerfCliResult {
6
+ url: string;
7
+ process: ChildProcess;
8
+ cleanup: () => Promise<void>;
9
+ }
10
+
11
+ interface SpawnPerfCliOptions {
12
+ port?: number;
13
+ }
14
+
15
+ const CLI_PATH = resolve(import.meta.dirname, "../../../dist/cli.js");
16
+
17
+ /**
18
+ * Start the readit CLI with one or more fixture files for perf testing.
19
+ * Uses --no-open (no --clean, so pre-seeded comments are loaded).
20
+ */
21
+ export async function spawnPerfCli(
22
+ fixturePaths: string | string[],
23
+ options: SpawnPerfCliOptions = {},
24
+ ): Promise<SpawnPerfCliResult> {
25
+ const { port = 4600 } = options;
26
+
27
+ const paths = Array.isArray(fixturePaths) ? fixturePaths : [fixturePaths];
28
+ const args = [CLI_PATH, ...paths, "--no-open", "--port", String(port)];
29
+
30
+ const cliProcess = spawn("bun", args, {
31
+ cwd: resolve(import.meta.dirname, "../../.."),
32
+ stdio: ["pipe", "pipe", "pipe"],
33
+ });
34
+
35
+ const url = await waitForServerReady(cliProcess);
36
+
37
+ return {
38
+ url,
39
+ process: cliProcess,
40
+ cleanup: async () => {
41
+ cliProcess.kill("SIGTERM");
42
+ await new Promise<void>((resolve) => {
43
+ cliProcess.once("exit", () => resolve());
44
+ setTimeout(resolve, 2000);
45
+ });
46
+ },
47
+ };
48
+ }
49
+
50
+ function waitForServerReady(cliProcess: ChildProcess): Promise<string> {
51
+ return new Promise((resolve, reject) => {
52
+ let output = "";
53
+
54
+ const timeout = setTimeout(() => {
55
+ reject(
56
+ new Error(`Server did not start within timeout. Output: ${output}`),
57
+ );
58
+ }, 30_000);
59
+
60
+ cliProcess.stdout?.on("data", (data: Buffer) => {
61
+ output += data.toString();
62
+
63
+ const urlMatch = output.match(/URL:\s+(http:\/\/[^\s]+)/);
64
+ if (urlMatch) {
65
+ clearTimeout(timeout);
66
+ resolve(urlMatch[1]);
67
+ }
68
+ });
69
+
70
+ cliProcess.stderr?.on("data", (data: Buffer) => {
71
+ output += data.toString();
72
+ });
73
+
74
+ cliProcess.on("error", (err) => {
75
+ clearTimeout(timeout);
76
+ reject(err);
77
+ });
78
+
79
+ cliProcess.on("exit", (code) => {
80
+ if (code !== 0 && code !== null) {
81
+ clearTimeout(timeout);
82
+ reject(new Error(`CLI exited with code ${code}: ${output}`));
83
+ }
84
+ });
85
+ });
86
+ }
@@ -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