@ulysses-ai/create-workspace 0.13.0-beta.2 → 0.14.0-beta.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.
@@ -0,0 +1,350 @@
1
+ #!/usr/bin/env node
2
+ // Unit tests for sync-tasks.mjs
3
+ // Run: node template/.claude/scripts/sync-tasks.test.mjs
4
+ import { toActiveForm } from './sync-tasks.mjs';
5
+
6
+ let failed = 0;
7
+ let passed = 0;
8
+
9
+ function assert(cond, msg) {
10
+ if (cond) { passed++; } else { failed++; console.error(` FAIL: ${msg}`); }
11
+ }
12
+
13
+ function assertEq(actual, expected, msg) {
14
+ const a = JSON.stringify(actual);
15
+ const e = JSON.stringify(expected);
16
+ if (a === e) { passed++; } else {
17
+ failed++;
18
+ console.error(` FAIL: ${msg}\n expected: ${e}\n actual: ${a}`);
19
+ }
20
+ }
21
+
22
+ console.log('# toActiveForm');
23
+ assertEq(toActiveForm('Start work'), 'Starting work', 'simple verb');
24
+ assertEq(toActiveForm('Write fix and test'), 'Writing fix and test', 'e-drop');
25
+ assertEq(toActiveForm('Run the tests'), 'Running the tests', 'double consonant');
26
+ assertEq(toActiveForm('Identify race condition'), 'Identifying race condition', 'y-keep');
27
+ assertEq(toActiveForm('Complete work'), 'Completing work', 'e-drop on Complete');
28
+ assertEq(toActiveForm('Reproduce on iOS Safari'), 'Reproducing on iOS Safari', 'e-drop multi-word');
29
+ assertEq(toActiveForm('Fix bug'), 'Fixing bug', 'simple');
30
+
31
+ import { parseTasksSection } from './sync-tasks.mjs';
32
+
33
+ const SAMPLE_WITH_TASKS = `---
34
+ type: session-tracker
35
+ name: demo
36
+ ---
37
+
38
+ # Work Session
39
+
40
+ ## Tasks
41
+
42
+ > Linked: gh:42 — Auth timeout on mobile
43
+
44
+ - [x] Start work
45
+ - [x] Reproduce on iOS Safari
46
+ - [ ] Identify race condition
47
+ - [ ] Complete work
48
+
49
+ ## Progress
50
+
51
+ (stuff)
52
+ `;
53
+
54
+ const SAMPLE_NO_TASKS = `---
55
+ type: session-tracker
56
+ name: demo
57
+ ---
58
+
59
+ # Work Session
60
+
61
+ ## Progress
62
+
63
+ (stuff)
64
+ `;
65
+
66
+ const SAMPLE_NO_LINK = `---
67
+ type: session-tracker
68
+ name: demo
69
+ ---
70
+
71
+ ## Tasks
72
+
73
+ - [x] Start work
74
+ - [ ] Complete work
75
+ `;
76
+
77
+ console.log('\n# parseTasksSection');
78
+ assertEq(
79
+ parseTasksSection(SAMPLE_WITH_TASKS).linked,
80
+ { id: 'gh:42', title: 'Auth timeout on mobile' },
81
+ 'parses linked blockquote'
82
+ );
83
+ assertEq(
84
+ parseTasksSection(SAMPLE_WITH_TASKS).todos.length,
85
+ 4,
86
+ 'parses 4 todos'
87
+ );
88
+ assertEq(
89
+ parseTasksSection(SAMPLE_WITH_TASKS).todos[0],
90
+ { content: 'Start work', activeForm: 'Starting work', status: 'completed' },
91
+ 'first todo completed'
92
+ );
93
+ assertEq(
94
+ parseTasksSection(SAMPLE_WITH_TASKS).todos[2],
95
+ { content: 'Identify race condition', activeForm: 'Identifying race condition', status: 'pending' },
96
+ 'pending todo'
97
+ );
98
+ assertEq(
99
+ parseTasksSection(SAMPLE_NO_TASKS),
100
+ { linked: null, todos: [] },
101
+ 'missing section returns empty'
102
+ );
103
+ assertEq(
104
+ parseTasksSection(SAMPLE_NO_LINK).linked,
105
+ null,
106
+ 'no blockquote → linked: null'
107
+ );
108
+ assertEq(
109
+ parseTasksSection(SAMPLE_NO_LINK).todos.length,
110
+ 2,
111
+ 'no blockquote → still parses todos'
112
+ );
113
+
114
+ import { renderTasksSection, enforceBookends } from './sync-tasks.mjs';
115
+
116
+ console.log('\n# enforceBookends');
117
+ assertEq(
118
+ enforceBookends([]).map(t => t.content),
119
+ ['Start work', 'Complete work'],
120
+ 'empty list → bookends inserted'
121
+ );
122
+ assertEq(
123
+ enforceBookends([
124
+ { content: 'Do thing', activeForm: 'Doing thing', status: 'pending' },
125
+ ]).map(t => t.content),
126
+ ['Start work', 'Do thing', 'Complete work'],
127
+ 'middle task gets wrapped in bookends'
128
+ );
129
+ assertEq(
130
+ enforceBookends([
131
+ { content: 'Complete work', activeForm: 'Completing work', status: 'pending' },
132
+ { content: 'Do thing', activeForm: 'Doing thing', status: 'pending' },
133
+ { content: 'Start work', activeForm: 'Starting work', status: 'completed' },
134
+ ]).map(t => t.content),
135
+ ['Start work', 'Do thing', 'Complete work'],
136
+ 'misplaced bookends moved to ends'
137
+ );
138
+ assertEq(
139
+ enforceBookends([{ content: 'Start work', activeForm: 'Starting work', status: 'completed' }])[0].status,
140
+ 'completed',
141
+ 'preserves Start work status when present'
142
+ );
143
+ assertEq(
144
+ enforceBookends([])[0].status,
145
+ 'completed',
146
+ 'inserted Start work defaults to completed'
147
+ );
148
+ assertEq(
149
+ enforceBookends([])[1].status,
150
+ 'pending',
151
+ 'inserted Complete work defaults to pending'
152
+ );
153
+
154
+ console.log('\n# renderTasksSection');
155
+ assertEq(
156
+ renderTasksSection({
157
+ linked: null,
158
+ todos: [
159
+ { content: 'Start work', activeForm: 'Starting work', status: 'completed' },
160
+ { content: 'Do thing', activeForm: 'Doing thing', status: 'pending' },
161
+ { content: 'Complete work', activeForm: 'Completing work', status: 'pending' },
162
+ ],
163
+ }),
164
+ '## Tasks\n\n- [x] Start work\n- [ ] Do thing\n- [ ] Complete work\n',
165
+ 'no link → no blockquote'
166
+ );
167
+ assertEq(
168
+ renderTasksSection({
169
+ linked: { id: 'gh:42', title: 'Auth timeout on mobile' },
170
+ todos: [
171
+ { content: 'Start work', activeForm: 'Starting work', status: 'completed' },
172
+ { content: 'Complete work', activeForm: 'Completing work', status: 'pending' },
173
+ ],
174
+ }),
175
+ '## Tasks\n\n> Linked: gh:42 — Auth timeout on mobile\n\n- [x] Start work\n- [ ] Complete work\n',
176
+ 'with link → blockquote rendered'
177
+ );
178
+ assertEq(
179
+ renderTasksSection({
180
+ linked: { id: 'gh:42', title: null },
181
+ todos: [
182
+ { content: 'Start work', activeForm: 'Starting work', status: 'completed' },
183
+ { content: 'Complete work', activeForm: 'Completing work', status: 'pending' },
184
+ ],
185
+ }),
186
+ '## Tasks\n\n> Linked: gh:42\n\n- [x] Start work\n- [ ] Complete work\n',
187
+ 'link with null title → bare ID'
188
+ );
189
+
190
+ import { writeTasksToSession } from './sync-tasks.mjs';
191
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'fs';
192
+ import { tmpdir } from 'os';
193
+ import { join } from 'path';
194
+
195
+ console.log('\n# writeTasksToSession');
196
+
197
+ function withTempSession(initialContent, fn) {
198
+ const dir = mkdtempSync(join(tmpdir(), 'sync-tasks-'));
199
+ const file = join(dir, 'session.md');
200
+ writeFileSync(file, initialContent);
201
+ try { fn(file); } finally { rmSync(dir, { recursive: true, force: true }); }
202
+ }
203
+
204
+ const FRESH_SESSION = `---
205
+ type: session-tracker
206
+ name: demo
207
+ workItem: gh:42
208
+ ---
209
+
210
+
211
+ # Work Session: demo
212
+
213
+ description here.
214
+
215
+ ## Progress
216
+
217
+ (Updated as the session progresses)
218
+ `;
219
+
220
+ withTempSession(FRESH_SESSION, (file) => {
221
+ writeTasksToSession(file, {
222
+ linked: { id: 'gh:42', title: 'Auth timeout on mobile' },
223
+ todos: [],
224
+ });
225
+ const after = readFileSync(file, 'utf-8');
226
+ assert(after.includes('## Tasks'), 'inserts ## Tasks section');
227
+ assert(after.includes('> Linked: gh:42 — Auth timeout on mobile'), 'inserts blockquote');
228
+ assert(after.includes('- [x] Start work'), 'inserts Start work bookend');
229
+ assert(after.includes('- [ ] Complete work'), 'inserts Complete work bookend');
230
+ assert(after.includes('## Progress'), 'preserves Progress heading');
231
+ assert(after.includes('(Updated as the session progresses)'), 'preserves Progress body');
232
+ assert(after.startsWith('---\ntype: session-tracker'), 'preserves frontmatter');
233
+ });
234
+
235
+ const SESSION_WITH_TASKS = `---
236
+ type: session-tracker
237
+ name: demo
238
+ ---
239
+
240
+
241
+ # Work Session: demo
242
+
243
+ ## Tasks
244
+
245
+ - [x] Start work
246
+ - [ ] Old task
247
+ - [ ] Complete work
248
+
249
+ ## Progress
250
+
251
+ original progress text
252
+ `;
253
+
254
+ withTempSession(SESSION_WITH_TASKS, (file) => {
255
+ writeTasksToSession(file, {
256
+ linked: null,
257
+ todos: [
258
+ { content: 'Start work', activeForm: 'Starting work', status: 'completed' },
259
+ { content: 'New task', activeForm: 'Doing new task', status: 'in_progress' },
260
+ { content: 'Complete work', activeForm: 'Completing work', status: 'pending' },
261
+ ],
262
+ });
263
+ const after = readFileSync(file, 'utf-8');
264
+ assert(after.includes('- [-] New task'), 'replaced with new task (in_progress → [-])');
265
+ assert(!after.includes('- [ ] Old task'), 'old task removed');
266
+ assert(after.includes('original progress text'), 'preserves Progress body');
267
+ const taskHeadingCount = (after.match(/^## Tasks$/gm) || []).length;
268
+ assertEq(taskHeadingCount, 1, 'exactly one ## Tasks section after rewrite');
269
+ });
270
+
271
+ withTempSession(FRESH_SESSION, (file) => {
272
+ const input = {
273
+ linked: { id: 'gh:42', title: 'Auth timeout on mobile' },
274
+ todos: [
275
+ { content: 'Reproduce', activeForm: 'Reproducing', status: 'completed' },
276
+ { content: 'Fix it', activeForm: 'Fixing it', status: 'pending' },
277
+ ],
278
+ };
279
+ writeTasksToSession(file, input);
280
+ const round = parseTasksSection(readFileSync(file, 'utf-8'));
281
+ assertEq(round.linked, input.linked, 'round-trip linked');
282
+ assertEq(round.todos.length, 4, 'round-trip todos length (with bookends)');
283
+ assertEq(round.todos[1].content, 'Reproduce', 'round-trip middle task content');
284
+ assertEq(round.todos[2].status, 'pending', 'round-trip middle task status');
285
+ });
286
+
287
+ console.log('\n# in_progress round-trip');
288
+
289
+ withTempSession(FRESH_SESSION, (file) => {
290
+ const input = {
291
+ linked: null,
292
+ todos: [
293
+ { content: 'Doing thing', activeForm: 'Doing thing', status: 'in_progress' },
294
+ ],
295
+ };
296
+ writeTasksToSession(file, input);
297
+ const after = readFileSync(file, 'utf-8');
298
+ assert(after.includes('- [-] Doing thing'), 'in_progress renders as [-]');
299
+ const round = parseTasksSection(after);
300
+ const middle = round.todos.find(t => t.content === 'Doing thing');
301
+ assertEq(middle.status, 'in_progress', 'in_progress round-trips losslessly');
302
+ });
303
+
304
+ import { execFileSync } from 'child_process';
305
+ import { fileURLToPath } from 'url';
306
+ import { dirname } from 'path';
307
+
308
+ const SCRIPT_PATH = join(dirname(fileURLToPath(import.meta.url)), 'sync-tasks.mjs');
309
+
310
+ console.log('\n# CLI');
311
+
312
+ withTempSession(FRESH_SESSION, (file) => {
313
+ const input = JSON.stringify({
314
+ todos: [
315
+ { content: 'Start work', activeForm: 'Starting work', status: 'completed' },
316
+ { content: 'Test thing', activeForm: 'Testing thing', status: 'in_progress' },
317
+ { content: 'Complete work', activeForm: 'Completing work', status: 'pending' },
318
+ ],
319
+ });
320
+ execFileSync('node', [SCRIPT_PATH, '--write', file], {
321
+ input,
322
+ encoding: 'utf-8',
323
+ });
324
+ const written = readFileSync(file, 'utf-8');
325
+ assert(written.includes('- [-] Test thing'), 'CLI --write rendered task (in_progress → [-])');
326
+
327
+ const out = execFileSync('node', [SCRIPT_PATH, '--read', file], {
328
+ encoding: 'utf-8',
329
+ });
330
+ const parsed = JSON.parse(out);
331
+ assertEq(parsed.todos.length, 3, 'CLI --read returns 3 todos');
332
+ assertEq(parsed.todos[1].content, 'Test thing', 'CLI --read content');
333
+ });
334
+
335
+ withTempSession('not a session file\n', (file) => {
336
+ let threw = false;
337
+ try {
338
+ execFileSync('node', [SCRIPT_PATH, '--read', file], {
339
+ encoding: 'utf-8',
340
+ stdio: ['pipe', 'pipe', 'pipe'],
341
+ });
342
+ } catch (e) {
343
+ threw = true;
344
+ assert(e.stderr.includes('Not a session') || e.stderr.includes('frontmatter'), 'errors on non-session file');
345
+ }
346
+ assert(threw, 'CLI throws on non-session file');
347
+ });
348
+
349
+ console.log(`\n${passed} passed, ${failed} failed`);
350
+ process.exit(failed > 0 ? 1 : 0);
@@ -6,13 +6,13 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "node \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"$CLAUDE_PROJECT_DIR\"; else echo \"$CLAUDE_PROJECT_DIR\"; fi)\"/.claude/hooks/workspace-update-check.mjs",
9
+ "command": "node \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"${CLAUDE_PROJECT_DIR:-$PWD}\"; else echo \"${CLAUDE_PROJECT_DIR:-$PWD}\"; fi)\"/.claude/hooks/workspace-update-check.mjs",
10
10
  "timeout": 5000,
11
11
  "statusMessage": "Checking for workspace updates..."
12
12
  },
13
13
  {
14
14
  "type": "command",
15
- "command": "node \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"$CLAUDE_PROJECT_DIR\"; else echo \"$CLAUDE_PROJECT_DIR\"; fi)\"/.claude/hooks/session-start.mjs",
15
+ "command": "node \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"${CLAUDE_PROJECT_DIR:-$PWD}\"; else echo \"${CLAUDE_PROJECT_DIR:-$PWD}\"; fi)\"/.claude/hooks/session-start.mjs",
16
16
  "timeout": 30000,
17
17
  "statusMessage": "Syncing workspace..."
18
18
  }
@@ -24,7 +24,7 @@
24
24
  "hooks": [
25
25
  {
26
26
  "type": "command",
27
- "command": "node \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"$CLAUDE_PROJECT_DIR\"; else echo \"$CLAUDE_PROJECT_DIR\"; fi)\"/.claude/hooks/subagent-start.mjs",
27
+ "command": "node \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"${CLAUDE_PROJECT_DIR:-$PWD}\"; else echo \"${CLAUDE_PROJECT_DIR:-$PWD}\"; fi)\"/.claude/hooks/subagent-start.mjs",
28
28
  "timeout": 5000,
29
29
  "statusMessage": "Loading shared context..."
30
30
  }
@@ -36,7 +36,7 @@
36
36
  "hooks": [
37
37
  {
38
38
  "type": "command",
39
- "command": "node \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"$CLAUDE_PROJECT_DIR\"; else echo \"$CLAUDE_PROJECT_DIR\"; fi)\"/.claude/hooks/pre-compact.mjs",
39
+ "command": "node \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"${CLAUDE_PROJECT_DIR:-$PWD}\"; else echo \"${CLAUDE_PROJECT_DIR:-$PWD}\"; fi)\"/.claude/hooks/pre-compact.mjs",
40
40
  "timeout": 5000,
41
41
  "statusMessage": "Checking for uncaptured context..."
42
42
  }
@@ -48,7 +48,7 @@
48
48
  "hooks": [
49
49
  {
50
50
  "type": "command",
51
- "command": "node \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"$CLAUDE_PROJECT_DIR\"; else echo \"$CLAUDE_PROJECT_DIR\"; fi)\"/.claude/hooks/post-compact.mjs",
51
+ "command": "node \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"${CLAUDE_PROJECT_DIR:-$PWD}\"; else echo \"${CLAUDE_PROJECT_DIR:-$PWD}\"; fi)\"/.claude/hooks/post-compact.mjs",
52
52
  "timeout": 5000
53
53
  }
54
54
  ]
@@ -59,12 +59,17 @@
59
59
  "hooks": [
60
60
  {
61
61
  "type": "command",
62
- "command": "node \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"$CLAUDE_PROJECT_DIR\"; else echo \"$CLAUDE_PROJECT_DIR\"; fi)\"/.claude/hooks/workspace-update-check.mjs",
62
+ "command": "node \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"${CLAUDE_PROJECT_DIR:-$PWD}\"; else echo \"${CLAUDE_PROJECT_DIR:-$PWD}\"; fi)\"/.claude/hooks/workspace-update-check.mjs",
63
63
  "timeout": 5000
64
64
  },
65
65
  {
66
66
  "type": "command",
67
- "command": "node \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"$CLAUDE_PROJECT_DIR\"; else echo \"$CLAUDE_PROJECT_DIR\"; fi)\"/.claude/hooks/repo-write-detection.mjs",
67
+ "command": "node \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"${CLAUDE_PROJECT_DIR:-$PWD}\"; else echo \"${CLAUDE_PROJECT_DIR:-$PWD}\"; fi)\"/.claude/hooks/repo-write-detection.mjs",
68
+ "timeout": 5000
69
+ },
70
+ {
71
+ "type": "command",
72
+ "command": "node \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"${CLAUDE_PROJECT_DIR:-$PWD}\"; else echo \"${CLAUDE_PROJECT_DIR:-$PWD}\"; fi)\"/.claude/hooks/bash-output-advisory.mjs",
68
73
  "timeout": 5000
69
74
  }
70
75
  ]
@@ -75,7 +80,7 @@
75
80
  "hooks": [
76
81
  {
77
82
  "type": "command",
78
- "command": "node \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"$CLAUDE_PROJECT_DIR\"; else echo \"$CLAUDE_PROJECT_DIR\"; fi)\"/.claude/hooks/session-end.mjs",
83
+ "command": "node \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"${CLAUDE_PROJECT_DIR:-$PWD}\"; else echo \"${CLAUDE_PROJECT_DIR:-$PWD}\"; fi)\"/.claude/hooks/session-end.mjs",
79
84
  "timeout": 15000,
80
85
  "statusMessage": "Saving session state..."
81
86
  }
@@ -87,7 +92,7 @@
87
92
  "hooks": [
88
93
  {
89
94
  "type": "command",
90
- "command": "node \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"$CLAUDE_PROJECT_DIR\"; else echo \"$CLAUDE_PROJECT_DIR\"; fi)\"/.claude/hooks/worktree-create.mjs",
95
+ "command": "node \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"${CLAUDE_PROJECT_DIR:-$PWD}\"; else echo \"${CLAUDE_PROJECT_DIR:-$PWD}\"; fi)\"/.claude/hooks/worktree-create.mjs",
91
96
  "timeout": 5000,
92
97
  "statusMessage": "Checking for stale worktrees..."
93
98
  }
@@ -72,6 +72,21 @@ updated: {YYYY-MM-DD}
72
72
  3. If multiple topics: "I see discussion about {topic-1} and {topic-2}. Split into separate braindumps?"
73
73
  4. Proceed with named flow for each
74
74
 
75
+ ## Include task snapshot
76
+
77
+ If an active session exists (detected via `.claude/.active-session.json`), include a `## Tasks at capture time` section in the braindump artifact with a snapshot of the current `TodoWrite` state:
78
+
79
+ ```markdown
80
+ ## Tasks at capture time
81
+
82
+ - [x] Start work
83
+ - [x] Reproduce on iOS Safari
84
+ - [ ] Identify race condition
85
+ - [ ] Complete work
86
+ ```
87
+
88
+ Use the same GFM checkbox format as `session.md`'s `## Tasks` section (just `content` and `status` per task — no `activeForm` field, no blockquote line) and render it inline in the braindump. Do NOT call `sync-tasks.mjs --write` — braindumps are snapshots, not the canonical store.
89
+
75
90
  ## Updating Existing Braindumps
76
91
 
77
92
  When updating, rewrite as a fresh snapshot (coherent-revisions rule). The updated braindump should read as if written in one pass.
@@ -298,7 +298,7 @@ Reuse aggressively — if the same concept appears in multiple chapters, import
298
298
 
299
299
  - **CSS variables in SVG fill don't paint reliably** — always use `className={cls.fill.X}`, never `fill={...}`. The primitives already do this.
300
300
  - **Bulk migration regressions** — if you wrote chapter components with `fill={colors.X}` and need to migrate, run `python3 .claude/skills/build-docs-site/scripts/bulk-fill-migration.py {chapters-dir}`. The script handles duplicate `className=`, missing imports, and reports variable-bound fills for manual review.
301
- - **Playwright viewport screenshots lie** — if a screenshot is blank, use DOM inspection via `browser_evaluate` before assuming the diagram is broken.
301
+ - **Automated viewport screenshots can lie** — if a screenshot from the browser automation tool comes back blank, inspect the DOM via the tool's evaluate hook before assuming the diagram is broken.
302
302
  - **Arrowhead markers don't theme-switch** — acceptable cosmetic mismatch, or render two markers with media query switching.
303
303
  - **SSR for browser-only diagrams** — wrap any diagram that uses browser APIs in `<BrowserOnly>` with a function child. The primitives don't need this; it only matters if a chapter component uses `window` or `document`.
304
304
 
@@ -121,15 +121,15 @@ If the build fails on `cls is not defined`, manually add `cls` to the import lin
121
121
 
122
122
  ---
123
123
 
124
- ## 3. Playwright viewport screenshot quirks
124
+ ## 3. Automated viewport screenshots can lie
125
125
 
126
126
  ### Symptom
127
127
 
128
- Playwright viewport-cropped screenshots of diagram regions come back blank, even though the diagrams render correctly when you visit the page in a real browser.
128
+ Viewport-cropped screenshots of diagram regions come back blank from the browser automation tool, even though the diagrams render correctly when you load the page in a real browser.
129
129
 
130
130
  ### Cause
131
131
 
132
- Best guess: scroll position and viewport sizing interact in Playwright's screenshot path such that the diagram region falls outside the captured viewport even when the visible browser shows it. Never fully diagnosed — the workaround is sufficient.
132
+ Scroll position and viewport sizing interact with the automated screenshot path such that the diagram region falls outside the captured frame even when the visible browser shows it. The exact interaction varies by tool and rarely rewards deep diagnosis — the workaround below is always sufficient.
133
133
 
134
134
  ### Fix
135
135
 
@@ -140,7 +140,7 @@ Best guess: scroll position and viewport sizing interact in Playwright's screens
140
140
  await page.screenshot({ fullPage: true });
141
141
  ```
142
142
 
143
- 2. **DOM inspection via `browser_evaluate`** (fastest, most authoritative):
143
+ 2. **DOM inspection via the automation tool's evaluate hook** (fastest, most authoritative):
144
144
  ```js
145
145
  const rect = document.querySelector('.dx-fill-primary');
146
146
  console.log({