@opencraw/studio 0.0.6 → 0.0.8

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 (67) hide show
  1. package/dist/index.esm.js +3632 -861
  2. package/dist/src/document-view/deck-view.mapper.d.ts +70 -0
  3. package/dist/src/document-view/index.d.ts +11 -0
  4. package/dist/src/document-view/pdf-view.mapper.d.ts +47 -0
  5. package/dist/src/document-view/table-preview.use-case.d.ts +152 -0
  6. package/dist/src/document-view/tree-view.mapper.d.ts +47 -0
  7. package/dist/src/document-view/workbook-view.mapper.d.ts +51 -0
  8. package/dist/src/explain-why/index.d.ts +5 -0
  9. package/dist/src/explain-why/item-hint.algorithm.d.ts +13 -0
  10. package/dist/src/explain-why/why-sentence.mapper.d.ts +48 -0
  11. package/dist/src/explain-why/why.use-case.d.ts +26 -0
  12. package/dist/src/flow-recording/action-to-step.mapper.d.ts +16 -0
  13. package/dist/src/flow-recording/index.d.ts +13 -0
  14. package/dist/src/flow-recording/next-link.policy.d.ts +27 -0
  15. package/dist/src/flow-recording/recorded-action.contract.d.ts +114 -0
  16. package/dist/src/flow-recording/recorder-script.client.d.ts +48 -0
  17. package/dist/src/flow-recording/recorder-session.use-case.d.ts +67 -0
  18. package/dist/src/flow-recording/secret-field.policy.d.ts +33 -0
  19. package/dist/src/flow-recording/wait-insertion.policy.d.ts +26 -0
  20. package/dist/src/index.d.ts +4 -3
  21. package/dist/src/page-inspector/dom-tree.mapper.d.ts +32 -0
  22. package/dist/src/page-inspector/index.d.ts +7 -0
  23. package/dist/src/page-inspector/page-data.algorithm.d.ts +39 -0
  24. package/dist/src/page-inspector/responses-seen.use-case.d.ts +36 -0
  25. package/dist/src/page-snapshot/index.d.ts +3 -0
  26. package/dist/src/page-snapshot/pdf-bytes.use-case.d.ts +16 -0
  27. package/dist/src/page-snapshot/reread-csv.use-case.d.ts +22 -0
  28. package/dist/src/page-snapshot/take-snapshot.use-case.d.ts +26 -1
  29. package/dist/src/sample-run/index.d.ts +3 -1
  30. package/dist/src/sample-run/last-run.store.d.ts +17 -0
  31. package/dist/src/sample-run/run-sample.use-case.d.ts +12 -6
  32. package/dist/src/sample-run/sample-run-record.contract.d.ts +30 -1
  33. package/dist/src/studio-api/command.contract.d.ts +359 -0
  34. package/dist/src/studio-api/deck-preview-view.contract.d.ts +28 -0
  35. package/dist/src/studio-api/deck-view.contract.d.ts +127 -0
  36. package/dist/src/studio-api/document-tree-view.contract.d.ts +26 -0
  37. package/dist/src/studio-api/event.contract.d.ts +86 -1
  38. package/dist/src/studio-api/grid-preview-view.contract.d.ts +26 -0
  39. package/dist/src/studio-api/grid-view.contract.d.ts +75 -0
  40. package/dist/src/studio-api/index.d.ts +24 -4
  41. package/dist/src/studio-api/inspect-view.contract.d.ts +62 -0
  42. package/dist/src/studio-api/pdf-view.contract.d.ts +66 -0
  43. package/dist/src/studio-api/responses-seen-view.contract.d.ts +20 -0
  44. package/dist/src/studio-api/snapshot-view.contract.d.ts +22 -1
  45. package/dist/src/studio-api/table-preview-view.contract.d.ts +65 -0
  46. package/dist/src/studio-api/why-view.contract.d.ts +16 -0
  47. package/dist/src/studio-server/deck-preview.handler.d.ts +17 -0
  48. package/dist/src/studio-server/deck-view.handler.d.ts +15 -0
  49. package/dist/src/studio-server/document-tree.handler.d.ts +14 -0
  50. package/dist/src/studio-server/explain-why.handler.d.ts +14 -0
  51. package/dist/src/studio-server/grid-preview.handler.d.ts +17 -0
  52. package/dist/src/studio-server/grid-view.handler.d.ts +20 -0
  53. package/dist/src/studio-server/inspect-page.handler.d.ts +18 -0
  54. package/dist/src/studio-server/pdf-bytes.handler.d.ts +17 -0
  55. package/dist/src/studio-server/pdf-view.handler.d.ts +14 -0
  56. package/dist/src/studio-server/recording.handler.d.ts +33 -0
  57. package/dist/src/studio-server/responses-seen.handler.d.ts +16 -0
  58. package/dist/src/studio-server/studio-http.use-case.d.ts +3 -1
  59. package/dist/src/studio-server/table-preview.handler.d.ts +16 -0
  60. package/dist/src/studio-server/workspace.store.d.ts +8 -3
  61. package/dist/ui/assets/index-DdiGdaTN.js +13 -0
  62. package/dist/ui/assets/pdf-lqXfRfkR.js +56 -0
  63. package/dist/ui/assets/pdf.worker.min-BmVo14Nb.mjs +28 -0
  64. package/dist/ui/assets/pdf.worker.min-D7i8o3wR.js +1 -0
  65. package/dist/ui/index.html +1 -1
  66. package/package.json +3 -2
  67. package/dist/ui/assets/index-BfbrsfCv.js +0 -13
package/dist/index.esm.js CHANGED
@@ -1,11 +1,13 @@
1
- import { normalize, sep, join, extname, dirname, resolve } from 'node:path';
1
+ import { join, normalize, sep, extname, dirname, resolve } from 'node:path';
2
2
  import { createServer } from 'node:http';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { z } from 'zod';
5
+ import { BODY_KINDS, BrowserClient, HttpClient, NODE_ID_ATTRIBUTE, rowsOfCells, parseXml, findDeckTables, findGridTables, analyzeTables, isDeckDocument, readRecipeSource, parseInputRecipe, parseOutputRecipe, bindRecipeSet, memorySink, createCrawler, traceLine, resolveMissingPolicy, isWorkbookDocument, countMatches, matchesOf, tryParseJson, RecipeBindingError, RecipeValidationError, isPdfDocument, BrowserProfiles } from '@opencraw/core';
5
6
  import { randomBytes, createHash } from 'node:crypto';
6
- import { BrowserClient, HttpClient, NODE_ID_ATTRIBUTE, readRecipeSource, parseInputRecipe, parseOutputRecipe, bindRecipeSet, memorySink, createCrawler, traceLine, countMatches, RecipeBindingError, RecipeValidationError } from '@opencraw/core';
7
7
  import { load } from 'cheerio';
8
- import { writeFile } from 'node:fs/promises';
8
+ import { readFile, writeFile } from 'node:fs/promises';
9
+ import { describeJson } from '@opencraw/probe';
10
+ import { tmpdir } from 'node:os';
9
11
  import { existsSync, createReadStream } from 'node:fs';
10
12
 
11
13
  const sentencePartSchema = z.object({
@@ -119,13 +121,210 @@ const inferSelectorCommandSchema = z.object({
119
121
  path: z.string().min(1),
120
122
  nodeIds: z.union([onePickedNodeSchema, twoPickedNodesSchema])
121
123
  });
122
- const studioCommandSchema = z.discriminatedUnion('type', [openWorkspaceCommandSchema, runSampleCommandSchema, stopRunCommandSchema, saveRecipeCommandSchema, fetchStartPageCommandSchema, saveOutlineCommandSchema, takeSnapshotCommandSchema, verifySelectorCommandSchema, inferSelectorCommandSchema]);
124
+ /**
125
+ * The Inspect panel's DOM tree and data-in-the-page findings, off the same
126
+ * cached snapshot `take-snapshot`/`infer-selector` already use (`page-inspector`,
127
+ * issue #93). Call `take-snapshot` first, same as `infer-selector`.
128
+ */
129
+ const inspectPageCommandSchema = z.object({
130
+ type: z.literal('inspect-page'),
131
+ recipeId: z.string().min(1),
132
+ path: z.string().min(1)
133
+ });
134
+ /**
135
+ * The JSON responses seen while a recipe's start page rendered
136
+ * (`page-inspector`'s `responses-seen.use-case.ts`, issue #93), for the
137
+ * Inspect panel's "responses seen" list. Opens its own browser session
138
+ * (see that use-case's doc comment for why); slower than `inspect-page`.
139
+ */
140
+ const responsesSeenCommandSchema = z.object({
141
+ type: z.literal('responses-seen'),
142
+ recipeId: z.string().min(1),
143
+ path: z.string().min(1)
144
+ });
145
+ /**
146
+ * The document tree for the content pane's tree canvas (`document-view`'s
147
+ * `tree-view.mapper.ts`, studio plan §3.4, issue #94's 5a), off the same
148
+ * cached snapshot `take-snapshot`/`inspect-page` already use — call
149
+ * `take-snapshot` first, same as `inspect-page`. Only a JSON/YAML/XML
150
+ * snapshot has one; PDF, workbook and deck documents get their own canvases
151
+ * (5b/5c/5d).
152
+ */
153
+ const documentTreeCommandSchema = z.object({
154
+ type: z.literal('document-tree'),
155
+ recipeId: z.string().min(1),
156
+ path: z.string().min(1)
157
+ });
158
+ /**
159
+ * The PDF canvas's cells and rows (`document-view`'s `pdf-view.mapper.ts`,
160
+ * studio plan §3.4, issue #94's 5b), off the same cached snapshot
161
+ * `take-snapshot`/`document-tree` already use — call `take-snapshot` first.
162
+ * Only a PDF snapshot has one; JSON/YAML/XML get the tree canvas (5a), the
163
+ * workbook and deck their own canvases (5c/5d).
164
+ */
165
+ const pdfViewCommandSchema = z.object({
166
+ type: z.literal('pdf-view'),
167
+ recipeId: z.string().min(1),
168
+ path: z.string().min(1)
169
+ });
170
+ /** A `table` extract's options, as the studio's PDF canvas picks build them — mirrors `document-view/table-preview.use-case.ts`'s `TablePreviewOptions`. */
171
+ const tablePreviewOptionsSchema = z.object({
172
+ header: z.string().min(1),
173
+ until: z.string().min(1).optional(),
174
+ columns: z.record(z.string(), z.string().min(1)).optional(),
175
+ align: z.enum(['auto', 'top', 'center', 'bottom']).optional()
176
+ });
177
+ /**
178
+ * The live preview of a `table` extract's options against a cached PDF
179
+ * snapshot (`document-view`'s `table-preview.use-case.ts`, studio plan §3.4,
180
+ * issue #94's 5b): the matched rows to highlight on the page, recomputed as
181
+ * the person edits the options — call `take-snapshot` first.
182
+ */
183
+ const tablePreviewCommandSchema = z.object({
184
+ type: z.literal('table-preview'),
185
+ recipeId: z.string().min(1),
186
+ path: z.string().min(1),
187
+ options: tablePreviewOptionsSchema
188
+ });
189
+ /**
190
+ * The grid canvas's sheets and cells (`document-view`'s `workbook-view.mapper.ts`,
191
+ * studio plan §3.4, issue #94's 5c), off the same cached snapshot
192
+ * `take-snapshot`/`document-tree` already use — call `take-snapshot` first.
193
+ * Only a CSV/spreadsheet snapshot has one; JSON/YAML/XML get the tree canvas
194
+ * (5a), PDF the PDF canvas (5b), the deck its own canvas (5d).
195
+ *
196
+ * `delimiter`/`encoding` override a CSV's auto-detected format (issue #94's
197
+ * 5c: "the detected delimiter and encoding shown and overridable"); the
198
+ * server re-reads the file with the override applied and keeps it cached, so
199
+ * a later `grid-preview` sees the same reading. Both are rejected on a
200
+ * spreadsheet snapshot (there is nothing to detect).
201
+ */
202
+ const gridViewCommandSchema = z.object({
203
+ type: z.literal('grid-view'),
204
+ recipeId: z.string().min(1),
205
+ path: z.string().min(1),
206
+ delimiter: z.string().min(1).optional(),
207
+ encoding: z.string().min(1).optional()
208
+ });
209
+ /** A `table` extract's options for a workbook, as the studio's grid canvas picks build them — mirrors `document-view/table-preview.use-case.ts`'s `GridTablePreviewOptions`. */
210
+ const gridPreviewOptionsSchema = z.object({
211
+ sheet: z.string().min(1).optional(),
212
+ header: z.string().min(1),
213
+ until: z.string().min(1).optional(),
214
+ columns: z.record(z.string(), z.string().min(1)).optional(),
215
+ headerRows: z.int().min(1).optional(),
216
+ fillDown: z.array(z.string().min(1)).min(1).optional(),
217
+ includeHidden: z.boolean().optional()
218
+ });
219
+ /**
220
+ * The live preview of a `table` extract's options against a cached CSV/
221
+ * spreadsheet snapshot (`document-view`'s `table-preview.use-case.ts`'s
222
+ * `previewGridTable`, studio plan §3.4, issue #94's 5c): the matched
223
+ * table(s), recomputed as the person edits the options — call `take-snapshot`
224
+ * and `grid-view` first.
225
+ */
226
+ const gridPreviewCommandSchema = z.object({
227
+ type: z.literal('grid-preview'),
228
+ recipeId: z.string().min(1),
229
+ path: z.string().min(1),
230
+ options: gridPreviewOptionsSchema
231
+ });
232
+ /**
233
+ * The deck canvas's slides, shapes, tables, charts and notes for a deck
234
+ * (PowerPoint) snapshot (`document-view`'s `deck-view.mapper.ts`, studio plan
235
+ * §3.4, issue #94's 5d), off the same cached snapshot
236
+ * `take-snapshot`/`document-tree` already use — call `take-snapshot` first.
237
+ * Only a `.pptx` snapshot has one; JSON/YAML/XML get the tree canvas (5a),
238
+ * PDF the PDF canvas (5b), the workbook the grid canvas (5c).
239
+ */
240
+ const deckViewCommandSchema = z.object({
241
+ type: z.literal('deck-view'),
242
+ recipeId: z.string().min(1),
243
+ path: z.string().min(1)
244
+ });
245
+ /** A `table` extract's options for a deck, as the studio's deck canvas picks build them — mirrors `document-view/table-preview.use-case.ts`'s `DeckTablePreviewOptions`. */
246
+ const deckPreviewOptionsSchema = z.object({
247
+ slide: z.string().min(1).optional(),
248
+ shapes: z.boolean().optional(),
249
+ align: z.enum(['auto', 'top', 'center', 'bottom']).optional(),
250
+ header: z.string().min(1),
251
+ until: z.string().min(1).optional(),
252
+ columns: z.record(z.string(), z.string().min(1)).optional(),
253
+ headerRows: z.int().min(1).optional(),
254
+ fillDown: z.array(z.string().min(1)).min(1).optional(),
255
+ includeHidden: z.boolean().optional()
256
+ });
257
+ /**
258
+ * The live preview of a `table` extract's options against a cached deck
259
+ * snapshot (`document-view`'s `table-preview.use-case.ts`'s `previewDeckTable`,
260
+ * studio plan §3.4, issue #94's 5d): the matched table(s), recomputed as the
261
+ * person edits the options — call `take-snapshot` and `deck-view` first.
262
+ */
263
+ const deckPreviewCommandSchema = z.object({
264
+ type: z.literal('deck-preview'),
265
+ recipeId: z.string().min(1),
266
+ path: z.string().min(1),
267
+ options: deckPreviewOptionsSchema
268
+ });
269
+ /** A missing value in an emitted record, by its position in the last sample run's records (the order they streamed in, matching the UI's own `records` list). */
270
+ const missingWhyTargetSchema = z.object({
271
+ kind: z.literal('missing'),
272
+ recipeId: z.string().min(1),
273
+ recordIndex: z.int().nonnegative(),
274
+ field: z.string().min(1)
275
+ });
276
+ /** A rejected record, by its position in the last sample run's `rejectedRecords`. */
277
+ const rejectedWhyTargetSchema = z.object({
278
+ kind: z.literal('rejected'),
279
+ recipeId: z.string().min(1),
280
+ rejectedIndex: z.int().nonnegative()
281
+ });
282
+ const whyTargetSchema = z.discriminatedUnion('kind', [missingWhyTargetSchema, rejectedWhyTargetSchema]);
283
+ /**
284
+ * Explains one missing or rejected value from the recipe's last sample run
285
+ * (`explain-why.use-case.ts`, issue #92's Why? tab): the mapping trace and
286
+ * scope snapshot `run-sample` already kept, the step that bound the value's
287
+ * source id and whether it ran, was skipped or found nothing, and the
288
+ * missing-value policy that applied.
289
+ */
290
+ const explainWhyCommandSchema = z.object({
291
+ type: z.literal('explain-why'),
292
+ target: whyTargetSchema
293
+ });
294
+ /**
295
+ * Opens the studio's own headed browser window and starts recording user
296
+ * actions on it (issue #95, phase 6): clicks, fills, selects, key presses
297
+ * and scrolls stream back as `recording-card` events, each already a
298
+ * verified step. `recipeId`'s start point is where the window opens.
299
+ */
300
+ const startRecordingCommandSchema = z.object({
301
+ type: z.literal('start-recording'),
302
+ recipeId: z.string().min(1)
303
+ });
304
+ /**
305
+ * Closes the recording window, if one is open; a no-op otherwise. Answers
306
+ * with every step recorded, in order, so the caller can offer "make this
307
+ * the login" (writes them under `session.bootstrap`) or "keep as steps"
308
+ * (appends them to the recipe's own `steps`) without a second round trip.
309
+ */
310
+ const stopRecordingCommandSchema = z.object({
311
+ type: z.literal('stop-recording')
312
+ });
313
+ const studioCommandSchema = z.discriminatedUnion('type', [openWorkspaceCommandSchema, runSampleCommandSchema, stopRunCommandSchema, saveRecipeCommandSchema, fetchStartPageCommandSchema, saveOutlineCommandSchema, takeSnapshotCommandSchema, verifySelectorCommandSchema, inferSelectorCommandSchema, explainWhyCommandSchema, inspectPageCommandSchema, responsesSeenCommandSchema, documentTreeCommandSchema, pdfViewCommandSchema, tablePreviewCommandSchema, gridViewCommandSchema, gridPreviewCommandSchema, deckViewCommandSchema, deckPreviewCommandSchema, startRecordingCommandSchema, stopRecordingCommandSchema]);
123
314
 
124
- /** What `take-snapshot` answers with: the rewritten document ready for the content pane's sandboxed iframe (studio plan §3.1, issue #91). */
315
+ /**
316
+ * What `take-snapshot` answers with: the rewritten document ready for the
317
+ * content pane's sandboxed iframe (studio plan §3.1, issue #91). `format`
318
+ * (api mode only; absent in web mode, whose document is always HTML) is
319
+ * what the content pane uses to choose a canvas — the snapshot iframe for
320
+ * `html` (and `docx`/`markdown`, already turned into HTML), else
321
+ * `document-view`'s tree/pdf/grid/deck canvas (studio plan §3.4, issue #94).
322
+ */
125
323
  const snapshotViewSchema = z.object({
126
324
  html: z.string(),
127
325
  nodeCount: z.number(),
128
- baseUrl: z.string()
326
+ baseUrl: z.string(),
327
+ format: z.enum(BODY_KINDS).optional()
129
328
  });
130
329
 
131
330
  /**
@@ -186,12 +385,32 @@ const traceLineEventSchema = z.object({
186
385
  type: z.literal('trace-line'),
187
386
  line: z.string()
188
387
  });
189
- /** One record a sample run emitted. */
388
+ /** One transform's effect within a field's trace: its `op` and the value it returned. */
389
+ const traceStepSchema = z.object({
390
+ op: z.string(),
391
+ value: z.unknown()
392
+ });
393
+ /** One entry of a mapping trace field: what a rule's `from` read, and the value after each transform, in order (`@opencraw/core`'s `FieldTrace`). */
394
+ const fieldTraceSchema = z.object({
395
+ from: z.unknown(),
396
+ steps: z.array(traceStepSchema)
397
+ });
398
+ /** One record a sample run emitted: `scope` (what it was mapped from) and `mapping` (each field's trace) ride along, so the Record tab (#92) can show a transform chain's real values without a second round trip. */
190
399
  const recordEventSchema = z.object({
191
400
  type: z.literal('record'),
192
401
  recipeId: z.string(),
193
402
  key: z.string().nullable(),
194
- data: z.record(z.string(), z.unknown())
403
+ data: z.record(z.string(), z.unknown()),
404
+ scope: z.record(z.string(), z.unknown()).optional(),
405
+ mapping: z.record(z.string(), fieldTraceSchema).optional()
406
+ });
407
+ /** One record a sample run rejected (a field's `skip-record` policy on a missing or uncoercible value). */
408
+ const recordRejectedEventSchema = z.object({
409
+ type: z.literal('record-rejected'),
410
+ recipeId: z.string(),
411
+ field: z.string(),
412
+ reason: z.string(),
413
+ scope: z.record(z.string(), z.unknown()).optional()
195
414
  });
196
415
  /** A sample run ended: how many records, and why it stopped when it did not finish on its own. */
197
416
  const runFinishedEventSchema = z.object({
@@ -208,7 +427,32 @@ const runFinishedEventSchema = z.object({
208
427
  const workspaceChangedEventSchema = z.object({
209
428
  type: z.literal('workspace-changed')
210
429
  });
211
- const studioEventSchema = z.discriminatedUnion('type', [traceLineEventSchema, recordEventSchema, runFinishedEventSchema, workspaceChangedEventSchema]);
430
+ /**
431
+ * One recorded action landed as a step (issue #95): the card it becomes
432
+ * (`scope-outline`'s own `OutlineCard`/`OutlineBracket` shape — the wire
433
+ * form `outline-view.contract.ts` already defines, never a second card
434
+ * format) and whether its value is a secret placeholder, for the secret
435
+ * pill styling.
436
+ */
437
+ const recordingCardEventSchema = z.object({
438
+ type: z.literal('recording-card'),
439
+ node: outlineNodeSchema,
440
+ secret: z.boolean()
441
+ });
442
+ /** A notice from the recording session that is not itself a step: a "next" link offer, or an unsupported iframe/shadow-DOM report. */
443
+ const recordingNoteEventSchema = z.object({
444
+ type: z.literal('recording-note'),
445
+ kind: z.enum(['next-link', 'unsupported', 'info']),
446
+ message: z.string()
447
+ });
448
+ /** One recorded step's raw JSON, exactly as `flow-recording` built it. */
449
+ const recordedStepSchema = z.record(z.string(), z.unknown());
450
+ /** The recording window closed — `stop-recording` completed, or the window was closed some other way. `steps` is every step recorded, in order, exactly as `stop-recording`'s own response carries them. */
451
+ const recordingStoppedEventSchema = z.object({
452
+ type: z.literal('recording-stopped'),
453
+ steps: z.array(recordedStepSchema)
454
+ });
455
+ const studioEventSchema = z.discriminatedUnion('type', [traceLineEventSchema, recordEventSchema, recordRejectedEventSchema, runFinishedEventSchema, workspaceChangedEventSchema, recordingCardEventSchema, recordingNoteEventSchema, recordingStoppedEventSchema]);
212
456
 
213
457
  /** One problem found on a recipe file, with the JSON path it lives at. */
214
458
  const recipeIssueSchema = z.object({
@@ -256,6 +500,250 @@ const startPageViewSchema = z.object({
256
500
  html: z.string()
257
501
  });
258
502
 
503
+ /** What `explain-why` answers with: the sentence (`why-sentence.mapper.ts`'s `buildWhySentence`), plus the structured facts a click can act on — a link back to the step that bound the value, the policy that applied. */
504
+ const whyViewSchema = z.object({
505
+ sentence: z.string(),
506
+ field: z.string(),
507
+ recipeId: z.string(),
508
+ outcome: z.enum(['missing', 'rejected']),
509
+ /** The JSON path (`steps.2.steps.0`) of the step that bound the value's source id, when one was found in the recipe. */
510
+ stepPath: z.string().optional(),
511
+ /** The missing-value policy that applied (`nullable`, `default`, `null`, `fail`, `skip-record`), when one could be resolved. */
512
+ policy: z.string().optional(),
513
+ /** The engine's own rejection reason, for a rejected record. */
514
+ reason: z.string().optional()
515
+ });
516
+
517
+ const domTreeNodeViewSchema = z.lazy(() => z.object({
518
+ nodeId: z.string(),
519
+ tag: z.string(),
520
+ id: z.string().optional(),
521
+ classes: z.array(z.string()).optional(),
522
+ attributes: z.record(z.string(), z.string()),
523
+ text: z.string().optional(),
524
+ hidden: z.boolean(),
525
+ children: z.array(domTreeNodeViewSchema),
526
+ repeatCount: z.number().int().optional(),
527
+ repeatNodeIds: z.array(z.string()).optional()
528
+ }));
529
+ const pageDataKindSchema = z.enum(['ld-json', 'json-script', 'inline-state', 'meta', 'link']);
530
+ /** One "data in the page" entry — mirrors `page-inspector/page-data.algorithm.ts`'s `PageDataFinding`. */
531
+ const pageDataFindingSchema = z.object({
532
+ kind: pageDataKindSchema,
533
+ label: z.string(),
534
+ selector: z.string(),
535
+ selectorKind: z.literal('css'),
536
+ matches: z.number(),
537
+ keys: z.array(z.string()).optional(),
538
+ attribute: z.string().optional()
539
+ });
540
+ /** What `inspect-page` answers with (studio plan §3.3, issue #93): the DOM tree and the data-in-the-page findings, in one round trip — both come from the same cached snapshot/raw capture, so there is no reason to ask twice. */
541
+ const inspectViewSchema = z.object({
542
+ tree: domTreeNodeViewSchema,
543
+ pageData: z.array(pageDataFindingSchema)
544
+ });
545
+
546
+ /** One JSON response seen — mirrors `page-inspector/responses-seen.use-case.ts`'s `ObservedResponse`. */
547
+ const observedResponseSchema = z.object({
548
+ url: z.string(),
549
+ status: z.number(),
550
+ size: z.number(),
551
+ shape: z.string()
552
+ });
553
+ /** What `responses-seen` answers with (studio plan §3.3, issue #93): the JSON responses the page fetched while it rendered, for the panel's "responses seen" list. */
554
+ const responsesSeenViewSchema = z.object({
555
+ responses: z.array(observedResponseSchema)
556
+ });
557
+
558
+ const documentTreeNodeViewSchema = z.lazy(() => z.object({
559
+ id: z.string(),
560
+ label: z.string(),
561
+ valueType: z.enum(['object', 'array', 'string', 'number', 'boolean', 'null', 'element']),
562
+ preview: z.string().optional(),
563
+ jsonpath: z.string().optional(),
564
+ xpath: z.string().optional(),
565
+ listPath: z.string().optional(),
566
+ listCount: z.number().optional(),
567
+ attributes: z.record(z.string(), z.string()).optional(),
568
+ children: z.array(documentTreeNodeViewSchema)
569
+ }));
570
+ /** What `document-tree` answers with (studio plan §3.4, issue #94's 5a): the parsed JSON/XML document as a tree, and, for XML, the namespace prefixes a pick's `extract` can use in `namespaces`. */
571
+ const documentTreeViewSchema = z.object({
572
+ format: z.enum(['json', 'xml']),
573
+ root: documentTreeNodeViewSchema,
574
+ namespaces: z.record(z.string(), z.string()).optional()
575
+ });
576
+
577
+ /** One text cell, in PDF points — mirrors `document-view/pdf-view.mapper.ts`'s `PdfCellView`, the wire shape for the UI (studio plan §3.4, issue #94's 5b). */
578
+ const pdfCellViewSchema = z.object({
579
+ x: z.number(),
580
+ y: z.number(),
581
+ width: z.number(),
582
+ height: z.number(),
583
+ text: z.string()
584
+ });
585
+ const pdfRowViewSchema = z.object({
586
+ top: z.number(),
587
+ bottom: z.number(),
588
+ cells: z.array(pdfCellViewSchema)
589
+ });
590
+ const pdfPageViewSchema = z.object({
591
+ number: z.number().int(),
592
+ width: z.number(),
593
+ height: z.number(),
594
+ rows: z.array(pdfRowViewSchema),
595
+ rowCount: z.number().int(),
596
+ cellCount: z.number().int(),
597
+ hasTextLayer: z.boolean()
598
+ });
599
+ /** What `pdf-view` answers with (studio plan §3.4, issue #94's 5b): every page's cells and rows, for the PDF canvas to draw over its own `pdf.js` rendering. */
600
+ const pdfDocumentViewSchema = z.object({
601
+ pages: z.array(pdfPageViewSchema)
602
+ });
603
+
604
+ const tablePreviewRowSchema = z.record(z.string(), z.string());
605
+ /** One table matched by a `table` extract's options — mirrors `@opencraw/core`'s `PdfTable`. */
606
+ const tablePreviewTableSchema = z.object({
607
+ page: z.number().int(),
608
+ title: z.string(),
609
+ header: z.array(z.string()),
610
+ rows: z.array(tablePreviewRowSchema)
611
+ });
612
+ /** One column band a matched table's body clustered into — mirrors `@opencraw/core`'s `TableBandDiagnostics`. */
613
+ const tablePreviewBandSchema = z.object({
614
+ start: z.number(),
615
+ end: z.number(),
616
+ column: z.number().int(),
617
+ name: z.string()
618
+ });
619
+ /** One matched table, with the exact page rows to highlight — mirrors `document-view/table-preview.use-case.ts`'s `TablePreviewMatch`. */
620
+ const tablePreviewMatchSchema = z.object({
621
+ page: z.number().int(),
622
+ headerRowIndex: z.number().int(),
623
+ matchedRowIndices: z.array(z.number().int()),
624
+ bands: z.array(tablePreviewBandSchema),
625
+ bandTolerance: z.number(),
626
+ table: tablePreviewTableSchema
627
+ });
628
+ /**
629
+ * What `table-preview` answers with (studio plan §3.4, issue #94's 5b: the
630
+ * live preview): every match, or `error` when a pattern in the options does
631
+ * not compile — expected while the person is still typing it, not thrown.
632
+ */
633
+ const tablePreviewViewSchema = z.object({
634
+ matches: z.array(tablePreviewMatchSchema),
635
+ error: z.string().optional()
636
+ });
637
+
638
+ /** One cell's value, and the type the grid shows it as — mirrors `document-view/workbook-view.mapper.ts`'s `GridCellView`, the wire shape for the UI (studio plan §3.4, issue #94's 5c). */
639
+ const gridCellViewSchema = z.object({
640
+ value: z.union([z.string(), z.number(), z.boolean()]),
641
+ type: z.enum(['string', 'number', 'boolean', 'date'])
642
+ });
643
+ /** One merged range, 0-based and inclusive. */
644
+ const gridMergeViewSchema = z.object({
645
+ ref: z.string(),
646
+ top: z.number().int(),
647
+ left: z.number().int(),
648
+ bottom: z.number().int(),
649
+ right: z.number().int()
650
+ });
651
+ const gridSheetViewSchema = z.object({
652
+ name: z.string(),
653
+ hidden: z.boolean(),
654
+ hiddenRows: z.array(z.number().int()),
655
+ columnCount: z.number().int(),
656
+ rows: z.array(z.array(gridCellViewSchema)),
657
+ merges: z.array(gridMergeViewSchema)
658
+ });
659
+ const csvFormatViewSchema = z.object({
660
+ encoding: z.string(),
661
+ delimiter: z.string()
662
+ });
663
+ /** What `grid-view` answers with (studio plan §3.4, issue #94's 5c): every sheet's cells, typed, with hidden sheets/rows marked and merged ranges resolved, for the grid canvas to draw. */
664
+ const workbookDocumentViewSchema = z.object({
665
+ sheets: z.array(gridSheetViewSchema),
666
+ csv: csvFormatViewSchema.optional()
667
+ });
668
+
669
+ const gridPreviewRowSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
670
+ /** One table matched by a `table` extract's options against a workbook — mirrors `@opencraw/core`'s `GridTable`. */
671
+ const gridTablePreviewMatchSchema = z.object({
672
+ sheet: z.string(),
673
+ title: z.string(),
674
+ header: z.array(z.string()),
675
+ rows: z.array(gridPreviewRowSchema)
676
+ });
677
+ /**
678
+ * What `grid-preview` answers with (studio plan §3.4, issue #94's 5c: the
679
+ * live preview): every matched table, or `error` when a pattern in the
680
+ * options does not compile — expected while the person is still typing it,
681
+ * not thrown.
682
+ */
683
+ const gridTablePreviewViewSchema = z.object({
684
+ matches: z.array(gridTablePreviewMatchSchema),
685
+ error: z.string().optional()
686
+ });
687
+
688
+ /** One text box on a slide, in points — mirrors `document-view/deck-view.mapper.ts`'s `DeckShapeView`, the wire shape for the UI (studio plan §3.4, issue #94's 5d). */
689
+ const deckShapeViewSchema = z.object({
690
+ x: z.number(),
691
+ y: z.number(),
692
+ width: z.number(),
693
+ height: z.number(),
694
+ text: z.string(),
695
+ placeholder: z.string().optional()
696
+ });
697
+ const deckChartValueSchema = z.union([z.number(), z.null()]);
698
+ const deckChartSeriesViewSchema = z.object({
699
+ name: z.string(),
700
+ categories: z.array(z.string()),
701
+ values: z.array(deckChartValueSchema)
702
+ });
703
+ const deckChartViewSchema = z.object({
704
+ type: z.string(),
705
+ title: z.string().optional(),
706
+ series: z.array(deckChartSeriesViewSchema)
707
+ });
708
+ const deckShapeRowSchema = z.array(z.number().int());
709
+ const deckSlideViewSchema = z.object({
710
+ number: z.number().int(),
711
+ title: z.string().optional(),
712
+ hidden: z.boolean(),
713
+ shapes: z.array(deckShapeViewSchema),
714
+ /** Indices into `shapes`, grouped into visual rows — see `deck-view.mapper.ts`'s own doc comment. */
715
+ shapeRows: z.array(deckShapeRowSchema),
716
+ tables: z.array(gridSheetViewSchema),
717
+ charts: z.array(deckChartViewSchema),
718
+ notes: z.string()
719
+ });
720
+ /** What `deck-view` answers with (studio plan §3.4, issue #94's 5d): the slide size in points and every slide's shapes, tables, charts and notes, for the deck canvas to draw and pick from. */
721
+ const deckDocumentViewSchema = z.object({
722
+ width: z.number(),
723
+ height: z.number(),
724
+ slides: z.array(deckSlideViewSchema)
725
+ });
726
+
727
+ const deckPreviewRowSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
728
+ /** One table matched by a `table` extract's options against a deck — mirrors `@opencraw/core`'s `DeckTable`. */
729
+ const deckTablePreviewMatchSchema = z.object({
730
+ slide: z.number().int(),
731
+ slideTitle: z.string(),
732
+ title: z.string(),
733
+ header: z.array(z.string()),
734
+ rows: z.array(deckPreviewRowSchema)
735
+ });
736
+ /**
737
+ * What `deck-preview` answers with (studio plan §3.4, issue #94's 5d: the
738
+ * live preview): every matched table, or `error` when a pattern in the
739
+ * options does not compile — expected while the person is still typing it,
740
+ * not thrown.
741
+ */
742
+ const deckTablePreviewViewSchema = z.object({
743
+ matches: z.array(deckTablePreviewMatchSchema),
744
+ error: z.string().optional()
745
+ });
746
+
259
747
  /** The cookie set on the page's own document response, so its own asset and API requests carry the token too. */
260
748
  const TOKEN_COOKIE = 'opencraw_token';
261
749
  /** A random token, generated once at server startup and required on every request. */
@@ -340,13 +828,13 @@ async function fetchApi(url) {
340
828
  const response = await client.send({
341
829
  url
342
830
  });
343
- return textOf$1(response.body);
831
+ return textOf$2(response.body);
344
832
  } finally {
345
833
  await client.dispose();
346
834
  }
347
835
  }
348
836
  /** Every body kind the studio can show as text today; others get a placeholder rather than a crash. */
349
- function textOf$1(body) {
837
+ function textOf$2(body) {
350
838
  if (body.kind === 'html') return body.html;
351
839
  if (body.kind === 'text') return body.text;
352
840
  if (body.kind === 'json') return JSON.stringify(body.data, null, 2);
@@ -508,7 +996,8 @@ async function snapshotWeb(url, browser) {
508
996
  const rewritten = rewriteDocument(html, baseUrl);
509
997
  return {
510
998
  ...rewritten,
511
- baseUrl
999
+ baseUrl,
1000
+ rawHtml: html
512
1001
  };
513
1002
  } finally {
514
1003
  await session.close();
@@ -523,17 +1012,21 @@ async function snapshotApi(url) {
523
1012
  const response = await client.send({
524
1013
  url
525
1014
  });
526
- const rewritten = rewriteDocument(textOf(response.body), url);
1015
+ const text = textOf$1(response.body);
1016
+ const rewritten = rewriteDocument(text, url);
527
1017
  return {
528
1018
  ...rewritten,
529
- baseUrl: url
1019
+ baseUrl: url,
1020
+ rawHtml: text,
1021
+ format: response.format,
1022
+ body: response.body
530
1023
  };
531
1024
  } finally {
532
1025
  await client.dispose();
533
1026
  }
534
1027
  }
535
1028
  /** Every body kind the studio can show as text today; others get a placeholder rather than a crash (mirrors `fetch-start-page.use-case.ts`'s own `textOf`). */
536
- function textOf(body) {
1029
+ function textOf$1(body) {
537
1030
  if (body.kind === 'html') return body.html;
538
1031
  if (body.kind === 'text') return body.text;
539
1032
  if (body.kind === 'json') return `<pre>${JSON.stringify(body.data, null, 2)}</pre>`;
@@ -559,1035 +1052,3189 @@ function keyOf(recipeId, path) {
559
1052
  }
560
1053
 
561
1054
  /**
562
- * Finds and parses one input recipe of a workspace folder, and the output
563
- * recipe it names.
1055
+ * Fetches a PDF's raw bytes for the PDF canvas's `pdf.js` to render in the
1056
+ * browser (studio plan §3.4, issue #94's 5b: "the PDF bytes served by the
1057
+ * studio"). `take-snapshot` already read this same document with
1058
+ * `@opencraw/core`'s `readPdf` for the cells/rows `document-view` maps, but
1059
+ * `HttpClient`/`readPdf` parse the bytes and keep only the result — pdf.js
1060
+ * needs the file itself, so this re-fetches it from the same URL a cached
1061
+ * snapshot's own `SnapshotResult.baseUrl` gives, the way `HttpClient`'s own
1062
+ * `file:` branch (`readLocalFile`) and its HTTP fetch do.
564
1063
  *
565
- * @param folder - The workspace folder.
566
- * @param recipeId - The input recipe's id.
567
- * @returns The parsed pair.
568
- * @throws RecipeValidationError when either recipe does not parse.
569
- * @throws Error when the input or its output is not in the folder.
1064
+ * @param url - The document's URL — a `file:` URL or an `http(s):` URL.
1065
+ * @returns The raw bytes.
1066
+ * @throws Error when a `file:` URL cannot be read, or the fetch does not succeed.
570
1067
  */
571
- async function loadRecipePair(folder, recipeId) {
572
- const documents = await readRecipeSource(folder);
573
- const inputDocument = documents.find(document => kindOf(document.content) === 'input' && idOf(document.content) === recipeId);
574
- if (inputDocument === undefined) throw new Error(`no input recipe "${recipeId}" in this workspace`);
575
- const input = parseInputRecipe(inputDocument.content, inputDocument.source);
576
- const outputDocument = documents.find(document => kindOf(document.content) === 'output' && idOf(document.content) === input.output);
577
- if (outputDocument === undefined) throw new Error(`no output recipe "${input.output}" in this workspace`);
578
- return {
579
- input,
580
- output: parseOutputRecipe(outputDocument.content, outputDocument.source)
581
- };
582
- }
583
- function kindOf(content) {
584
- return content?.kind;
585
- }
586
- function idOf(content) {
587
- return content?.id;
1068
+ async function pdfBytes(url) {
1069
+ if (url.startsWith('file:')) return new Uint8Array(await readFile(fileURLToPath(url)));
1070
+ const response = await fetch(url);
1071
+ if (!response.ok) throw new Error(`pdfBytes: ${String(response.status)} fetching ${url}`);
1072
+ return new Uint8Array(await response.arrayBuffer());
588
1073
  }
589
1074
 
590
1075
  /**
591
- * Runs one input recipe of a workspace folder as a sample: `debug: true` (so
592
- * each `record:emit` carries its data), an in-memory sink, and `budget` as
593
- * the crawler's `sample` (`@opencraw/core`'s `CrawlOptions.sample`, #89).
594
- * Every engine event becomes a trace line with the same `traceLine`
595
- * formatter the cli's `--trace` uses; every emitted record reaches
596
- * `callbacks.onRecord` as it happens, not only in the final result.
1076
+ * Re-reads a CSV document with an overridden delimiter and/or encoding
1077
+ * (studio plan §3.4, issue #94's 5c). `take-snapshot` already read this same
1078
+ * document once with auto-detection (`http.client.ts`'s own `csvWorkbook`
1079
+ * call); it keeps only the parsed result, not the raw bytes, so re-reading
1080
+ * the file/URL is the only way to apply a different choice — the same
1081
+ * "the studio re-fetches, the reader keeps no bytes around" tradeoff
1082
+ * `pdf-bytes.use-case.ts` documents for pdf.js.
597
1083
  *
598
- * @param folder - The workspace folder the recipe lives in.
599
- * @param recipeId - The input recipe to run.
600
- * @param budget - The sample budget; unset runs the recipe to completion (still capped by its own `limits`).
601
- * @param callbacks - Where trace lines and records go as the run proceeds.
602
- * @param browser - Browser launch settings for a web-mode recipe (an executable path override, headless…); the server-wide setting `studio-http.use-case.ts`'s `StudioServerOptions.browser` carries in, e.g. `OPENCRAW_CHROMIUM` in a sandbox with no full Playwright install.
603
- * @returns A handle: `stop` closes the crawler cleanly, `result` resolves when the run ends.
604
- * @throws RecipeValidationError, RecipeBindingError, Error when the recipe or its output cannot be loaded or bound.
1084
+ * @param url - The document's URL — a `file:` URL or an `http(s):` URL (`page-snapshot`'s cached `SnapshotResult.baseUrl`).
1085
+ * @param override - The delimiter and/or encoding to force; `HttpClient` still detects whichever one is left out.
1086
+ * @returns The freshly read workbook.
1087
+ * @throws Error when the URL cannot be read, or it no longer reads back as a workbook.
605
1088
  */
606
- async function runSample(folder, recipeId, budget, callbacks, browser) {
607
- const {
608
- input,
609
- output
610
- } = await loadRecipePair(folder, recipeId);
611
- const set = bindRecipeSet(output, [input]);
612
- const sink = memorySink();
613
- const crawler = createCrawler({
614
- sink,
615
- debug: true,
616
- sample: budget,
617
- browser,
618
- onEvent: event => {
619
- const line = traceLine(event);
620
- if (line !== undefined) callbacks.onTraceLine(line);
621
- if (event.type === 'record:emit') callbacks.onRecord({
622
- key: event.key,
623
- data: event.data
624
- });
625
- }
626
- });
627
- const result = runToResult(crawler, set, sink);
628
- return {
629
- stop: () => crawler.close(),
630
- result
631
- };
632
- }
633
- async function runToResult(crawler, set, sink) {
1089
+ async function rereadCsv(url, override) {
1090
+ const client = await HttpClient.open({});
634
1091
  try {
635
- const report = await crawler.run(set);
636
- const recipe = report.recipes[0];
637
- return {
638
- recipeId: recipe.recipeId,
639
- emitted: recipe.emitted,
640
- rejected: recipe.rejected,
641
- duplicates: recipe.duplicates,
642
- durationMs: recipe.durationMs,
643
- error: recipe.error,
644
- stoppedBy: recipe.stoppedBy,
645
- records: sink.records.map(record => ({
646
- key: record.key,
647
- data: record.data
648
- }))
649
- };
1092
+ const response = await client.send({
1093
+ url,
1094
+ as: 'csv',
1095
+ delimiter: override.delimiter,
1096
+ encoding: override.encoding
1097
+ });
1098
+ if (response.body.kind !== 'workbook') throw new Error(`rereadCsv: ${url} did not read back as a workbook`);
1099
+ return response.body;
650
1100
  } finally {
651
- await crawler.close();
1101
+ await client.dispose();
652
1102
  }
653
1103
  }
654
1104
 
1105
+ /** A cell reads as an ISO date (`2026-06-01`), an ISO moment (`2026-06-01T09:30:00`) or a bare time of day (`09:30:00`) — the three shapes `workbook-document`'s `read-xlsx.client.ts` writes for a spreadsheet date. */
1106
+ const ISO_DATE_OR_TIME = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2})?$|^\d{2}:\d{2}:\d{2}$/;
655
1107
  /**
656
- * Handles `fetch-start-page`: loads the named input recipe from the current
657
- * workspace and fetches its start point (see `page-snapshot`'s own caveats:
658
- * no session bootstrap, a fresh browser or HTTP context per call).
1108
+ * Builds the grid canvas's view model (studio plan §3.4, issue #94's 5c) from
1109
+ * an already-read `WorkbookDocument` (`take-snapshot`'s cached body — no
1110
+ * second read of the file): every sheet's cells, typed, with hidden sheets
1111
+ * and rows marked, and merged ranges resolved to bounds so the grid draws
1112
+ * them as one cell. Table-specific diagnostics (the matched rows of the
1113
+ * current `table` extract's options) are not here — they depend on the
1114
+ * picks so far, so they come from `table-preview.use-case.ts`'s
1115
+ * `previewGridTable` instead, computed live as those options change.
659
1116
  *
660
- * @param state - The server state: `folder` must already be set.
661
- * @param command - The command.
662
- * @returns The page's content as plain text.
663
- * @throws Error when no workspace is open, or the recipe cannot be found or loaded.
1117
+ * @param document - The read workbook (a spreadsheet or a CSV).
1118
+ * @returns The view.
664
1119
  */
665
- async function handleFetchStartPage(state, command) {
666
- if (state.folder === undefined) throw new Error('open a workspace first');
667
- const {
668
- input
669
- } = await loadRecipePair(state.folder, command.recipeId);
670
- const html = await fetchStartPage(input, state.browser);
1120
+ function workbookDocumentView(document) {
671
1121
  return {
672
- html
1122
+ sheets: document.sheets.map(sheet => sheetView(sheet)),
1123
+ ...(document.csv !== undefined && {
1124
+ csv: document.csv
1125
+ })
1126
+ };
1127
+ }
1128
+ function sheetView(sheet) {
1129
+ return {
1130
+ name: sheet.name,
1131
+ hidden: sheet.hidden === true,
1132
+ hiddenRows: sheet.hiddenRows === undefined ? [] : [...sheet.hiddenRows],
1133
+ columnCount: Math.max(0, ...sheet.rows.map(row => row.length)),
1134
+ rows: sheet.rows.map(row => row.map(cell => cellView(cell))),
1135
+ merges: (sheet.merges ?? []).flatMap(reference => {
1136
+ const range = rangeOf(reference);
1137
+ return range === undefined ? [] : [{
1138
+ ref: reference,
1139
+ ...range
1140
+ }];
1141
+ })
1142
+ };
1143
+ }
1144
+ function cellView(cell) {
1145
+ return {
1146
+ value: cell,
1147
+ type: cellType(cell)
1148
+ };
1149
+ }
1150
+ function cellType(cell) {
1151
+ if (typeof cell === 'number') return 'number';
1152
+ if (typeof cell === 'boolean') return 'boolean';
1153
+ return ISO_DATE_OR_TIME.test(cell) ? 'date' : 'string';
1154
+ }
1155
+ /** `B10:B13` (or a single cell, `A1`) as 0-based inclusive bounds; `undefined` for anything else — mirrors `@opencraw/core`'s own (unexported) `rangeOf`/`cellOf` in `grid-table.algorithm.ts`: a small pure parse, kept here rather than reached for across the package boundary (a sibling slice's non-`index.ts` file is never imported, and this is `@opencraw/core`'s own private helper, not even on its barrel). */
1156
+ function rangeOf(reference) {
1157
+ const [from, to = from] = reference.split(':', 2);
1158
+ const start = cellOf(from);
1159
+ const end = cellOf(to);
1160
+ if (start === undefined || end === undefined) return undefined;
1161
+ return {
1162
+ top: Math.min(start.row, end.row),
1163
+ left: Math.min(start.column, end.column),
1164
+ bottom: Math.max(start.row, end.row),
1165
+ right: Math.max(start.column, end.column)
1166
+ };
1167
+ }
1168
+ function cellOf(reference) {
1169
+ const match = /^\$?([A-Z]+)\$?(\d+)$/i.exec(reference.trim());
1170
+ if (match === null) return undefined;
1171
+ const letters = match[1].toUpperCase();
1172
+ let column = 0;
1173
+ for (const char of letters) column = column * 26 + (char.codePointAt(0) ?? 64) - 64;
1174
+ return {
1175
+ row: Number(match[2]) - 1,
1176
+ column: column - 1
673
1177
  };
674
1178
  }
675
1179
 
676
- const GENERATED_ID = /^(?:react-|radix-|:r|ember|css-|mui-|headlessui-)/i;
677
1180
  /**
678
- * The path from the document root to `node`, one level per ancestor. Two
679
- * elements that share an ancestor produce identical levels up to that
680
- * ancestor and diverge exactly where their parents stop being the same node
681
- * — `list-inference.algorithm.ts` leans on that to find the nearest pair of
682
- * siblings two picks belong to, without ever touching cheerio again.
1181
+ * Builds the deck canvas's view model (studio plan §3.4, issue #94's 5d)
1182
+ * from an already-read `DeckDocument` (`take-snapshot`'s cached body — no
1183
+ * second read of the file): every slide's shapes (with their visual-row
1184
+ * grouping, for the text-box-grid pick), native tables (through
1185
+ * `workbook-view.mapper.ts`'s own sheet view — a deck table *is* a sheet),
1186
+ * charts and notes. Table-specific diagnostics for a `table` extract's own
1187
+ * options (the live preview) are not here — they come from
1188
+ * `table-preview.use-case.ts`'s `previewDeckTable` instead, computed live as
1189
+ * those options change, the same split `pdf-view.mapper.ts`/`workbook-view.mapper.ts`
1190
+ * already use.
683
1191
  *
684
- * @param $ - The parsed document.
685
- * @param node - The element to describe.
686
- * @returns The path, root first.
1192
+ * @param document - The read deck.
1193
+ * @returns The view.
687
1194
  */
688
- function domPathOf($, node) {
689
- const chain = [];
690
- let current = node;
691
- while (current !== undefined && current.length > 0 && isElement(current.get(0))) {
692
- chain.unshift(current);
693
- const parent = current.parent();
694
- current = parent.length > 0 ? parent : undefined;
695
- }
696
- return chain.map(element => levelOf($, element));
1195
+ function deckDocumentView(document) {
1196
+ return {
1197
+ width: document.width,
1198
+ height: document.height,
1199
+ slides: document.slides.map(slide => slideView(slide, document.height))
1200
+ };
697
1201
  }
698
- function levelOf($, element) {
699
- const node = element.get(0);
700
- const tag = node.tagName.toLowerCase();
701
- const attrs = {};
702
- const entries = Object.entries(node.attribs ?? {});
703
- for (const [name, value] of entries) attrs[name] = value;
704
- const classes = (attrs.class ?? '').split(/\s+/).filter(token => token.length > 0);
705
- const siblings = element.parent().length > 0 ? element.parent().children(tag) : $.root().children(tag);
706
- const index = siblings.toArray().indexOf(node) + 1;
1202
+ function slideView(slide, slideHeight) {
707
1203
  return {
708
- tag,
709
- id: attrs.id,
710
- classes,
711
- attrs,
712
- index: Math.max(index, 1),
713
- siblingCount: Math.max(siblings.length, 1)
1204
+ number: slide.number,
1205
+ ...(slide.title !== undefined && {
1206
+ title: slide.title
1207
+ }),
1208
+ hidden: slide.hidden,
1209
+ shapes: slide.shapes.map(shape => shapeView(shape)),
1210
+ shapeRows: shapeRowsOf(slide.shapes, slideHeight),
1211
+ tables: workbookDocumentView({
1212
+ sheets: slide.tables
1213
+ }).sheets,
1214
+ charts: slide.charts.map(chart => chartView(chart)),
1215
+ notes: slide.notes
714
1216
  };
715
1217
  }
716
- function isElement(node) {
717
- return node !== undefined && node.type === 'tag';
1218
+ function shapeView(shape) {
1219
+ return {
1220
+ x: shape.x,
1221
+ y: shape.y,
1222
+ width: shape.width,
1223
+ height: shape.height,
1224
+ text: shape.text,
1225
+ ...(shape.placeholder !== undefined && {
1226
+ placeholder: shape.placeholder
1227
+ })
1228
+ };
1229
+ }
1230
+ function chartView(chart) {
1231
+ return {
1232
+ type: chart.type,
1233
+ ...(chart.title !== undefined && {
1234
+ title: chart.title
1235
+ }),
1236
+ series: chart.series.map(series => ({
1237
+ name: series.name,
1238
+ categories: [...series.categories],
1239
+ values: [...series.values]
1240
+ }))
1241
+ };
718
1242
  }
719
1243
  /**
720
- * Whether `id` looks handed by a framework (`react-`, a CSS-module or a
721
- * `radix-`/`mui-` autogenerated one) rather than by the site's own markup —
722
- * `class-token.policy.ts` has the equivalent check for class tokens, kept
723
- * separate because ids and classes are ranked at different tiers.
1244
+ * Groups a slide's shapes into visual rows the same way `deck-table.algorithm.ts`'s
1245
+ * own (core-internal) `shapeTables` does: each shape is one cell, `y`
1246
+ * flipped to the PDF-style bottom-up convention `rowsOfCells` (and the whole
1247
+ * table-reading pipeline it feeds) works in, so the grouping this returns is
1248
+ * byte-for-byte what `findDeckTables(document, { shapes: true })` groups at
1249
+ * run time — a pick built from it matches for real, not just visually.
1250
+ *
1251
+ * @param shapes - The slide's shapes, in reading order.
1252
+ * @param slideHeight - The deck's slide height, in points (`DeckDocument.height`).
1253
+ * @returns Each row's shape indices, left to right; a blank shape (whitespace-only text) is never part of a row.
724
1254
  */
725
- function isStableId(id) {
726
- return id.length > 0 && !GENERATED_ID.test(id);
1255
+ function shapeRowsOf(shapes, slideHeight) {
1256
+ const indexOf = new Map();
1257
+ const cells = shapes.flatMap((shape, index) => {
1258
+ const text = shape.text.replaceAll(/\s+/g, ' ').trim();
1259
+ if (text === '') return [];
1260
+ const cell = {
1261
+ x: shape.x,
1262
+ y: slideHeight - shape.y - shape.height,
1263
+ width: shape.width,
1264
+ height: shape.height,
1265
+ text
1266
+ };
1267
+ indexOf.set(cell, index);
1268
+ return [cell];
1269
+ });
1270
+ return rowsOfCells(cells).map(row => row.cells.map(cell => indexOf.get(cell)));
727
1271
  }
1272
+
1273
+ const TEXT_LIMIT$1 = 120;
728
1274
  /**
729
- * Whether `path` failed to reach the real document root. `domPathOf` walks
730
- * up through cheerio's own `.parent()`, which (matching real DOM semantics)
731
- * never crosses from a `<template>`'s content into the light DOM outside
732
- * it — a `<template shadowrootmode>` is exactly declarative shadow DOM, the
733
- * only shadow-DOM shape that can show up in static, server-captured markup
734
- * at all (imperative `attachShadow()` content never serialises into
735
- * `page.content()`/a fetched response in the first place, so that case
736
- * cannot reach this slice and needs no separate detection). A path whose
737
- * first level is not `html` was cut short at exactly this kind of boundary:
738
- * v1 does not pierce it, so callers report a clear "shadow DOM is not
739
- * supported" error instead of building ancestor-based candidates over a
740
- * chain that stops partway up.
1275
+ * Builds the document tree for `document-tree` (studio plan §3.4, issue
1276
+ * #94's 5a) from the body `page-snapshot`'s `take-snapshot.use-case.ts`
1277
+ * already fetched — no second read of the document.
1278
+ *
1279
+ * @param body - The parsed body (`HttpBody`); YAML and JSON Lines both
1280
+ * arrive here already normalised to `{ kind: 'json' }` by `http.client.ts`.
1281
+ * @returns The tree.
1282
+ * @throws Error when `body`'s kind is not `json` or `xml` (PDF, workbook and
1283
+ * deck documents get their own canvases — 5b/5c/5d, not this mapper).
741
1284
  */
742
- function crossesShadowRoot(path) {
743
- return path.length === 0 || path[0].tag !== 'html';
1285
+ function documentTreeView(body) {
1286
+ if (body.kind === 'json') return {
1287
+ format: 'json',
1288
+ root: jsonNode(body.data, '$', 'root')
1289
+ };
1290
+ if (body.kind === 'xml') return xmlTreeView(body.xml);
1291
+ throw new Error(`document-tree: "${body.kind}" has no tree canvas (only "json" and "xml" bodies do)`);
744
1292
  }
745
- /** Parses `html` and returns cheerio's API, for callers that need more than one path from the same document. */
746
- function parseDocument$1(html) {
747
- return load(html);
1293
+ function listFields(path, context) {
1294
+ if (context === undefined) return {};
1295
+ return {
1296
+ listPath: context.generalPrefix + path.slice(context.itemPrefix.length),
1297
+ listCount: context.count
1298
+ };
1299
+ }
1300
+ function jsonNode(value, path, label, context) {
1301
+ if (Array.isArray(value)) {
1302
+ const children = value.map((item, index) => {
1303
+ const itemPath = `${path}[${index}]`;
1304
+ return jsonNode(item, itemPath, String(index), {
1305
+ itemPrefix: itemPath,
1306
+ generalPrefix: `${path}[*]`,
1307
+ count: value.length
1308
+ });
1309
+ });
1310
+ return {
1311
+ id: path,
1312
+ label,
1313
+ valueType: 'array',
1314
+ jsonpath: path,
1315
+ ...listFields(path, context),
1316
+ children
1317
+ };
1318
+ }
1319
+ if (value !== null && typeof value === 'object') {
1320
+ const entries = Object.entries(value);
1321
+ // A plain object passes its own context through unchanged: only an array (or, in XML, a repeated element) starts a new one.
1322
+ const children = entries.map(([key, item]) => jsonNode(item, `${path}${propertyAccess(key)}`, key, context));
1323
+ return {
1324
+ id: path,
1325
+ label,
1326
+ valueType: 'object',
1327
+ jsonpath: path,
1328
+ ...listFields(path, context),
1329
+ children
1330
+ };
1331
+ }
1332
+ return {
1333
+ id: path,
1334
+ label,
1335
+ valueType: leafType(value),
1336
+ jsonpath: path,
1337
+ preview: previewOf(value),
1338
+ ...listFields(path, context),
1339
+ children: []
1340
+ };
1341
+ }
1342
+ function leafType(value) {
1343
+ if (value === null) return 'null';
1344
+ if (typeof value === 'string') return 'string';
1345
+ if (typeof value === 'number') return 'number';
1346
+ if (typeof value === 'boolean') return 'boolean';
1347
+ return 'string';
1348
+ }
1349
+ function previewOf(value) {
1350
+ const text = value === null ? 'null' : typeof value === 'string' ? value : JSON.stringify(value);
1351
+ return text.length > TEXT_LIMIT$1 ? `${text.slice(0, TEXT_LIMIT$1)}…` : text;
1352
+ }
1353
+ /** A JSONPath property step: dot notation for a plain identifier, bracket notation (quoted) otherwise — jsonpath-plus accepts both, but only dot notation round-trips through a person reading the card. */
1354
+ function propertyAccess(key) {
1355
+ return /^[A-Z_]\w*$/i.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`;
1356
+ }
1357
+ // --- XML ---
1358
+ const DEFAULT_NS_PREFIX = 'ns';
1359
+ function xmlTreeView(xml) {
1360
+ const document = parseXml(xml, 'document.xml');
1361
+ const root = document.documentElement;
1362
+ if (root === null) throw new Error('document-tree: the XML document has no root element');
1363
+ const namespaces = declaredNamespaces(root);
1364
+ const tree = xmlNode(root, '', namespaces, undefined);
1365
+ return {
1366
+ format: 'xml',
1367
+ root: tree,
1368
+ ...(Object.keys(namespaces).length > 0 && {
1369
+ namespaces
1370
+ })
1371
+ };
1372
+ }
1373
+ /** Every `xmlns:*` the root declares, plus a synthesised prefix (`ns`, `ns2`, …) for a bare default `xmlns="…"` — XPath 1.0 has no default namespace, so a query needs a prefix even when the markup does not use one (mirrors `xpath.algorithm.ts`'s own `declaredPrefixes`, extended for the default case that function leaves out on purpose — it is resolved automatically there, but a *picked* card still needs to name it explicitly in `namespaces`). */
1374
+ function declaredNamespaces(root) {
1375
+ const namespaces = {};
1376
+ let synthetic = 0;
1377
+ for (const attribute of root.attributes) {
1378
+ if (attribute.prefix === 'xmlns') namespaces[attribute.localName] = attribute.value;else if (attribute.name === 'xmlns') {
1379
+ synthetic += 1;
1380
+ namespaces[synthetic === 1 ? DEFAULT_NS_PREFIX : `${DEFAULT_NS_PREFIX}${synthetic}`] = attribute.value;
1381
+ }
1382
+ }
1383
+ return namespaces;
1384
+ }
1385
+ function prefixFor(element, namespaces) {
1386
+ if (element.namespaceURI === null || element.namespaceURI === undefined) return undefined;
1387
+ if (element.prefix !== null && element.prefix !== undefined && element.prefix !== '') return element.prefix;
1388
+ return Object.entries(namespaces).find(([, uri]) => uri === element.namespaceURI)?.[0];
1389
+ }
1390
+ function qualifiedName(element, namespaces) {
1391
+ const prefix = prefixFor(element, namespaces);
1392
+ return prefix === undefined ? element.localName ?? element.tagName : `${prefix}:${element.localName ?? element.tagName}`;
1393
+ }
1394
+ function xmlNode(element, parentPath, namespaces, context) {
1395
+ const name = qualifiedName(element, namespaces);
1396
+ const siblingIndex = elementSiblingIndex(element);
1397
+ const siblingCount = elementSiblingCount(element);
1398
+ const step = siblingCount > 1 ? `${name}[${siblingIndex}]` : name;
1399
+ const path = `${parentPath}/${step}`;
1400
+ // I am one of several same-named siblings: my own listPath generalises myself, and my children inherit *my* context (innermost repetition wins) instead of whatever ancestor context I received.
1401
+ const selfContext = siblingCount > 1 ? {
1402
+ itemPrefix: path,
1403
+ generalPrefix: `${parentPath}/${name}`,
1404
+ count: siblingCount
1405
+ } : context;
1406
+ const childContext = siblingCount > 1 ? selfContext : context;
1407
+ const attributes = elementAttributes(element);
1408
+ const childElements = [...element.childNodes].filter(node => node.nodeType === 1);
1409
+ const children = [...Object.entries(attributes).map(([key, value]) => attributeNode(key, value, path, childContext)), ...childElements.map(child => xmlNode(child, path, namespaces, childContext))];
1410
+ const text = childElements.length === 0 ? textOf(element) : undefined;
1411
+ return {
1412
+ id: path,
1413
+ label: name,
1414
+ valueType: 'element',
1415
+ xpath: path,
1416
+ ...listFields(path, selfContext),
1417
+ ...(Object.keys(attributes).length > 0 && {
1418
+ attributes
1419
+ }),
1420
+ ...(text !== undefined && {
1421
+ preview: text,
1422
+ valueType: childElements.length === 0 ? 'string' : 'element'
1423
+ }),
1424
+ children
1425
+ };
1426
+ }
1427
+ function attributeNode(name, value, elementPath, context) {
1428
+ const path = `${elementPath}/@${name}`;
1429
+ return {
1430
+ id: path,
1431
+ label: `@${name}`,
1432
+ valueType: 'string',
1433
+ xpath: path,
1434
+ preview: previewOf(value),
1435
+ ...listFields(path, context),
1436
+ children: []
1437
+ };
1438
+ }
1439
+ function elementAttributes(element) {
1440
+ return Object.fromEntries([...element.attributes].filter(attribute => attribute.prefix !== 'xmlns' && attribute.name !== 'xmlns').map(attribute => [attribute.name, attribute.value]));
1441
+ }
1442
+ function elementSiblingIndex(element) {
1443
+ let index = 1;
1444
+ let sibling = element.previousSibling;
1445
+ while (sibling !== null) {
1446
+ if (sibling.nodeType === 1 && sibling.tagName === element.tagName) index += 1;
1447
+ sibling = sibling.previousSibling;
1448
+ }
1449
+ return index;
1450
+ }
1451
+ function elementSiblingCount(element) {
1452
+ const parent = element.parentNode;
1453
+ if (parent === null) return 1;
1454
+ return [...parent.childNodes].filter(node => node.nodeType === 1 && node.tagName === element.tagName).length;
748
1455
  }
1456
+ function textOf(element) {
1457
+ const text = (element.textContent ?? '').trim().replaceAll(/\s+/g, ' ');
1458
+ return text === '' ? undefined : previewOf(text);
1459
+ }
1460
+
749
1461
  /**
750
- * The path to the element the snapshot stamped with `nodeId` (its
751
- * `data-oc-node` attribute — `@opencraw/core`'s `NODE_ID_ATTRIBUTE`,
752
- * `packages/studio/src/page-snapshot/rewrite-document.mapper.ts`). This is
753
- * how a click in the content pane's iframe — which only ever needs to read
754
- * that one attribute off the element the user clicked — turns into
755
- * something `selector-inference`'s other algorithms can work with, without
756
- * the browser-side picker script needing to know anything about ancestry,
757
- * classes or candidate ranking itself.
1462
+ * Builds the PDF canvas's view model (studio plan §3.4, issue #94's 5b) from
1463
+ * an already-read `PdfDocument` (`take-snapshot`'s cached body — no second
1464
+ * read of the file): every cell and row `readPdf` found, in PDF points, plus
1465
+ * per-page counts and whether the page has a text layer at all. Table-specific
1466
+ * diagnostics (the header row, the column bands, the wrapped-row regrouping)
1467
+ * are not here — they depend on a `table` extract's own options, so they come
1468
+ * from `table-preview.use-case.ts` instead, computed live as those options
1469
+ * change.
758
1470
  *
759
- * @param html - The snapshot document (already stamped with node ids).
760
- * @param nodeId - The `data-oc-node` value to find.
761
- * @returns The node's path, or `undefined` when no element carries that id.
1471
+ * @param document - The read PDF.
1472
+ * @returns The view.
762
1473
  */
763
- function pathToNode(html, nodeId) {
764
- const $ = parseDocument$1(html);
765
- const found = $(`[${NODE_ID_ATTRIBUTE}="${cssAttributeValue(nodeId)}"]`);
766
- return found.length === 0 ? undefined : domPathOf($, found.first());
767
- }
768
- function cssAttributeValue(value) {
769
- return value.replaceAll('\\', String.raw`\\`).replaceAll('"', String.raw`\"`);
1474
+ function pdfDocumentView(document) {
1475
+ return {
1476
+ pages: document.pages.map(page => ({
1477
+ number: page.number,
1478
+ width: page.width,
1479
+ height: page.height,
1480
+ rows: page.rows.map(row => ({
1481
+ top: row.top,
1482
+ bottom: row.bottom,
1483
+ cells: row.cells.map(cell => ({
1484
+ x: cell.x,
1485
+ y: cell.y,
1486
+ width: cell.width,
1487
+ height: cell.height,
1488
+ text: cell.text
1489
+ }))
1490
+ })),
1491
+ rowCount: page.rows.length,
1492
+ cellCount: page.rows.reduce((total, row) => total + row.cells.length, 0),
1493
+ hasTextLayer: page.rows.length > 0
1494
+ }))
1495
+ };
770
1496
  }
771
1497
 
772
- /** Attributes worth reading instead of text, in preference order, when the clicked element has one. */
773
- const PREFERRED_ATTRS = {
774
- a: ['href'],
775
- img: ['src', 'alt']
776
- };
777
1498
  /**
778
- * What a `Read` card's `take` should be for the element a pick landed on
779
- * (issue #91, studio plan §3.2): `attr:href` for an `<a>`, `attr:src` for an
780
- * `<img>`, `attr:<name>` for the first `data-*` attribute otherwise, `json`
781
- * for a `<script type="application/json">`, else the default, `text`.
1499
+ * Runs a `table` extract's options against an already-read PDF (studio plan
1500
+ * §3.4, issue #94's 5b: the live preview) — a pure function of the document
1501
+ * and the options, so re-running it on every keystroke as the person edits
1502
+ * `selector`/`until`/`columns` costs nothing: no re-fetch, no re-parse
1503
+ * (`readPdf` already ran once, when `take-snapshot` captured the document).
782
1504
  *
783
- * @param level - The clicked element's own level (the last entry of its `DomPath`).
784
- * @returns The `take` value to put on the `extract` step.
1505
+ * The workbook grid's live preview (issue #94's 5c) is `previewGridTable`,
1506
+ * below: the same idea (a pure re-run of the options against the cached
1507
+ * document), but its own result shape — a workbook table has no page, no
1508
+ * column bands, no wrapped-row regrouping, so folding it into
1509
+ * `TablePreviewMatch`/`TablePreviewResult` would leave most of those fields
1510
+ * meaningless for a grid pick; a `GridTablePreviewMatch`/`GridTablePreviewResult`
1511
+ * sibling reads honestly instead.
1512
+ *
1513
+ * @param document - The already-read PDF.
1514
+ * @param options - The `table` extract's options, not yet compiled to `RegExp`.
1515
+ * @returns Every match, each with the exact row indices to highlight — or `error` (and no matches) when a pattern is not a valid regular expression, expected while the person is still typing it.
785
1516
  */
786
- function takeKindFor(level) {
787
- if (level.tag === 'script' && (level.attrs.type ?? '').toLowerCase() === 'application/json') return 'json';
788
- const preferred = PREFERRED_ATTRS[level.tag];
789
- if (preferred !== undefined) {
790
- const attribute = preferred.find(name => level.attrs[name] !== undefined);
791
- if (attribute !== undefined) return `attr:${attribute}`;
1517
+ function previewPdfTable(document, options) {
1518
+ let query;
1519
+ try {
1520
+ query = {
1521
+ header: compile(options.header, 'selector'),
1522
+ until: options.until === undefined ? undefined : compile(options.until, 'until'),
1523
+ columns: options.columns === undefined ? undefined : Object.fromEntries(Object.entries(options.columns).map(([key, pattern]) => [key, compile(pattern, `columns.${key}`)])),
1524
+ align: options.align
1525
+ };
1526
+ } catch (error) {
1527
+ return {
1528
+ matches: [],
1529
+ error: error instanceof Error ? error.message : String(error)
1530
+ };
1531
+ }
1532
+ const analyses = analyzeTables(document, query);
1533
+ return {
1534
+ matches: analyses.map(analysis => ({
1535
+ page: analysis.page,
1536
+ headerRowIndex: analysis.headerRowIndex,
1537
+ matchedRowIndices: rowIndicesOf(analysis),
1538
+ bands: analysis.bands,
1539
+ bandTolerance: analysis.bandTolerance,
1540
+ table: analysis.table
1541
+ }))
1542
+ };
1543
+ }
1544
+ function rowIndicesOf(analysis) {
1545
+ const indices = new Set([analysis.headerRowIndex, ...analysis.rowGroups.flat()]);
1546
+ if (analysis.untilRowIndex !== undefined) indices.add(analysis.untilRowIndex);
1547
+ return [...indices].sort((a, b) => a - b);
1548
+ }
1549
+ function compile(source, where) {
1550
+ try {
1551
+ return new RegExp(source, 'i');
1552
+ } catch (error) {
1553
+ throw new Error(`${where}: invalid pattern ${source} (${error.message})`, {
1554
+ cause: error
1555
+ });
792
1556
  }
793
- const dataAttribute = Object.keys(level.attrs).find(name => name.startsWith('data-') && name !== 'data-oc-node' && name !== 'data-oc-hidden');
794
- if (dataAttribute !== undefined) return `attr:${dataAttribute}`;
795
- return 'text';
796
1557
  }
797
-
798
1558
  /**
799
- * Which class tokens are worth building a selector on. A hand-authored class
800
- * (`product_pod`, `price_color`) survives a rebuild; a generated one
801
- * (a content hash, a CSS-module suffix, a styled-components token) does not,
802
- * so a candidate built on it looks fine on the snapshot it was picked from
803
- * and breaks on the very next deploy. This is the "generated/hashed class
804
- * names" tradeoff issue #91 calls out explicitly: the heuristics below are a
805
- * reasoned judgement call, not a guarantee — see the doc comments on each
806
- * pattern for what real-world tool it targets.
1559
+ * Runs a `table` extract's options against an already-read workbook (studio
1560
+ * plan §3.4, issue #94's 5c: the live preview) — a pure function of the
1561
+ * document and the options, exactly like `previewPdfTable` above: no
1562
+ * re-fetch, no re-parse (`csvWorkbook`/`readXlsxWorkbook` already ran once,
1563
+ * when `take-snapshot` captured the document).
1564
+ *
1565
+ * @param document - The already-read workbook.
1566
+ * @param options - The `table` extract's options, not yet compiled to `RegExp`.
1567
+ * @returns Every matched table — or `error` (and no matches) when a pattern is not a valid regular expression, expected while the person is still typing it.
807
1568
  */
808
- const GENERATED_TOKEN_PATTERNS = [/** styled-components / emotion: `sc-bdVaJa`, `css-1x2y3z4` */
809
- /^(?:sc|css)-[a-z0-9]+$/i, /** CSS Modules, most bundlers' default hash suffix: `wrapper__3fA9k`, `Button-module_root__aB3dK` */
810
- /__[a-z0-9]{4,10}$/i, /** JSS / MUI: `jss123`, `MuiButton-root-145` */
811
- /^(?:jss|mui[a-z]*-)[a-z0-9-]*\d{2}$/i, /** A token that is only digits: not a class a human would type. */
812
- /^\d+$/, /** A bare hex hash: `a3f9c21`, `1a2b3c4d` (6+ hex chars, nothing else). */
813
- /^[0-9a-f]{6,}$/i, /** Tailwind/atomic-CSS-in-JS runtime classes some bundlers emit: `_1a2b3c` */
814
- /^_[0-9a-z]{5,}$/i];
1569
+ function previewGridTable(document, options) {
1570
+ let query;
1571
+ try {
1572
+ query = {
1573
+ header: compile(options.header, 'selector'),
1574
+ until: options.until === undefined ? undefined : compile(options.until, 'until'),
1575
+ columns: options.columns === undefined ? undefined : Object.fromEntries(Object.entries(options.columns).map(([key, pattern]) => [key, compile(pattern, `columns.${key}`)])),
1576
+ sheet: options.sheet === undefined ? undefined : compile(options.sheet, 'sheet'),
1577
+ headerRows: options.headerRows,
1578
+ fillDown: options.fillDown,
1579
+ includeHidden: options.includeHidden
1580
+ };
1581
+ } catch (error) {
1582
+ return {
1583
+ matches: [],
1584
+ error: error instanceof Error ? error.message : String(error)
1585
+ };
1586
+ }
1587
+ const tables = findGridTables(document, query);
1588
+ return {
1589
+ matches: tables.map(table => ({
1590
+ sheet: table.sheet,
1591
+ title: table.title,
1592
+ header: table.header,
1593
+ rows: table.rows
1594
+ }))
1595
+ };
1596
+ }
815
1597
  /**
816
- * Whether a class token looks hand-authored and safe to build a selector on.
1598
+ * Runs a `table` extract's options against an already-read deck (studio plan
1599
+ * §3.4, issue #94's 5d: the live preview) — a pure function of the document
1600
+ * and the options, exactly like `previewPdfTable`/`previewGridTable` above:
1601
+ * no re-fetch, no re-parse (`readPptxDeck` already ran once, when
1602
+ * `take-snapshot` captured the document).
817
1603
  *
818
- * @param token - One class, no surrounding whitespace.
819
- * @returns `false` for anything matching a known generated-class shape.
1604
+ * @param document - The already-read deck.
1605
+ * @param options - The `table` extract's options, not yet compiled to `RegExp`.
1606
+ * @returns Every matched table — or `error` (and no matches) when a pattern is not a valid regular expression, expected while the person is still typing it.
820
1607
  */
821
- function isStableClassToken(token) {
822
- return token.length > 0 && GENERATED_TOKEN_PATTERNS.every(pattern => !pattern.test(token));
1608
+ function previewDeckTable(document, options) {
1609
+ let query;
1610
+ try {
1611
+ query = {
1612
+ slide: options.slide === undefined ? undefined : compile(options.slide, 'slide'),
1613
+ shapes: options.shapes,
1614
+ align: options.align,
1615
+ header: compile(options.header, 'selector'),
1616
+ until: options.until === undefined ? undefined : compile(options.until, 'until'),
1617
+ columns: options.columns === undefined ? undefined : Object.fromEntries(Object.entries(options.columns).map(([key, pattern]) => [key, compile(pattern, `columns.${key}`)])),
1618
+ headerRows: options.headerRows,
1619
+ fillDown: options.fillDown,
1620
+ includeHidden: options.includeHidden
1621
+ };
1622
+ } catch (error) {
1623
+ return {
1624
+ matches: [],
1625
+ error: error instanceof Error ? error.message : String(error)
1626
+ };
1627
+ }
1628
+ const tables = findDeckTables(document, query);
1629
+ return {
1630
+ matches: tables.map(table => ({
1631
+ slide: table.slide,
1632
+ slideTitle: table.slideTitle,
1633
+ title: table.title,
1634
+ header: table.header,
1635
+ rows: table.rows
1636
+ }))
1637
+ };
823
1638
  }
1639
+
824
1640
  /**
825
- * The subset of `tokens` worth ranking a selector candidate on, order kept.
1641
+ * Handles `deck-preview`: the deck canvas's live preview of a `table`
1642
+ * extract's options (`document-view`'s `table-preview.use-case.ts`'s
1643
+ * `previewDeckTable`, studio plan §3.4, issue #94's 5d), off the same cached
1644
+ * snapshot `take-snapshot`/`deck-view` already captured — a pure computation
1645
+ * over it, so this answers instantly on every option change, no re-fetch or
1646
+ * re-parse.
826
1647
  *
827
- * @param tokens - An element's class list.
828
- * @returns Only the stable tokens.
1648
+ * @param state - The server state.
1649
+ * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
1650
+ * @returns The matched tables, or `error` when an option's pattern does not compile.
1651
+ * @throws Error when no workspace is open, or `command.path` has no cached snapshot yet.
829
1652
  */
830
- function stableClasses(tokens) {
831
- return tokens.filter(token => isStableClassToken(token));
1653
+ function handleDeckPreview(state, command) {
1654
+ const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
1655
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
1656
+ if (!isDeckDocument(snapshot.body)) throw new Error(`"${command.recipeId}" at "${command.path}" is not a deck snapshot: deck-preview only reads a read PowerPoint document`);
1657
+ return previewDeckTable(snapshot.body, command.options);
832
1658
  }
1659
+
833
1660
  /**
834
- * Whether two class lists describe "the same kind of element" for list
835
- * inference: same stable classes, generated tokens (which often differ
836
- * per-instance, e.g. a React `key`-derived class) ignored on both sides.
1661
+ * Handles `deck-view`: the deck canvas's slides, shapes, tables, charts and
1662
+ * notes (`document-view`'s `deck-view.mapper.ts`, studio plan §3.4, issue
1663
+ * #94's 5d), off the same cached snapshot `take-snapshot`/`document-tree`
1664
+ * already use.
837
1665
  *
838
- * @param a - One element's classes.
839
- * @param b - Another element's classes.
840
- * @returns Whether their stable class sets are equal, order ignored.
1666
+ * @param state - The server state.
1667
+ * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
1668
+ * @returns The document's view.
1669
+ * @throws Error when no workspace is open, `command.path` has no cached snapshot yet, or the snapshot's document is not a read deck.
841
1670
  */
842
- function sameClassSignature(a, b) {
843
- const left = new Set(stableClasses(a));
844
- const right = new Set(stableClasses(b));
845
- if (left.size !== right.size) return false;
846
- return [...left].every(token => right.has(token));
1671
+ function handleDeckView(state, command) {
1672
+ const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
1673
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
1674
+ if (!isDeckDocument(snapshot.body)) throw new Error(`"${command.recipeId}" at "${command.path}" is not a deck snapshot: the deck canvas only reads a read PowerPoint document`);
1675
+ return deckDocumentView(snapshot.body);
847
1676
  }
848
1677
 
849
1678
  /**
850
- * Thrown when a pick or a list-inference walk crosses a declarative shadow
851
- * root (`<template shadowrootmode>`). v1 does not pierce shadow DOM: rather
852
- * than infer a selector that silently matches the wrong thing (or nothing,
853
- * once the page hydrates), the studio surfaces this and stops.
1679
+ * Handles `document-tree`: the content pane's tree canvas for a JSON, YAML
1680
+ * or XML document (`document-view`, studio plan §3.4, issue #94's 5a), off
1681
+ * the same cached snapshot `take-snapshot`/`inspect-page` already use.
1682
+ *
1683
+ * @param state - The server state.
1684
+ * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
1685
+ * @returns The tree.
1686
+ * @throws Error when no workspace is open, `command.path` has no cached snapshot yet, or the snapshot's document is not JSON/YAML/XML (no parsed body at all in web mode; PDF/workbook/deck in api mode — see `documentTreeView`'s own doc comment).
854
1687
  */
855
- class ShadowDomUnsupportedError extends Error {
856
- path;
857
- constructor(path) {
858
- super(`"${path}" is inside a shadow root: shadow DOM is not supported for picking (v1)`);
859
- this.path = path;
860
- this.name = 'ShadowDomUnsupportedError';
861
- }
1688
+ function handleDocumentTree(state, command) {
1689
+ const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
1690
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
1691
+ if (snapshot.body === undefined) throw new Error(`"${command.recipeId}" at "${command.path}" is a web-mode (HTML) snapshot: the tree canvas only reads a JSON/YAML/XML document`);
1692
+ return documentTreeView(snapshot.body);
862
1693
  }
863
1694
 
864
- /** Attributes ranked just under `id`: named explicitly, not "any `data-*`", because a generated `data-reactid` is no more stable than a generated class. */
865
- const STABLE_ATTRS = ['data-testid', 'data-id', 'itemprop'];
866
- const TIER_ORDER$1 = {
867
- 'id': 0,
868
- 'data-attr': 1,
869
- 'class': 2,
870
- 'structure': 3,
871
- 'position': 4
872
- };
873
1695
  /**
874
- * Every selector candidate worth trying for the last node of `path`, ranked
875
- * best-first: `id` > stable `data-*`/`itemprop` attributes > stable class
876
- * tokens > ancestor structure (`parent > tag`) > position (`nth-of-type`) as
877
- * a last resort (issue #91's ranking, studio plan §3.2). Structure and
878
- * position candidates only look as far up as `path` itself goes — pass a
879
- * sub-path (e.g. from an item's root down) to keep a field's selector
880
- * relative to its item instead of absolute to the document.
1696
+ * Finds and parses one input recipe of a workspace folder, and the output
1697
+ * recipe it names.
881
1698
  *
882
- * @param path - The node's ancestry, root (or scope root) first, itself last.
883
- * @param includePosition - `false` for an item selector: a position candidate would only ever match one of the items, which is never what an item selector should do.
884
- * @returns Candidates, best tier first; ties keep this function's own order.
1699
+ * @param folder - The workspace folder.
1700
+ * @param recipeId - The input recipe's id.
1701
+ * @returns The parsed pair.
1702
+ * @throws RecipeValidationError when either recipe does not parse.
1703
+ * @throws Error when the input or its output is not in the folder.
885
1704
  */
886
- function candidatesFor(path, includePosition = true) {
887
- const node = path.at(-1);
888
- if (node === undefined) return [];
889
- const candidates = [];
890
- if (node.id !== undefined && isStableId(node.id)) candidates.push({
891
- selector: `#${cssEscape(node.id)}`,
892
- tier: 'id'
1705
+ async function loadRecipePair(folder, recipeId) {
1706
+ const documents = await readRecipeSource(folder);
1707
+ const inputDocument = documents.find(document => kindOf(document.content) === 'input' && idOf(document.content) === recipeId);
1708
+ if (inputDocument === undefined) throw new Error(`no input recipe "${recipeId}" in this workspace`);
1709
+ const input = parseInputRecipe(inputDocument.content, inputDocument.source);
1710
+ const outputDocument = documents.find(document => kindOf(document.content) === 'output' && idOf(document.content) === input.output);
1711
+ if (outputDocument === undefined) throw new Error(`no output recipe "${input.output}" in this workspace`);
1712
+ return {
1713
+ input,
1714
+ output: parseOutputRecipe(outputDocument.content, outputDocument.source)
1715
+ };
1716
+ }
1717
+ function kindOf(content) {
1718
+ return content?.kind;
1719
+ }
1720
+ function idOf(content) {
1721
+ return content?.id;
1722
+ }
1723
+
1724
+ /**
1725
+ * Runs one input recipe of a workspace folder as a sample: `debug: true` (so
1726
+ * each `record:emit` carries its scope snapshot and mapping trace, and each
1727
+ * `record:reject` its scope), an in-memory sink, and `budget` as the
1728
+ * crawler's `sample` (`@opencraw/core`'s `CrawlOptions.sample`, #89). Every
1729
+ * engine event becomes a trace line with the same `traceLine` formatter the
1730
+ * cli's `--trace` uses; every emitted record reaches `callbacks.onRecord` as
1731
+ * it happens, not only in the final result. Step events (`step:start`,
1732
+ * `step:skip`, `step:retry`) are aggregated by path into `result.steps`, so
1733
+ * `explain-why` (#92) can later say whether the step that bound a mapping's
1734
+ * source id ran, was skipped, or retried — without a second run.
1735
+ *
1736
+ * @param folder - The workspace folder the recipe lives in.
1737
+ * @param recipeId - The input recipe to run.
1738
+ * @param budget - The sample budget; unset runs the recipe to completion (still capped by its own `limits`).
1739
+ * @param callbacks - Where trace lines and records go as the run proceeds.
1740
+ * @param browser - Browser launch settings for a web-mode recipe (an executable path override, headless…); the server-wide setting `studio-http.use-case.ts`'s `StudioServerOptions.browser` carries in, e.g. `OPENCRAW_CHROMIUM` in a sandbox with no full Playwright install.
1741
+ * @returns A handle: `stop` closes the crawler cleanly, `result` resolves when the run ends.
1742
+ * @throws RecipeValidationError, RecipeBindingError, Error when the recipe or its output cannot be loaded or bound.
1743
+ */
1744
+ async function runSample(folder, recipeId, budget, callbacks, browser) {
1745
+ const {
1746
+ input,
1747
+ output
1748
+ } = await loadRecipePair(folder, recipeId);
1749
+ const set = bindRecipeSet(output, [input]);
1750
+ const sink = memorySink();
1751
+ const records = [];
1752
+ const rejectedRecords = [];
1753
+ const steps = new Map();
1754
+ const crawler = createCrawler({
1755
+ sink,
1756
+ debug: true,
1757
+ sample: budget,
1758
+ browser,
1759
+ onEvent: event => {
1760
+ const line = traceLine(event);
1761
+ if (line !== undefined) callbacks.onTraceLine(line);
1762
+ trackStep(steps, event);
1763
+ if (event.type === 'record:emit') {
1764
+ const record = {
1765
+ key: event.key,
1766
+ data: event.data,
1767
+ scope: event.scope,
1768
+ mapping: event.mapping
1769
+ };
1770
+ records.push(record);
1771
+ callbacks.onRecord(record);
1772
+ } else if (event.type === 'record:reject') {
1773
+ const rejected = {
1774
+ field: event.field,
1775
+ reason: event.reason,
1776
+ url: event.url,
1777
+ scope: event.scope
1778
+ };
1779
+ rejectedRecords.push(rejected);
1780
+ callbacks.onRejected?.(rejected);
1781
+ }
1782
+ }
893
1783
  });
894
- for (const name of STABLE_ATTRS) {
895
- const value = node.attrs[name];
896
- if (value !== undefined && value.length > 0) candidates.push({
897
- selector: `${node.tag}[${name}="${cssValue(value)}"]`,
898
- tier: 'data-attr'
1784
+ const result = runToResult(crawler, set, records, rejectedRecords, steps);
1785
+ return {
1786
+ stop: () => crawler.close(),
1787
+ result
1788
+ };
1789
+ }
1790
+ /**
1791
+ * Folds one `step:*` event into `steps`, by path: `skip` always wins over an
1792
+ * earlier `ran`/`retried` outcome for the same path (a step skipped even
1793
+ * once is worth flagging), `retry` wins over a plain `ran`, and a path seen
1794
+ * only as `finish`/`start` (no skip, no retry) settles on `ran`. A `forEach`
1795
+ * runs its body once per item, so the same path folds repeatedly; this is
1796
+ * deliberately a summary across the whole sample, not a per-record trace.
1797
+ */
1798
+ function trackStep(steps, event) {
1799
+ if (event.type === 'step:start') {
1800
+ if (!steps.has(event.path)) steps.set(event.path, {
1801
+ path: event.path,
1802
+ stepType: event.stepType,
1803
+ stepId: event.stepId,
1804
+ outcome: 'ran'
1805
+ });
1806
+ return;
1807
+ }
1808
+ if (event.type === 'step:retry') {
1809
+ steps.set(event.path, {
1810
+ path: event.path,
1811
+ stepType: event.stepType,
1812
+ stepId: event.stepId,
1813
+ outcome: 'retried',
1814
+ error: event.error
899
1815
  });
1816
+ return;
900
1817
  }
901
- const classes = stableClasses(node.classes);
902
- if (classes.length > 0) candidates.push({
903
- selector: `${node.tag}.${classes.map(token => cssEscape(token)).join('.')}`,
904
- tier: 'class'
905
- });
906
- candidates.push(...structureCandidates(path));
907
- if (includePosition) candidates.push(...positionCandidates(path));
908
- return sortByTier(candidates);
909
- }
910
- /** `ancestor > ... > tag`, growing from 2 to 4 levels (or to the top of `path`), each ancestor named by its own best simple form. */
911
- function structureCandidates(path) {
912
- const node = path.at(-1);
913
- if (node === undefined) return [];
914
- const candidates = [];
915
- const maxDepth = Math.min(path.length - 1, 3);
916
- for (let depth = 1; depth <= maxDepth; depth++) {
917
- const ancestor = path.at(-1 - depth);
918
- if (ancestor === undefined) break;
919
- const between = Array.from({
920
- length: depth - 1
921
- }, (_, index) => path.at(-depth + index)?.tag ?? '');
922
- const parts = [simpleSelector(ancestor), ...between, node.tag];
923
- candidates.push({
924
- selector: parts.join(' > '),
925
- tier: 'structure'
1818
+ if (event.type === 'step:skip') {
1819
+ steps.set(event.path, {
1820
+ path: event.path,
1821
+ stepType: event.stepType,
1822
+ stepId: event.stepId,
1823
+ outcome: 'skipped',
1824
+ error: event.error
926
1825
  });
927
1826
  }
928
- return candidates;
929
1827
  }
930
- /** `parent > tag:nth-of-type(n)`, the position-only fallback; never `nth-child`, which counts every sibling tag, not just this element's own. */
931
- function positionCandidates(path) {
932
- const node = path.at(-1);
933
- if (node === undefined) return [];
934
- const own = node.siblingCount > 1 ? `${node.tag}:nth-of-type(${node.index})` : node.tag;
935
- const parent = path.at(-2);
936
- if (parent === undefined) return [{
937
- selector: own,
938
- tier: 'position'
939
- }];
940
- return [{
941
- selector: `${simpleSelector(parent)} > ${own}`,
942
- tier: 'position'
943
- }];
1828
+ async function runToResult(crawler, set, records, rejectedRecords, steps) {
1829
+ try {
1830
+ const report = await crawler.run(set);
1831
+ const recipe = report.recipes[0];
1832
+ return {
1833
+ recipeId: recipe.recipeId,
1834
+ emitted: recipe.emitted,
1835
+ rejected: recipe.rejected,
1836
+ duplicates: recipe.duplicates,
1837
+ durationMs: recipe.durationMs,
1838
+ error: recipe.error,
1839
+ stoppedBy: recipe.stoppedBy,
1840
+ records,
1841
+ rejectedRecords,
1842
+ // eslint-disable-next-line unicorn/prefer-iterator-to-array -- `.toArray()` needs a `lib` newer than this repo's `es2022` (tsconfig.base.json)
1843
+ steps: [...steps.values()]
1844
+ };
1845
+ } finally {
1846
+ await crawler.close();
1847
+ }
944
1848
  }
945
- /** A level's own selector fragment, no combinators: id, else stable classes, else the bare tag. */
946
- function simpleSelector(level) {
947
- if (level.id !== undefined && isStableId(level.id)) return `#${cssEscape(level.id)}`;
948
- const classes = stableClasses(level.classes);
949
- return classes.length > 0 ? `${level.tag}.${classes.map(token => cssEscape(token)).join('.')}` : level.tag;
1849
+
1850
+ /** @returns A fresh, empty cache. */
1851
+ function createLastRunCache() {
1852
+ return {
1853
+ entries: new Map()
1854
+ };
950
1855
  }
951
- function sortByTier(candidates) {
952
- return [...candidates].sort((a, b) => TIER_ORDER$1[a.tier] - TIER_ORDER$1[b.tier]);
1856
+ /** Records a finished sample run, replacing whatever this recipe's cache entry held before. */
1857
+ function recordLastRun(cache, recipeId, result) {
1858
+ cache.entries.set(recipeId, {
1859
+ recipeId,
1860
+ result
1861
+ });
953
1862
  }
954
- /** Escapes a CSS identifier segment (an id or a class token) well enough for the shapes real sites use; not a full CSS.escape polyfill. */
955
- function cssEscape(token) {
956
- return token.replaceAll(/([^\w-])/gi, String.raw`\$1`);
1863
+ /** @returns The recipe's last completed sample run, or `undefined` when none has finished yet. */
1864
+ function lastRunOf(cache, recipeId) {
1865
+ return cache.entries.get(recipeId);
957
1866
  }
958
- /** Escapes a value inside `[name="…"]`. */
959
- function cssValue(value) {
960
- return value.replaceAll(/(["\\])/g, String.raw`\$1`);
1867
+
1868
+ /**
1869
+ * A best-effort hint about what a missing value's bound id actually held in
1870
+ * the scope snapshot: the keys of an object, or the length of a list — the
1871
+ * fallback when there is no per-item DOM snapshot to compare a selector
1872
+ * against (see `why-sentence.mapper.ts`'s doc comment for why the richer,
1873
+ * "the item has `.price_new`" hint is out of this phase's scope).
1874
+ *
1875
+ * @param scope - The record's scope snapshot (`SampleRunRecord.scope`/`SampleRunRejected.scope`).
1876
+ * @param boundId - The leading id the rule's `from` reads.
1877
+ * @returns A short clause (no leading punctuation), or `undefined` when the id is not in scope or nothing useful can be said about it.
1878
+ */
1879
+ function hintFor(scope, boundId) {
1880
+ if (scope === undefined || boundId === undefined) return undefined;
1881
+ const value = scope[boundId];
1882
+ if (value === undefined) return undefined;
1883
+ if (Array.isArray(value)) return value.length === 0 ? undefined : `"${boundId}" is a list of ${value.length}, not a single value`;
1884
+ if (typeof value === 'object' && value !== null) {
1885
+ const keys = Object.keys(value);
1886
+ return keys.length === 0 ? undefined : `"${boundId}" has: ${keys.slice(0, 5).join(', ')}`;
1887
+ }
1888
+ return undefined;
961
1889
  }
962
1890
 
963
- const TIER_ORDER = {
964
- 'id': 0,
965
- 'data-attr': 1,
966
- 'class': 2,
967
- 'structure': 3,
968
- 'position': 4
969
- };
970
1891
  /**
971
- * Verifies and orders selector candidates against real markup, by running
972
- * each one through the engine's own CSS matching (`@opencraw/core`'s
973
- * `countMatches`/`matchesOf`) rather than reimplementing it — issue #91's
974
- * "every candidate is verified" requirement.
1892
+ * Builds the Why? tab's sentence (studio plan §4.4, issue #92) from the
1893
+ * facts `why.use-case.ts` gathers: the value the mapping read (and each
1894
+ * transform's effect on it), the step that bound its source id and whether
1895
+ * it ran, was skipped, or bound nothing this recipe knows of, the
1896
+ * missing-value policy that applied, and a hint about what was actually
1897
+ * there instead.
975
1898
  *
976
- * Ordering: candidates that do not match `targetNodeId` (when given) sort
977
- * last; among the rest, best tier first, and within a tier the fewest
978
- * matches first (the most specific selector that still hits the target).
979
- * A candidate matching nothing is dropped entirely: it is not a real option.
1899
+ * This is the scope-snapshot-only half of the design: phase 2's per-item DOM
1900
+ * snapshot (the CSS-selector "the item has `.price_new`" hint the issue's
1901
+ * example shows) is not wired in here — `sample-run` keeps the mapping's
1902
+ * scope snapshot (bound ids and their values), not a per-`forEach`-item DOM
1903
+ * capture, and building that link is a bigger integration than this phase's
1904
+ * budget covers (see this package's `page-snapshot` slice, which captures a
1905
+ * step's page for the content pane, not per-iteration). `hint`, when given,
1906
+ * is instead built from the scope snapshot itself (`item-hint.algorithm.ts`).
980
1907
  *
981
- * @param html - The document to verify against (the snapshot, or the live page for the second, cross-check pass).
982
- * @param candidates - Unranked candidates, e.g. from `candidatesFor`.
983
- * @param targetNodeId - The `data-oc-node` id the pick landed on, when the document carries those stamps (the snapshot does; a live page fetched separately does not).
984
- * @returns Every candidate that matched at least one element, ranked best first.
1908
+ * @param facts - What is known about the value.
1909
+ * @returns One sentence, always ending in a period.
985
1910
  */
986
- function rankCandidates(html, candidates, targetNodeId) {
987
- const ranked = candidates.map(candidate => {
988
- const matches = countMatches(html, candidate.selector);
989
- const hitsTarget = targetNodeId === undefined ;
990
- return {
991
- ...candidate,
992
- matches,
993
- hitsTarget
994
- };
995
- });
996
- return ranked.filter(candidate => candidate.matches > 0).sort((a, b) => Number(b.hitsTarget) - Number(a.hitsTarget) || TIER_ORDER[a.tier] - TIER_ORDER[b.tier] || a.matches - b.matches);
1911
+ function buildWhySentence(facts) {
1912
+ return facts.outcome === 'rejected' ? rejectedSentence(facts) : missingSentence(facts);
997
1913
  }
998
- /** The single best candidate, or `undefined` when none of them matched anything. */
999
- function bestCandidate(html, candidates, targetNodeId) {
1000
- return rankCandidates(html, candidates, targetNodeId)[0];
1914
+ function rejectedSentence(facts) {
1915
+ const parts = [`"${facts.field}" was rejected: ${facts.reason ?? 'the engine gave no reason'}`];
1916
+ if (facts.policy !== undefined) parts.push(` (policy: ${facts.policy})`);
1917
+ return `${parts.join('')}.`;
1918
+ }
1919
+ function missingSentence(facts) {
1920
+ const parts = [`"${facts.field}" is missing`, stepClause(facts)];
1921
+ if (facts.policy !== undefined) parts.push(`; left ${facts.policy === 'null' ? 'null' : facts.policy} by policy`);
1922
+ if (facts.hint !== undefined) parts.push(`; ${facts.hint}`);
1923
+ return `${parts.join('')}.`;
1924
+ }
1925
+ function stepClause(facts) {
1926
+ const boundId = facts.boundId ?? facts.field;
1927
+ const step = facts.step;
1928
+ if (step === undefined) return ': no step in this recipe binds its source';
1929
+ const label = step.stepId === undefined ? describeStepType(step.stepType) : `${describeStepType(step.stepType)} "${step.stepId}"`;
1930
+ if (step.outcome === 'not-found') return `: no step in this recipe binds "${boundId}"`;
1931
+ if (step.outcome === 'skipped') return `: ${label} (${step.path}), which binds "${boundId}", was skipped${step.error === undefined ? '' : ` (${step.error})`}`;
1932
+ if (step.outcome === 'retried') return `: ${label} (${step.path}), which binds "${boundId}", had to retry${step.error === undefined ? '' : ` (${step.error})`} and still found nothing for "${facts.field}"`;
1933
+ return `: ${label} (${step.path}) ran and bound "${boundId}", but "${facts.field}" reads ${sourceOf(facts)} from it, which was empty`;
1934
+ }
1935
+ function sourceOf(facts) {
1936
+ return facts.trace?.from === undefined ? 'a path' : JSON.stringify(facts.trace.from);
1937
+ }
1938
+ /** A step's type read as a short phrase, matching the Steps outline's own vocabulary (studio plan §4.1: "Go to…", "Read… from…"). */
1939
+ function describeStepType(stepType) {
1940
+ switch (stepType) {
1941
+ case 'extract':
1942
+ {
1943
+ return 'the Read step';
1944
+ }
1945
+ case 'request':
1946
+ {
1947
+ return 'the request step';
1948
+ }
1949
+ case 'forEach':
1950
+ {
1951
+ return 'the loop';
1952
+ }
1953
+ case 'paginate':
1954
+ {
1955
+ return 'the pagination';
1956
+ }
1957
+ case 'goto':
1958
+ {
1959
+ return 'the Go to step';
1960
+ }
1961
+ default:
1962
+ {
1963
+ return `the ${stepType} step`;
1964
+ }
1965
+ }
1001
1966
  }
1002
1967
 
1003
1968
  /**
1004
- * Infers "these two picks are the same field on two items of a list" from
1005
- * two node paths, by walking up from both to the nearest ancestors that are
1006
- * real siblings (the same parent) with the same tag and stable-class
1007
- * signature — studio plan §3.2, issue #91. That ancestor is the item; the
1008
- * remainder of each path is the field, relative to it.
1009
- *
1010
- * Because a `DomPath` is built root-down (`dom-path.model.ts`), two paths
1011
- * from the same document are byte-for-byte identical up to their real
1012
- * common ancestor and diverge exactly at its differing children — so the
1013
- * *first* level where they differ is always the nearest-sibling test point;
1014
- * there is no need to look further down. This also makes the "wrapper trap"
1015
- * (an extra single-child element around each item) resolve itself: a
1016
- * wrapper's own instances are never one another's siblings, only the
1017
- * genuine repeating element is, so the walk naturally lands there instead of
1018
- * on the wrapper (see the wrapper-trap fixture in this slice's tests).
1969
+ * Explains one missing or rejected value from a recipe's last sample run
1970
+ * (issue #92's Why? tab, studio plan §4.4): the mapping trace and scope
1971
+ * snapshot `run-sample` kept, the recipe's own rule and field for the
1972
+ * missing-value policy, and the step that bound the value's source id — did
1973
+ * it run, was it skipped, or does no step in this recipe bind it at all.
1019
1974
  *
1020
- * @param pathA - The first click's path.
1021
- * @param pathB - The second click's path.
1022
- * @returns The item and field shapes (generalised) plus each pick's raw path, or `null` when the two picks are not on a coherent list (different branches entirely, the same node twice, or one path nested inside the other).
1023
- * @throws ShadowDomUnsupportedError when either path crosses a declarative shadow root.
1975
+ * @param folder - The workspace folder (to re-read the recipe pair: the last
1976
+ * run only kept the run's own data, not the recipe text, and a save may
1977
+ * have happened since).
1978
+ * @param cache - The server's last-run cache (`sample-run`'s `LastRunCache`).
1979
+ * @param loadPair - Loads the input/output recipe pair; injected so this
1980
+ * stays testable without a real workspace folder (`sample-run`'s
1981
+ * `loadRecipePair` in production).
1982
+ * @param target - What was clicked.
1983
+ * @returns The explanation.
1984
+ * @throws Error when no sample run has finished for this recipe yet, or the target does not name a real record/field.
1024
1985
  */
1025
- function inferList(pathA, pathB) {
1026
- if (crossesShadowRoot(pathA)) throw new ShadowDomUnsupportedError(pathToString(pathA));
1027
- if (crossesShadowRoot(pathB)) throw new ShadowDomUnsupportedError(pathToString(pathB));
1028
- let divergedAt = -1;
1029
- const limit = Math.min(pathA.length, pathB.length);
1030
- for (let index = 0; index < limit; index++) {
1031
- if (!sameLevel(pathA[index], pathB[index])) {
1032
- divergedAt = index;
1033
- break;
1034
- }
1035
- }
1036
- // Identical paths, or one a strict prefix of the other (the same node picked twice, or one element nested inside the other): not a list.
1037
- if (divergedAt <= 0) return null;
1038
- const branchA = pathA[divergedAt];
1039
- const branchB = pathB[divergedAt];
1040
- if (branchA.tag !== branchB.tag || !sameClassSignature(branchA.classes, branchB.classes)) return null;
1041
- if (branchA.index === branchB.index) return null; // Same position: not two different items.
1042
- const ancestors = pathA.slice(0, divergedAt);
1043
- const fieldPathA = pathA.slice(divergedAt);
1044
- const fieldPathB = pathB.slice(divergedAt);
1045
- const fieldPath = mergeRelativePaths(fieldPathA, fieldPathB);
1986
+ async function explainWhy(folder, cache, loadPair, target) {
1987
+ const entry = lastRunOf(cache, target.recipeId);
1988
+ if (entry === undefined) throw new Error(`no finished sample run for "${target.recipeId}" yet — run a sample first`);
1989
+ const {
1990
+ input,
1991
+ output
1992
+ } = await loadPair(folder, target.recipeId);
1993
+ return target.kind === 'rejected' ? explainRejected(target.recipeId, input, output, entry.result.rejectedRecords, entry.result.steps, target.rejectedIndex) : explainMissing(target.recipeId, input, output, entry.result.records, entry.result.steps, target.recordIndex, target.field);
1994
+ }
1995
+ function explainRejected(recipeId, input, output, rejectedRecords, steps, index) {
1996
+ const rejected = rejectedRecords[index];
1997
+ if (rejected === undefined) throw new Error(`no rejected record at index ${index}`);
1998
+ const rule = ruleFor(input.mapping, rejected.field);
1999
+ const field = fieldFor(output.fields, rejected.field);
2000
+ const policy = field === undefined ? undefined : resolveMissingPolicy(field, output, rule);
2001
+ const boundId = boundIdOf(rule);
2002
+ const step = boundId === undefined ? undefined : findStep(input.steps, boundId, steps);
2003
+ const hint = hintFor(rejected.scope, boundId);
2004
+ const sentence = buildWhySentence({
2005
+ field: rejected.field,
2006
+ outcome: 'rejected',
2007
+ reason: rejected.reason,
2008
+ policy,
2009
+ boundId,
2010
+ step,
2011
+ hint
2012
+ });
1046
2013
  return {
1047
- itemPath: [...ancestors, fieldPath[0]],
1048
- fieldPath,
1049
- fieldPathA,
1050
- fieldPathB
2014
+ sentence,
2015
+ field: rejected.field,
2016
+ recipeId,
2017
+ outcome: 'rejected',
2018
+ reason: rejected.reason,
2019
+ policy,
2020
+ stepPath: step?.path
1051
2021
  };
1052
2022
  }
1053
- function sameLevel(a, b) {
1054
- return a.tag === b.tag && a.id === b.id && a.index === b.index && a.siblingCount === b.siblingCount && sameClassSignature(a.classes, b.classes);
2023
+ function explainMissing(recipeId, input, output, records, steps, index, field) {
2024
+ const record = records[index];
2025
+ if (record === undefined) throw new Error(`no record at index ${index}`);
2026
+ const trace = record.mapping?.[field];
2027
+ const rule = ruleFor(input.mapping, field);
2028
+ const fieldSpec = fieldFor(output.fields, field);
2029
+ const policy = fieldSpec === undefined ? undefined : resolveMissingPolicy(fieldSpec, output, rule);
2030
+ const boundId = boundIdOf(rule);
2031
+ const step = boundId === undefined ? undefined : findStep(input.steps, boundId, steps);
2032
+ const hint = hintFor(record.scope, boundId);
2033
+ const sentence = buildWhySentence({
2034
+ field,
2035
+ outcome: 'missing',
2036
+ policy,
2037
+ trace,
2038
+ boundId,
2039
+ step,
2040
+ hint
2041
+ });
2042
+ return {
2043
+ sentence,
2044
+ field,
2045
+ recipeId,
2046
+ outcome: 'missing',
2047
+ policy,
2048
+ stepPath: step?.path
2049
+ };
1055
2050
  }
1056
2051
  /**
1057
- * Zips two item-relative paths (item first) level by level into one that
1058
- * only keeps what both share: the tag (must already match, checked by the
1059
- * caller for level 0), the intersection of stable classes, and attributes
1060
- * present with the same value on both sides. Shorter than the other when
1061
- * the two picks' local structure differs in depth (a real possibility once
1062
- * a field is missing on one item; `list-inference.algorithm.test.ts`'s
1063
- * shifted-field fixture is exactly this) — the merge just stops there,
1064
- * which is fine: a field selector only needs to reach as deep as the
1065
- * shallower of the two picks.
2052
+ * The rule bound to one output field path: a direct match on `input.mapping`
2053
+ * (its keys are themselves dotted target paths — `stock.inStock`, `tax` —
2054
+ * `map-record.use-case.ts` writes each with `setPath`), or, for a path
2055
+ * inside an `each` list's items (`variants[1].size`), the item's own rule
2056
+ * inside the list rule's `fields`. Only one level of `each` nesting is
2057
+ * resolved; a field inside a nested `each` (an `each` of `each`) is not —
2058
+ * out of this phase's scope.
1066
2059
  */
1067
- function mergeRelativePaths(a, b) {
1068
- const length = Math.min(a.length, b.length);
1069
- const merged = [];
1070
- for (let index = 0; index < length; index++) {
1071
- const left = a[index];
1072
- const right = b[index];
1073
- if (left.tag !== right.tag) break;
1074
- merged.push(mergeLevel(left, right));
1075
- }
1076
- return merged.length > 0 ? merged : [a[0]];
2060
+ function ruleFor(mapping, path) {
2061
+ const direct = mapping[path];
2062
+ if (direct !== undefined) return direct;
2063
+ const match = /^([^[]+)\[\d+\]\.(.+)$/.exec(path);
2064
+ if (match === null) return undefined;
2065
+ const parent = mapping[match[1]];
2066
+ return parent !== undefined && 'each' in parent ? parent.fields[match[2]] : undefined;
1077
2067
  }
1078
- function mergeLevel(a, b) {
1079
- const classes = stableClasses(a.classes).filter(token => b.classes.includes(token));
1080
- const attrs = {};
1081
- for (const [name, value] of Object.entries(a.attrs)) {
1082
- if (name !== 'class' && name !== 'id' && b.attrs[name] === value) attrs[name] = value;
2068
+ /** The output field spec at one dotted path (`stock.inStock`, `variants[1].size`), walking `object`/`array` members the same way `map-record.use-case.ts` builds them. */
2069
+ function fieldFor(fields, path) {
2070
+ let current = fields;
2071
+ let field;
2072
+ for (const token of path.split('.')) {
2073
+ const match = /^([^[]+)(\[\d+\])?$/.exec(token);
2074
+ if (match === null || current === undefined) return undefined;
2075
+ field = current[match[1]];
2076
+ if (field === undefined) return undefined;
2077
+ current = match[2] === undefined ? field.fields : field.items?.fields;
1083
2078
  }
1084
- return {
1085
- tag: a.tag,
1086
- id: a.id !== undefined && a.id === b.id ? a.id : undefined,
1087
- classes,
1088
- attrs,
1089
- index: 1,
1090
- siblingCount: Math.max(a.siblingCount, b.siblingCount, 1)
1091
- };
2079
+ return field;
1092
2080
  }
1093
- function pathToString(path) {
1094
- return path.map(level => level.tag + (level.id === undefined ? '' : `#${level.id}`)).join(' > ');
2081
+ /** The leading id a `from` rule reads (`item` of `item.description`, `price` of `price`); `undefined` for an `each` rule, a `.` (self), or a rule this run found nothing to say about. */
2082
+ function boundIdOf(rule) {
2083
+ if (rule === undefined || !('from' in rule)) return undefined;
2084
+ const first = Array.isArray(rule.from) ? rule.from[0] : rule.from;
2085
+ const match = /^([A-Z_][\w-]*)/i.exec(first);
2086
+ return match?.[1];
1095
2087
  }
1096
-
1097
2088
  /**
1098
- * Handles `infer-selector`: turns one or two picked nodes (their
1099
- * `data-oc-node` ids, off the cached snapshot) into a verified selector —
1100
- * one id for a `Read` card's field, two for the safe item+field list shape
1101
- * `selector-inference`'s `inferList` builds by construction (issue #91,
1102
- * studio plan §3.2). All the actual candidate/ranking/list-inference work
1103
- * is `selector-inference`'s (pure); this handler only locates the clicked
1104
- * nodes in the cached document and calls into it.
2089
+ * Finds the step of the recipe whose `id` or `as` (a `forEach`'s loop
2090
+ * variable) is `boundId`, walking into `forEach`/`paginate`/`if` bodies the
2091
+ * same way `scope-outline`'s outline does, and reports how it went over the
2092
+ * whole sample run from `stepSummaries` (`sample-run`'s per-path summary).
1105
2093
  *
1106
- * @param state - The server state.
1107
- * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
1108
- * @returns A field pick, a list pick, or `"unsupported"` with why.
1109
- * @throws Error when no workspace is open, or `command.path` has no cached snapshot yet.
2094
+ * @returns `undefined` only when nothing in `steps` looks like a step at all; a `boundId` that names no real step still gets a fact, with `outcome: 'not-found'`.
1110
2095
  */
1111
- function handleInferSelector(state, command) {
1112
- const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
1113
- if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
1114
- const html = snapshot.html;
1115
- const paths = command.nodeIds.map(nodeId => pathToNode(html, nodeId));
1116
- if (paths.includes(undefined)) return {
1117
- kind: 'unsupported',
1118
- reason: 'one of the picked nodes is no longer in the snapshot; take a fresh snapshot and try again'
2096
+ function findStep(steps, boundId, stepSummaries) {
2097
+ const found = search(steps, boundId, 'steps');
2098
+ if (found === undefined) return {
2099
+ path: '',
2100
+ stepType: '',
2101
+ outcome: 'not-found'
1119
2102
  };
1120
- const [pathA, pathB] = paths;
1121
- if (pathB === undefined) return {
1122
- kind: 'field',
1123
- field: fieldPick(html, pathA)
2103
+ const summary = stepSummaries.find(entry => entry.path === found.path);
2104
+ return {
2105
+ path: found.path,
2106
+ stepType: found.step.type ?? 'unknown',
2107
+ stepId: found.step.id,
2108
+ outcome: summary?.outcome ?? 'ran',
2109
+ error: summary?.error
1124
2110
  };
1125
- try {
1126
- const result = inferList(pathA, pathB);
1127
- if (result === null) return {
1128
- kind: 'unsupported',
1129
- reason: 'these two picks do not belong to the same list: pick the same field on two similar items'
1130
- };
1131
- const item = bestCandidate(html, candidatesFor(result.itemPath, false));
1132
- if (item === undefined) return {
1133
- kind: 'unsupported',
1134
- reason: 'no selector matches every item of this list; try picking two items closer to each other in the markup'
1135
- };
1136
- const field = fieldPick(html, result.fieldPathA, result.fieldPath);
1137
- return {
1138
- kind: 'list',
1139
- item: {
1140
- selector: item.selector,
1141
- tier: item.tier,
1142
- matches: item.matches
1143
- },
1144
- field
1145
- };
1146
- } catch (error) {
1147
- if (error instanceof ShadowDomUnsupportedError) return {
1148
- kind: 'unsupported',
1149
- reason: error.message
2111
+ }
2112
+ function search(steps, boundId, prefix) {
2113
+ for (const [index, step] of steps.entries()) {
2114
+ const path = `${prefix}.${index}`;
2115
+ if (step.id === boundId || step.as === boundId) return {
2116
+ path,
2117
+ step
1150
2118
  };
1151
- throw error;
2119
+ if (step.steps !== undefined) {
2120
+ const nested = search(step.steps, boundId, `${path}.steps`);
2121
+ if (nested !== undefined) return nested;
2122
+ }
2123
+ if (step.else !== undefined) {
2124
+ const nested = search(step.else, boundId, `${path}.else`);
2125
+ if (nested !== undefined) return nested;
2126
+ }
1152
2127
  }
2128
+ return undefined;
1153
2129
  }
1154
- /** The best selector for one clicked node, `take` decided from the concrete clicked element; `rankSource` (a generalised path) is used for candidate generation when given, so a per-item-unique attribute of the one node picked cannot end up in the selector. */
1155
- function fieldPick(html, clicked, rankSource = clicked) {
1156
- const leaf = clicked.at(-1);
1157
- const take = leaf === undefined ? 'text' : takeKindFor(leaf);
1158
- const best = bestCandidate(html, candidatesFor(rankSource));
1159
- if (best === undefined) throw new Error('no selector matches the picked node — this should not happen, since the node came from this same document');
2130
+
2131
+ /**
2132
+ * Handles `explain-why` (issue #92's Why? tab): builds the sentence for a
2133
+ * missing or rejected value from the recipe's last sample run, kept in
2134
+ * `state.lastRuns` by `handleRunSample`.
2135
+ *
2136
+ * @param state - The server state: `folder` must already be set (`open-workspace` first).
2137
+ * @param command - The command.
2138
+ * @returns The explanation.
2139
+ * @throws Error when no workspace is open, no sample run has finished for the target's recipe, or the target does not name a real record/field.
2140
+ */
2141
+ async function handleExplainWhy(state, command) {
2142
+ if (state.folder === undefined) throw new Error('open a workspace first');
2143
+ return explainWhy(state.folder, state.lastRuns, loadRecipePair, command.target);
2144
+ }
2145
+
2146
+ /**
2147
+ * Handles `fetch-start-page`: loads the named input recipe from the current
2148
+ * workspace and fetches its start point (see `page-snapshot`'s own caveats:
2149
+ * no session bootstrap, a fresh browser or HTTP context per call).
2150
+ *
2151
+ * @param state - The server state: `folder` must already be set.
2152
+ * @param command - The command.
2153
+ * @returns The page's content as plain text.
2154
+ * @throws Error when no workspace is open, or the recipe cannot be found or loaded.
2155
+ */
2156
+ async function handleFetchStartPage(state, command) {
2157
+ if (state.folder === undefined) throw new Error('open a workspace first');
2158
+ const {
2159
+ input
2160
+ } = await loadRecipePair(state.folder, command.recipeId);
2161
+ const html = await fetchStartPage(input, state.browser);
1160
2162
  return {
1161
- selector: best.selector,
1162
- tier: best.tier,
1163
- take,
1164
- matches: best.matches
2163
+ html
1165
2164
  };
1166
2165
  }
1167
2166
 
1168
2167
  /**
1169
- * The outline model: an `InputRecipe`'s `steps` (and only `steps`; the rest
1170
- * of the recipe passes through untouched, see `recipe-to-outline.mapper.ts`)
1171
- * read as a tree of cards and brackets instead of raw JSON.
2168
+ * Handles `grid-preview`: the grid canvas's live preview of a `table`
2169
+ * extract's options (`document-view`'s `table-preview.use-case.ts`'s
2170
+ * `previewGridTable`, studio plan §3.4, issue #94's 5c), off the same
2171
+ * cached snapshot `take-snapshot`/`grid-view` already captured — a pure
2172
+ * computation over it, so this answers instantly on every option change, no
2173
+ * re-fetch or re-parse.
1172
2174
  *
1173
- * A **card** is one step that reads as a sentence (`sentence`, built by
1174
- * `card-sentence.mapper.ts`). A **bracket** is a container step (`forEach`,
1175
- * `paginate`, `if`) whose body is itself a list of cards/brackets: the
1176
- * indentation the plan doc describes is this nesting, not a separate layout
1177
- * concept. Both kinds carry `step`, the step's own raw JSON exactly as
1178
- * parsed (every key, in its original order): `outline-to-recipe.mapper.ts`
1179
- * rebuilds the recipe from it, substituting only the (possibly edited)
1180
- * `children`/`elseChildren` for whatever `steps`/`else` value `step` itself
1181
- * holds, so an edit to one field never disturbs another's position or a
1182
- * step type this outline cannot make sense of.
2175
+ * @param state - The server state.
2176
+ * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
2177
+ * @returns The matched tables, or `error` when an option's pattern does not compile.
2178
+ * @throws Error when no workspace is open, or `command.path` has no cached snapshot yet.
1183
2179
  */
1184
- /** The step types the outline renders as a bracket: their body is nested steps, not a single value. */
1185
- const BRACKET_STEP_TYPES = ['forEach', 'paginate', 'if'];
1186
- function isBracket(node) {
1187
- return node.kind === 'bracket';
2180
+ function handleGridPreview(state, command) {
2181
+ const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
2182
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
2183
+ if (!isWorkbookDocument(snapshot.body)) throw new Error(`"${command.recipeId}" at "${command.path}" is not a workbook snapshot: grid-preview only reads a read CSV/spreadsheet document`);
2184
+ return previewGridTable(snapshot.body, command.options);
1188
2185
  }
1189
- function isBracketStepType(type) {
1190
- return BRACKET_STEP_TYPES.includes(type);
2186
+
2187
+ /**
2188
+ * Handles `grid-view`: the grid canvas's sheets and cells (`document-view`'s
2189
+ * `workbook-view.mapper.ts`, studio plan §3.4, issue #94's 5c), off the same
2190
+ * cached snapshot `take-snapshot`/`document-tree`/`pdf-view` already use.
2191
+ *
2192
+ * `delimiter`/`encoding` re-read a CSV with the override applied
2193
+ * (`page-snapshot`'s `rereadCsv`) and replace the cached snapshot's body with
2194
+ * the fresh reading, so a following `grid-preview` (and another `grid-view`
2195
+ * with no override) sees the same one — the override sticks until
2196
+ * `take-snapshot` recaptures.
2197
+ *
2198
+ * @param state - The server state.
2199
+ * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
2200
+ * @returns The document's view.
2201
+ * @throws Error when no workspace is open, `command.path` has no cached snapshot yet, an override is given for a snapshot that is not a CSV, or the snapshot's document is not a read workbook.
2202
+ */
2203
+ async function handleGridView(state, command) {
2204
+ const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
2205
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
2206
+ const overriding = command.delimiter !== undefined || command.encoding !== undefined;
2207
+ if (!overriding) {
2208
+ if (!isWorkbookDocument(snapshot.body)) throw new Error(`"${command.recipeId}" at "${command.path}" is not a workbook snapshot: the grid canvas only reads a read CSV/spreadsheet document`);
2209
+ return workbookDocumentView(snapshot.body);
2210
+ }
2211
+ if (snapshot.format !== 'csv') throw new Error(`"${command.recipeId}" at "${command.path}" is not a CSV snapshot: "delimiter"/"encoding" only override a CSV reading`);
2212
+ const body = await rereadCsv(snapshot.baseUrl, {
2213
+ delimiter: command.delimiter,
2214
+ encoding: command.encoding
2215
+ });
2216
+ putSnapshot(state.snapshots, command.recipeId, command.path, {
2217
+ ...snapshot,
2218
+ body
2219
+ });
2220
+ return workbookDocumentView(body);
1191
2221
  }
1192
2222
 
1193
- /** Step types the plan doc names as always going to a "custom" card: nothing here renders their meaning as a sentence. */
1194
- const ALWAYS_CUSTOM = new Set(['evaluate', 'hook', 'captcha']);
2223
+ const GENERATED_ID = /^(?:react-|radix-|:r|ember|css-|mui-|headlessui-)/i;
1195
2224
  /**
1196
- * Turns one step's raw JSON into its sentence parts (word/pill/code), the
1197
- * way the plan doc's examples read: *Go to…*, *Read… from…*, *For each… in…
1198
- * → one record*, and so on. Never throws: a step type this cannot read
1199
- * becomes a short, honest fallback with `custom: true`, so the outline never
1200
- * drops a step for looking unfamiliar.
2225
+ * The path from the document root to `node`, one level per ancestor. Two
2226
+ * elements that share an ancestor produce identical levels up to that
2227
+ * ancestor and diverge exactly where their parents stop being the same node
2228
+ * — `list-inference.algorithm.ts` leans on that to find the nearest pair of
2229
+ * siblings two picks belong to, without ever touching cheerio again.
1201
2230
  *
1202
- * @param step - A step's raw JSON (its `type` and whatever fields it has).
1203
- * @returns The sentence parts, and whether they are a real reading or a fallback.
2231
+ * @param $ - The parsed document.
2232
+ * @param node - The element to describe.
2233
+ * @returns The path, root first.
1204
2234
  */
1205
- function cardSentence(step) {
1206
- const type = typeof step.type === 'string' ? step.type : undefined;
1207
- if (type === undefined || ALWAYS_CUSTOM.has(type)) return fallback(type);
1208
- const build = BUILDERS[type];
1209
- if (build === undefined) return fallback(type);
1210
- try {
1211
- return {
1212
- parts: build(step),
1213
- custom: false
1214
- };
1215
- } catch {
1216
- return fallback(type);
2235
+ function domPathOf($, node) {
2236
+ const chain = [];
2237
+ let current = node;
2238
+ while (current !== undefined && current.length > 0 && isElement(current.get(0))) {
2239
+ chain.unshift(current);
2240
+ const parent = current.parent();
2241
+ current = parent.length > 0 ? parent : undefined;
1217
2242
  }
2243
+ return chain.map(element => levelOf($, element));
1218
2244
  }
1219
- function fallback(type) {
2245
+ function levelOf($, element) {
2246
+ const node = element.get(0);
2247
+ const tag = node.tagName.toLowerCase();
2248
+ const attrs = {};
2249
+ const entries = Object.entries(node.attribs ?? {});
2250
+ for (const [name, value] of entries) attrs[name] = value;
2251
+ const classes = (attrs.class ?? '').split(/\s+/).filter(token => token.length > 0);
2252
+ const siblings = element.parent().length > 0 ? element.parent().children(tag) : $.root().children(tag);
2253
+ const index = siblings.toArray().indexOf(node) + 1;
1220
2254
  return {
1221
- parts: [word(type ?? 'custom step')],
1222
- custom: true
2255
+ tag,
2256
+ id: attrs.id,
2257
+ classes,
2258
+ attrs,
2259
+ index: Math.max(index, 1),
2260
+ siblingCount: Math.max(siblings.length, 1)
1223
2261
  };
1224
2262
  }
1225
- function word(text) {
1226
- return {
1227
- kind: 'word',
1228
- text
1229
- };
2263
+ function isElement(node) {
2264
+ return node !== undefined && node.type === 'tag';
1230
2265
  }
1231
- function pill(text) {
1232
- return {
1233
- kind: 'pill',
1234
- text
1235
- };
2266
+ /**
2267
+ * Whether `id` looks handed by a framework (`react-`, a CSS-module or a
2268
+ * `radix-`/`mui-` autogenerated one) rather than by the site's own markup —
2269
+ * `class-token.policy.ts` has the equivalent check for class tokens, kept
2270
+ * separate because ids and classes are ranked at different tiers.
2271
+ */
2272
+ function isStableId(id) {
2273
+ return id.length > 0 && !GENERATED_ID.test(id);
1236
2274
  }
1237
- function code(text) {
1238
- return {
1239
- kind: 'code',
1240
- text
1241
- };
2275
+ /**
2276
+ * Whether `path` failed to reach the real document root. `domPathOf` walks
2277
+ * up through cheerio's own `.parent()`, which (matching real DOM semantics)
2278
+ * never crosses from a `<template>`'s content into the light DOM outside
2279
+ * it — a `<template shadowrootmode>` is exactly declarative shadow DOM, the
2280
+ * only shadow-DOM shape that can show up in static, server-captured markup
2281
+ * at all (imperative `attachShadow()` content never serialises into
2282
+ * `page.content()`/a fetched response in the first place, so that case
2283
+ * cannot reach this slice and needs no separate detection). A path whose
2284
+ * first level is not `html` was cut short at exactly this kind of boundary:
2285
+ * v1 does not pierce it, so callers report a clear "shadow DOM is not
2286
+ * supported" error instead of building ancestor-based candidates over a
2287
+ * chain that stops partway up.
2288
+ */
2289
+ function crossesShadowRoot(path) {
2290
+ return path.length === 0 || path[0].tag !== 'html';
1242
2291
  }
1243
- /** A field that names an id: shown as a pill, `fallbackText` when it is absent. */
2292
+ /** Parses `html` and returns cheerio's API, for callers that need more than one path from the same document. */
2293
+ function parseDocument$1(html) {
2294
+ return load(html);
2295
+ }
2296
+ /**
2297
+ * The path to the element the snapshot stamped with `nodeId` (its
2298
+ * `data-oc-node` attribute — `@opencraw/core`'s `NODE_ID_ATTRIBUTE`,
2299
+ * `packages/studio/src/page-snapshot/rewrite-document.mapper.ts`). This is
2300
+ * how a click in the content pane's iframe — which only ever needs to read
2301
+ * that one attribute off the element the user clicked — turns into
2302
+ * something `selector-inference`'s other algorithms can work with, without
2303
+ * the browser-side picker script needing to know anything about ancestry,
2304
+ * classes or candidate ranking itself.
2305
+ *
2306
+ * @param html - The snapshot document (already stamped with node ids).
2307
+ * @param nodeId - The `data-oc-node` value to find.
2308
+ * @returns The node's path, or `undefined` when no element carries that id.
2309
+ */
2310
+ function pathToNode(html, nodeId) {
2311
+ const $ = parseDocument$1(html);
2312
+ const found = $(`[${NODE_ID_ATTRIBUTE}="${cssAttributeValue(nodeId)}"]`);
2313
+ return found.length === 0 ? undefined : domPathOf($, found.first());
2314
+ }
2315
+ function cssAttributeValue(value) {
2316
+ return value.replaceAll('\\', String.raw`\\`).replaceAll('"', String.raw`\"`);
2317
+ }
2318
+
2319
+ /** Attributes worth reading instead of text, in preference order, when the clicked element has one. */
2320
+ const PREFERRED_ATTRS = {
2321
+ a: ['href'],
2322
+ img: ['src', 'alt']
2323
+ };
2324
+ /**
2325
+ * What a `Read` card's `take` should be for the element a pick landed on
2326
+ * (issue #91, studio plan §3.2): `attr:href` for an `<a>`, `attr:src` for an
2327
+ * `<img>`, `attr:<name>` for the first `data-*` attribute otherwise, `json`
2328
+ * for a `<script type="application/json">`, else the default, `text`.
2329
+ *
2330
+ * @param level - The clicked element's own level (the last entry of its `DomPath`).
2331
+ * @returns The `take` value to put on the `extract` step.
2332
+ */
2333
+ function takeKindFor(level) {
2334
+ if (level.tag === 'script' && (level.attrs.type ?? '').toLowerCase() === 'application/json') return 'json';
2335
+ const preferred = PREFERRED_ATTRS[level.tag];
2336
+ if (preferred !== undefined) {
2337
+ const attribute = preferred.find(name => level.attrs[name] !== undefined);
2338
+ if (attribute !== undefined) return `attr:${attribute}`;
2339
+ }
2340
+ const dataAttribute = Object.keys(level.attrs).find(name => name.startsWith('data-') && name !== 'data-oc-node' && name !== 'data-oc-hidden');
2341
+ if (dataAttribute !== undefined) return `attr:${dataAttribute}`;
2342
+ return 'text';
2343
+ }
2344
+
2345
+ /**
2346
+ * Which class tokens are worth building a selector on. A hand-authored class
2347
+ * (`product_pod`, `price_color`) survives a rebuild; a generated one
2348
+ * (a content hash, a CSS-module suffix, a styled-components token) does not,
2349
+ * so a candidate built on it looks fine on the snapshot it was picked from
2350
+ * and breaks on the very next deploy. This is the "generated/hashed class
2351
+ * names" tradeoff issue #91 calls out explicitly: the heuristics below are a
2352
+ * reasoned judgement call, not a guarantee — see the doc comments on each
2353
+ * pattern for what real-world tool it targets.
2354
+ */
2355
+ const GENERATED_TOKEN_PATTERNS = [/** styled-components / emotion: `sc-bdVaJa`, `css-1x2y3z4` */
2356
+ /^(?:sc|css)-[a-z0-9]+$/i, /** CSS Modules, most bundlers' default hash suffix: `wrapper__3fA9k`, `Button-module_root__aB3dK` */
2357
+ /__[a-z0-9]{4,10}$/i, /** JSS / MUI: `jss123`, `MuiButton-root-145` */
2358
+ /^(?:jss|mui[a-z]*-)[a-z0-9-]*\d{2}$/i, /** A token that is only digits: not a class a human would type. */
2359
+ /^\d+$/, /** A bare hex hash: `a3f9c21`, `1a2b3c4d` (6+ hex chars, nothing else). */
2360
+ /^[0-9a-f]{6,}$/i, /** Tailwind/atomic-CSS-in-JS runtime classes some bundlers emit: `_1a2b3c` */
2361
+ /^_[0-9a-z]{5,}$/i];
2362
+ /**
2363
+ * Whether a class token looks hand-authored and safe to build a selector on.
2364
+ *
2365
+ * @param token - One class, no surrounding whitespace.
2366
+ * @returns `false` for anything matching a known generated-class shape.
2367
+ */
2368
+ function isStableClassToken(token) {
2369
+ return token.length > 0 && GENERATED_TOKEN_PATTERNS.every(pattern => !pattern.test(token));
2370
+ }
2371
+ /**
2372
+ * The subset of `tokens` worth ranking a selector candidate on, order kept.
2373
+ *
2374
+ * @param tokens - An element's class list.
2375
+ * @returns Only the stable tokens.
2376
+ */
2377
+ function stableClasses(tokens) {
2378
+ return tokens.filter(token => isStableClassToken(token));
2379
+ }
2380
+ /**
2381
+ * Whether two class lists describe "the same kind of element" for list
2382
+ * inference: same stable classes, generated tokens (which often differ
2383
+ * per-instance, e.g. a React `key`-derived class) ignored on both sides.
2384
+ *
2385
+ * @param a - One element's classes.
2386
+ * @param b - Another element's classes.
2387
+ * @returns Whether their stable class sets are equal, order ignored.
2388
+ */
2389
+ function sameClassSignature(a, b) {
2390
+ const left = new Set(stableClasses(a));
2391
+ const right = new Set(stableClasses(b));
2392
+ if (left.size !== right.size) return false;
2393
+ return [...left].every(token => right.has(token));
2394
+ }
2395
+
2396
+ /**
2397
+ * Thrown when a pick or a list-inference walk crosses a declarative shadow
2398
+ * root (`<template shadowrootmode>`). v1 does not pierce shadow DOM: rather
2399
+ * than infer a selector that silently matches the wrong thing (or nothing,
2400
+ * once the page hydrates), the studio surfaces this and stops.
2401
+ */
2402
+ class ShadowDomUnsupportedError extends Error {
2403
+ path;
2404
+ constructor(path) {
2405
+ super(`"${path}" is inside a shadow root: shadow DOM is not supported for picking (v1)`);
2406
+ this.path = path;
2407
+ this.name = 'ShadowDomUnsupportedError';
2408
+ }
2409
+ }
2410
+
2411
+ /** Attributes ranked just under `id`: named explicitly, not "any `data-*`", because a generated `data-reactid` is no more stable than a generated class. */
2412
+ const STABLE_ATTRS = ['data-testid', 'data-id', 'itemprop'];
2413
+ const TIER_ORDER$1 = {
2414
+ 'id': 0,
2415
+ 'data-attr': 1,
2416
+ 'class': 2,
2417
+ 'structure': 3,
2418
+ 'position': 4
2419
+ };
2420
+ /**
2421
+ * Every selector candidate worth trying for the last node of `path`, ranked
2422
+ * best-first: `id` > stable `data-*`/`itemprop` attributes > stable class
2423
+ * tokens > ancestor structure (`parent > tag`) > position (`nth-of-type`) as
2424
+ * a last resort (issue #91's ranking, studio plan §3.2). Structure and
2425
+ * position candidates only look as far up as `path` itself goes — pass a
2426
+ * sub-path (e.g. from an item's root down) to keep a field's selector
2427
+ * relative to its item instead of absolute to the document.
2428
+ *
2429
+ * @param path - The node's ancestry, root (or scope root) first, itself last.
2430
+ * @param includePosition - `false` for an item selector: a position candidate would only ever match one of the items, which is never what an item selector should do.
2431
+ * @returns Candidates, best tier first; ties keep this function's own order.
2432
+ */
2433
+ function candidatesFor(path, includePosition = true) {
2434
+ const node = path.at(-1);
2435
+ if (node === undefined) return [];
2436
+ const candidates = [];
2437
+ if (node.id !== undefined && isStableId(node.id)) candidates.push({
2438
+ selector: `#${cssEscape(node.id)}`,
2439
+ tier: 'id'
2440
+ });
2441
+ for (const name of STABLE_ATTRS) {
2442
+ const value = node.attrs[name];
2443
+ if (value !== undefined && value.length > 0) candidates.push({
2444
+ selector: `${node.tag}[${name}="${cssValue(value)}"]`,
2445
+ tier: 'data-attr'
2446
+ });
2447
+ }
2448
+ const classes = stableClasses(node.classes);
2449
+ if (classes.length > 0) candidates.push({
2450
+ selector: `${node.tag}.${classes.map(token => cssEscape(token)).join('.')}`,
2451
+ tier: 'class'
2452
+ });
2453
+ candidates.push(...structureCandidates(path));
2454
+ if (includePosition) candidates.push(...positionCandidates(path));
2455
+ return sortByTier(candidates);
2456
+ }
2457
+ /** `ancestor > ... > tag`, growing from 2 to 4 levels (or to the top of `path`), each ancestor named by its own best simple form. */
2458
+ function structureCandidates(path) {
2459
+ const node = path.at(-1);
2460
+ if (node === undefined) return [];
2461
+ const candidates = [];
2462
+ const maxDepth = Math.min(path.length - 1, 3);
2463
+ for (let depth = 1; depth <= maxDepth; depth++) {
2464
+ const ancestor = path.at(-1 - depth);
2465
+ if (ancestor === undefined) break;
2466
+ const between = Array.from({
2467
+ length: depth - 1
2468
+ }, (_, index) => path.at(-depth + index)?.tag ?? '');
2469
+ const parts = [simpleSelector(ancestor), ...between, node.tag];
2470
+ candidates.push({
2471
+ selector: parts.join(' > '),
2472
+ tier: 'structure'
2473
+ });
2474
+ }
2475
+ return candidates;
2476
+ }
2477
+ /** `parent > tag:nth-of-type(n)`, the position-only fallback; never `nth-child`, which counts every sibling tag, not just this element's own. */
2478
+ function positionCandidates(path) {
2479
+ const node = path.at(-1);
2480
+ if (node === undefined) return [];
2481
+ const own = node.siblingCount > 1 ? `${node.tag}:nth-of-type(${node.index})` : node.tag;
2482
+ const parent = path.at(-2);
2483
+ if (parent === undefined) return [{
2484
+ selector: own,
2485
+ tier: 'position'
2486
+ }];
2487
+ return [{
2488
+ selector: `${simpleSelector(parent)} > ${own}`,
2489
+ tier: 'position'
2490
+ }];
2491
+ }
2492
+ /** A level's own selector fragment, no combinators: id, else stable classes, else the bare tag. */
2493
+ function simpleSelector(level) {
2494
+ if (level.id !== undefined && isStableId(level.id)) return `#${cssEscape(level.id)}`;
2495
+ const classes = stableClasses(level.classes);
2496
+ return classes.length > 0 ? `${level.tag}.${classes.map(token => cssEscape(token)).join('.')}` : level.tag;
2497
+ }
2498
+ function sortByTier(candidates) {
2499
+ return [...candidates].sort((a, b) => TIER_ORDER$1[a.tier] - TIER_ORDER$1[b.tier]);
2500
+ }
2501
+ /** Escapes a CSS identifier segment (an id or a class token) well enough for the shapes real sites use; not a full CSS.escape polyfill. */
2502
+ function cssEscape(token) {
2503
+ return token.replaceAll(/([^\w-])/gi, String.raw`\$1`);
2504
+ }
2505
+ /** Escapes a value inside `[name="…"]`. */
2506
+ function cssValue(value) {
2507
+ return value.replaceAll(/(["\\])/g, String.raw`\$1`);
2508
+ }
2509
+
2510
+ const TIER_ORDER = {
2511
+ 'id': 0,
2512
+ 'data-attr': 1,
2513
+ 'class': 2,
2514
+ 'structure': 3,
2515
+ 'position': 4
2516
+ };
2517
+ /**
2518
+ * Verifies and orders selector candidates against real markup, by running
2519
+ * each one through the engine's own CSS matching (`@opencraw/core`'s
2520
+ * `countMatches`/`matchesOf`) rather than reimplementing it — issue #91's
2521
+ * "every candidate is verified" requirement.
2522
+ *
2523
+ * Ordering: candidates that do not match `targetNodeId` (when given) sort
2524
+ * last; among the rest, best tier first, and within a tier the fewest
2525
+ * matches first (the most specific selector that still hits the target).
2526
+ * A candidate matching nothing is dropped entirely: it is not a real option.
2527
+ *
2528
+ * @param html - The document to verify against (the snapshot, or the live page for the second, cross-check pass).
2529
+ * @param candidates - Unranked candidates, e.g. from `candidatesFor`.
2530
+ * @param targetNodeId - The `data-oc-node` id the pick landed on, when the document carries those stamps (the snapshot does; a live page fetched separately does not).
2531
+ * @returns Every candidate that matched at least one element, ranked best first.
2532
+ */
2533
+ function rankCandidates(html, candidates, targetNodeId) {
2534
+ const ranked = candidates.map(candidate => {
2535
+ const matches = countMatches(html, candidate.selector);
2536
+ const hitsTarget = targetNodeId === undefined || matches === 0 ? targetNodeId === undefined : matchesOf(html, candidate.selector).includes(targetNodeId);
2537
+ return {
2538
+ ...candidate,
2539
+ matches,
2540
+ hitsTarget
2541
+ };
2542
+ });
2543
+ return ranked.filter(candidate => candidate.matches > 0).sort((a, b) => Number(b.hitsTarget) - Number(a.hitsTarget) || TIER_ORDER[a.tier] - TIER_ORDER[b.tier] || a.matches - b.matches);
2544
+ }
2545
+ /** The single best candidate, or `undefined` when none of them matched anything. */
2546
+ function bestCandidate(html, candidates, targetNodeId) {
2547
+ return rankCandidates(html, candidates, targetNodeId)[0];
2548
+ }
2549
+
2550
+ /**
2551
+ * Infers "these two picks are the same field on two items of a list" from
2552
+ * two node paths, by walking up from both to the nearest ancestors that are
2553
+ * real siblings (the same parent) with the same tag and stable-class
2554
+ * signature — studio plan §3.2, issue #91. That ancestor is the item; the
2555
+ * remainder of each path is the field, relative to it.
2556
+ *
2557
+ * Because a `DomPath` is built root-down (`dom-path.model.ts`), two paths
2558
+ * from the same document are byte-for-byte identical up to their real
2559
+ * common ancestor and diverge exactly at its differing children — so the
2560
+ * *first* level where they differ is always the nearest-sibling test point;
2561
+ * there is no need to look further down. This also makes the "wrapper trap"
2562
+ * (an extra single-child element around each item) resolve itself: a
2563
+ * wrapper's own instances are never one another's siblings, only the
2564
+ * genuine repeating element is, so the walk naturally lands there instead of
2565
+ * on the wrapper (see the wrapper-trap fixture in this slice's tests).
2566
+ *
2567
+ * @param pathA - The first click's path.
2568
+ * @param pathB - The second click's path.
2569
+ * @returns The item and field shapes (generalised) plus each pick's raw path, or `null` when the two picks are not on a coherent list (different branches entirely, the same node twice, or one path nested inside the other).
2570
+ * @throws ShadowDomUnsupportedError when either path crosses a declarative shadow root.
2571
+ */
2572
+ function inferList(pathA, pathB) {
2573
+ if (crossesShadowRoot(pathA)) throw new ShadowDomUnsupportedError(pathToString(pathA));
2574
+ if (crossesShadowRoot(pathB)) throw new ShadowDomUnsupportedError(pathToString(pathB));
2575
+ let divergedAt = -1;
2576
+ const limit = Math.min(pathA.length, pathB.length);
2577
+ for (let index = 0; index < limit; index++) {
2578
+ if (!sameLevel(pathA[index], pathB[index])) {
2579
+ divergedAt = index;
2580
+ break;
2581
+ }
2582
+ }
2583
+ // Identical paths, or one a strict prefix of the other (the same node picked twice, or one element nested inside the other): not a list.
2584
+ if (divergedAt <= 0) return null;
2585
+ const branchA = pathA[divergedAt];
2586
+ const branchB = pathB[divergedAt];
2587
+ if (branchA.tag !== branchB.tag || !sameClassSignature(branchA.classes, branchB.classes)) return null;
2588
+ if (branchA.index === branchB.index) return null; // Same position: not two different items.
2589
+ const ancestors = pathA.slice(0, divergedAt);
2590
+ const fieldPathA = pathA.slice(divergedAt);
2591
+ const fieldPathB = pathB.slice(divergedAt);
2592
+ const fieldPath = mergeRelativePaths(fieldPathA, fieldPathB);
2593
+ return {
2594
+ itemPath: [...ancestors, fieldPath[0]],
2595
+ fieldPath,
2596
+ fieldPathA,
2597
+ fieldPathB
2598
+ };
2599
+ }
2600
+ function sameLevel(a, b) {
2601
+ return a.tag === b.tag && a.id === b.id && a.index === b.index && a.siblingCount === b.siblingCount && sameClassSignature(a.classes, b.classes);
2602
+ }
2603
+ /**
2604
+ * Zips two item-relative paths (item first) level by level into one that
2605
+ * only keeps what both share: the tag (must already match, checked by the
2606
+ * caller for level 0), the intersection of stable classes, and attributes
2607
+ * present with the same value on both sides. Shorter than the other when
2608
+ * the two picks' local structure differs in depth (a real possibility once
2609
+ * a field is missing on one item; `list-inference.algorithm.test.ts`'s
2610
+ * shifted-field fixture is exactly this) — the merge just stops there,
2611
+ * which is fine: a field selector only needs to reach as deep as the
2612
+ * shallower of the two picks.
2613
+ */
2614
+ function mergeRelativePaths(a, b) {
2615
+ const length = Math.min(a.length, b.length);
2616
+ const merged = [];
2617
+ for (let index = 0; index < length; index++) {
2618
+ const left = a[index];
2619
+ const right = b[index];
2620
+ if (left.tag !== right.tag) break;
2621
+ merged.push(mergeLevel(left, right));
2622
+ }
2623
+ return merged.length > 0 ? merged : [a[0]];
2624
+ }
2625
+ function mergeLevel(a, b) {
2626
+ const classes = stableClasses(a.classes).filter(token => b.classes.includes(token));
2627
+ const attrs = {};
2628
+ for (const [name, value] of Object.entries(a.attrs)) {
2629
+ if (name !== 'class' && name !== 'id' && b.attrs[name] === value) attrs[name] = value;
2630
+ }
2631
+ return {
2632
+ tag: a.tag,
2633
+ id: a.id !== undefined && a.id === b.id ? a.id : undefined,
2634
+ classes,
2635
+ attrs,
2636
+ index: 1,
2637
+ siblingCount: Math.max(a.siblingCount, b.siblingCount, 1)
2638
+ };
2639
+ }
2640
+ function pathToString(path) {
2641
+ return path.map(level => level.tag + (level.id === undefined ? '' : `#${level.id}`)).join(' > ');
2642
+ }
2643
+
2644
+ /**
2645
+ * Handles `infer-selector`: turns one or two picked nodes (their
2646
+ * `data-oc-node` ids, off the cached snapshot) into a verified selector —
2647
+ * one id for a `Read` card's field, two for the safe item+field list shape
2648
+ * `selector-inference`'s `inferList` builds by construction (issue #91,
2649
+ * studio plan §3.2). All the actual candidate/ranking/list-inference work
2650
+ * is `selector-inference`'s (pure); this handler only locates the clicked
2651
+ * nodes in the cached document and calls into it.
2652
+ *
2653
+ * @param state - The server state.
2654
+ * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
2655
+ * @returns A field pick, a list pick, or `"unsupported"` with why.
2656
+ * @throws Error when no workspace is open, or `command.path` has no cached snapshot yet.
2657
+ */
2658
+ function handleInferSelector(state, command) {
2659
+ const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
2660
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
2661
+ const html = snapshot.html;
2662
+ const paths = command.nodeIds.map(nodeId => pathToNode(html, nodeId));
2663
+ if (paths.includes(undefined)) return {
2664
+ kind: 'unsupported',
2665
+ reason: 'one of the picked nodes is no longer in the snapshot; take a fresh snapshot and try again'
2666
+ };
2667
+ const [pathA, pathB] = paths;
2668
+ if (pathB === undefined) return {
2669
+ kind: 'field',
2670
+ field: fieldPick(html, pathA)
2671
+ };
2672
+ try {
2673
+ const result = inferList(pathA, pathB);
2674
+ if (result === null) return {
2675
+ kind: 'unsupported',
2676
+ reason: 'these two picks do not belong to the same list: pick the same field on two similar items'
2677
+ };
2678
+ const item = bestCandidate(html, candidatesFor(result.itemPath, false));
2679
+ if (item === undefined) return {
2680
+ kind: 'unsupported',
2681
+ reason: 'no selector matches every item of this list; try picking two items closer to each other in the markup'
2682
+ };
2683
+ const field = fieldPick(html, result.fieldPathA, result.fieldPath);
2684
+ return {
2685
+ kind: 'list',
2686
+ item: {
2687
+ selector: item.selector,
2688
+ tier: item.tier,
2689
+ matches: item.matches
2690
+ },
2691
+ field
2692
+ };
2693
+ } catch (error) {
2694
+ if (error instanceof ShadowDomUnsupportedError) return {
2695
+ kind: 'unsupported',
2696
+ reason: error.message
2697
+ };
2698
+ throw error;
2699
+ }
2700
+ }
2701
+ /** The best selector for one clicked node, `take` decided from the concrete clicked element; `rankSource` (a generalised path) is used for candidate generation when given, so a per-item-unique attribute of the one node picked cannot end up in the selector. */
2702
+ function fieldPick(html, clicked, rankSource = clicked) {
2703
+ const leaf = clicked.at(-1);
2704
+ const take = leaf === undefined ? 'text' : takeKindFor(leaf);
2705
+ const best = bestCandidate(html, candidatesFor(rankSource));
2706
+ if (best === undefined) throw new Error('no selector matches the picked node — this should not happen, since the node came from this same document');
2707
+ return {
2708
+ selector: best.selector,
2709
+ tier: best.tier,
2710
+ take,
2711
+ matches: best.matches
2712
+ };
2713
+ }
2714
+
2715
+ /** A leaf element's own text longer than this is cut, with an ellipsis. */
2716
+ const TEXT_LIMIT = 80;
2717
+ /** Consecutive siblings whose subtree is identical (ids aside) collapse into one row once there are at least this many — a real page can have tens of thousands of `<li>`s, and showing all of them is not "inspecting" it (issue #93). */
2718
+ const MIN_REPEAT_RUN = 3;
2719
+ /**
2720
+ * Builds the Inspect panel's DOM tree from a captured snapshot (studio plan
2721
+ * §3.3, issue #93): the same rewritten, marked-up document the content pane's
2722
+ * iframe shows (`page-snapshot`'s `take-snapshot.use-case.ts`), so a tree
2723
+ * row's `nodeId` addresses exactly the element the iframe would highlight —
2724
+ * no separate capture, per issue #93's scope note to reuse phase 2's
2725
+ * snapshot rather than recapture.
2726
+ *
2727
+ * @param snapshotHtml - The cached, rewritten snapshot (`SnapshotResult.html`).
2728
+ * @returns The tree's root (the document's `<html>` element).
2729
+ * @throws Error when the document has no `<html>` element (should not happen: `rewriteDocument` always produces one).
2730
+ */
2731
+ function domTree(snapshotHtml) {
2732
+ const $ = load(snapshotHtml);
2733
+ const html = $('html').get(0);
2734
+ if (html === undefined) throw new Error('domTree: the snapshot has no <html> element');
2735
+ return buildNode($, html);
2736
+ }
2737
+ function buildNode($, element) {
2738
+ const $el = $(element);
2739
+ const attributes = {
2740
+ ...element.attribs
2741
+ };
2742
+ const nodeId = attributes[NODE_ID_ATTRIBUTE] ?? '';
2743
+ const hidden = attributes[HIDDEN_ATTRIBUTE] === '1';
2744
+ delete attributes[NODE_ID_ATTRIBUTE];
2745
+ delete attributes[HIDDEN_ATTRIBUTE];
2746
+ const id = attributes.id;
2747
+ const classes = (attributes.class ?? '').split(/\s+/).filter(token => token !== '');
2748
+ const childElements = $el.children().toArray();
2749
+ const children = collapseRepeats(childElements.map(child => buildNode($, child)));
2750
+ const text = childElements.length === 0 ? abbreviate($el.text()) : undefined;
2751
+ return {
2752
+ nodeId,
2753
+ tag: element.tagName.toLowerCase(),
2754
+ ...(id !== undefined && id !== '' && {
2755
+ id
2756
+ }),
2757
+ ...(classes.length > 0 && {
2758
+ classes
2759
+ }),
2760
+ attributes,
2761
+ ...(text !== undefined && text !== '' && {
2762
+ text
2763
+ }),
2764
+ hidden,
2765
+ children
2766
+ };
2767
+ }
2768
+ /**
2769
+ * Merges runs of `MIN_REPEAT_RUN` or more consecutive siblings whose subtree
2770
+ * is identical (their own `nodeId`s aside) into one representative row, the
2771
+ * issue's `×20` — real pages have tens of thousands of nodes, and this is
2772
+ * what makes the tree usable rather than merely correct.
2773
+ *
2774
+ * @param nodes - Already-built sibling rows, in document order.
2775
+ * @returns The same rows, with each long-enough identical run replaced by one.
2776
+ */
2777
+ function collapseRepeats(nodes) {
2778
+ const result = [];
2779
+ let at = 0;
2780
+ while (at < nodes.length) {
2781
+ const signature = signatureOf(nodes[at]);
2782
+ let end = at + 1;
2783
+ while (end < nodes.length && signatureOf(nodes[end]) === signature) end += 1;
2784
+ const run = nodes.slice(at, end);
2785
+ if (run.length >= MIN_REPEAT_RUN) {
2786
+ result.push({
2787
+ ...run[0],
2788
+ repeatCount: run.length,
2789
+ repeatNodeIds: run.map(node => node.nodeId)
2790
+ });
2791
+ } else {
2792
+ result.push(...run);
2793
+ }
2794
+ at = end;
2795
+ }
2796
+ return result;
2797
+ }
2798
+ /** A run member's identity for collapsing: everything but its own `nodeId` (every instance has a different one by construction). */
2799
+ function signatureOf(node) {
2800
+ return JSON.stringify({
2801
+ ...node,
2802
+ nodeId: undefined,
2803
+ children: node.children.map(child => signatureOf(child))
2804
+ });
2805
+ }
2806
+ function abbreviate(text) {
2807
+ const trimmed = text.trim().replaceAll(/\s+/g, ' ');
2808
+ return trimmed.length > TEXT_LIMIT ? `${trimmed.slice(0, TEXT_LIMIT)}…` : trimmed;
2809
+ }
2810
+
2811
+ /** The global names sites hydrate state into, that `describeHtml`'s own JSON-LD/table scan does not cover (issue #93's "inline state assignments"). */
2812
+ const INLINE_STATE_NAMES = ['__NEXT_DATA__', '__NUXT__', '__INITIAL_STATE__', '__APOLLO_STATE__', '__PRELOADED_STATE__', '__STATE__'];
2813
+ const INLINE_STATE_PATTERN = new RegExp(String.raw`(?:window\.)?(${INLINE_STATE_NAMES.join('|')})\s*=`);
2814
+ /**
2815
+ * Finds the data-in-the-page findings the Inspect panel lists apart from the
2816
+ * DOM tree (studio plan §3.3, issue #93): JSON-LD and `application/json`
2817
+ * script blocks, inline state assignments, `<meta>` name/property values and
2818
+ * `<link rel>`s — reusing `@opencraw/probe`'s `describeJson` for a found
2819
+ * value's shape (its own `find-data.algorithm.ts`/`html-findings.mapper.ts`
2820
+ * scan a page's raw text for *reporting*, with no element to point a pick at;
2821
+ * this function's own job — locating each finding's element so a pick can
2822
+ * reach it — is new).
2823
+ *
2824
+ * Takes the **raw** captured document, not the display snapshot
2825
+ * (`page-snapshot`'s `SnapshotResult.html`): `rewriteDocument` strips every
2826
+ * `<script>` for the sandboxed iframe, which would make JSON-LD, inline
2827
+ * state and other script-tag data invisible here. `<meta>`/`<link>` survive
2828
+ * rewriting untouched, so using the raw document for every kind (rather than
2829
+ * mixing two documents) keeps this function simple and its selectors
2830
+ * verified against what the engine will actually crawl.
2831
+ *
2832
+ * @param rawHtml - The document as fetched/rendered, before `rewriteDocument` strips scripts.
2833
+ * @returns The findings, in document order.
2834
+ */
2835
+ function pageData(rawHtml) {
2836
+ const $ = load(rawHtml);
2837
+ const findings = [];
2838
+ $('script[type="application/ld+json"]').each((_index, element) => {
2839
+ findings.push(...scriptFinding($, element, rawHtml, 'ld-json', ldJsonLabel));
2840
+ });
2841
+ $('script[type="application/json"]').each((_index, element) => {
2842
+ findings.push(...scriptFinding($, element, rawHtml, 'json-script', () => `json: ${structuralSelector($, element)}`));
2843
+ });
2844
+ $('script').each((_index, element) => {
2845
+ const type = $(element).attr('type');
2846
+ if (type !== undefined && type !== 'text/javascript' && type !== 'module') return;
2847
+ const text = $(element).text();
2848
+ const match = INLINE_STATE_PATTERN.exec(text);
2849
+ if (match === null) return;
2850
+ const value = tryParseJson(text);
2851
+ const selector = structuralSelector($, element);
2852
+ findings.push({
2853
+ kind: 'inline-state',
2854
+ label: match[1],
2855
+ selector,
2856
+ selectorKind: 'css',
2857
+ matches: countMatches(rawHtml, selector),
2858
+ keys: keysOf(value)
2859
+ });
2860
+ });
2861
+ $('meta[name], meta[property]').each((_index, element) => {
2862
+ const name = $(element).attr('name') ?? $(element).attr('property') ?? '';
2863
+ const selector = structuralSelector($, element);
2864
+ findings.push({
2865
+ kind: 'meta',
2866
+ label: `meta: ${name}`,
2867
+ selector,
2868
+ selectorKind: 'css',
2869
+ matches: countMatches(rawHtml, selector),
2870
+ attribute: 'content'
2871
+ });
2872
+ });
2873
+ $('link[rel]').each((_index, element) => {
2874
+ const rel = $(element).attr('rel') ?? '';
2875
+ const selector = structuralSelector($, element);
2876
+ findings.push({
2877
+ kind: 'link',
2878
+ label: `link: ${rel}`,
2879
+ selector,
2880
+ selectorKind: 'css',
2881
+ matches: countMatches(rawHtml, selector),
2882
+ attribute: 'href'
2883
+ });
2884
+ });
2885
+ return findings;
2886
+ }
2887
+ function scriptFinding($, element, rawHtml, kind, labelOf) {
2888
+ const value = tryParseJson($(element).text());
2889
+ if (value === undefined) return [];
2890
+ const selector = structuralSelector($, element);
2891
+ return [{
2892
+ kind,
2893
+ label: labelOf(value),
2894
+ selector,
2895
+ selectorKind: 'css',
2896
+ matches: countMatches(rawHtml, selector),
2897
+ keys: keysOf(value)
2898
+ }];
2899
+ }
2900
+ function ldJsonLabel(value) {
2901
+ if (Array.isArray(value)) return `ld+json: array (${value.length})`;
2902
+ if (typeof value === 'object' && value !== null) {
2903
+ const type = value['@type'];
2904
+ if (typeof type === 'string') return `ld+json: ${type}`;
2905
+ }
2906
+ return `ld+json: ${describeJson(value, 'json').type}`;
2907
+ }
2908
+ function keysOf(value) {
2909
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? Object.keys(value) : undefined;
2910
+ }
2911
+ /**
2912
+ * A unique CSS path for an element with no stable attribute of its own
2913
+ * (a `<script>` tag has no id or class to speak of): `<ancestor> > tag`
2914
+ * from `<html>` down, `:nth-of-type` added only where a tag has siblings of
2915
+ * the same name under the same parent. Always resolves to exactly one
2916
+ * element, on the raw document this ran against and on the real page (a
2917
+ * purely structural selector, unaffected by the snapshot's own rewriting).
2918
+ *
2919
+ * @param $ - The document.
2920
+ * @param element - The element to address.
2921
+ * @returns The selector.
2922
+ */
2923
+ function structuralSelector($, element) {
2924
+ const segments = [];
2925
+ let $current = $(element);
2926
+ while ($current.length > 0) {
2927
+ const tag = $current.prop('tagName')?.toLowerCase();
2928
+ if (tag === undefined) break;
2929
+ const $parent = $current.parent();
2930
+ const siblingsOfType = $parent.length > 0 ? $parent.children(tag) : $current;
2931
+ const index = siblingsOfType.toArray().indexOf($current.get(0)) + 1;
2932
+ segments.unshift(siblingsOfType.length > 1 ? `${tag}:nth-of-type(${index})` : tag);
2933
+ if (tag === 'html' || $parent.length === 0) break;
2934
+ $current = $parent;
2935
+ }
2936
+ return segments.join(' > ');
2937
+ }
2938
+
2939
+ /** How long the browser is given to settle after `goto`, watching for JSON responses (mirrors `packages/cli`'s `probe --browser`, `observeBrowserJson`). */
2940
+ const OBSERVE_MS = 4000;
2941
+ /** Responses kept, largest/most-recent-first ordering left to the caller; just a sane cap on what the panel lists. */
2942
+ const RESPONSE_LIMIT = 15;
2943
+ /**
2944
+ * Records the JSON responses seen while an input recipe's start page
2945
+ * rendered (studio plan §3.3, issue #93): the same technique `packages/cli`'s
2946
+ * `probe --browser` uses (`observeBrowserJson`) — render the page, listen for
2947
+ * `response` events whose content type is JSON, give it a few seconds to
2948
+ * settle. Kept as its own capture, independent of `page-snapshot`'s
2949
+ * `take-snapshot.use-case.ts`: that capture reads `page.content()` right
2950
+ * after `goto` resolves, with no observation window, and changing its shape
2951
+ * to also collect responses was a larger, riskier change to already-shipped,
2952
+ * tested code than this phase's budget allowed (see this phase's final
2953
+ * report) — so this opens its own short-lived browser session instead of
2954
+ * piggy-backing on the cached snapshot.
2955
+ *
2956
+ * Only `"start"` is supported, mirroring `take-snapshot.use-case.ts`'s own
2957
+ * reach; api-mode recipes have no page load to observe, so they always
2958
+ * answer with an empty list rather than an error (there is nothing wrong
2959
+ * with asking, the answer is just "none").
2960
+ *
2961
+ * @param input - The input recipe; only `mode` and the first `start` point are used.
2962
+ * @param stepPath - The step to observe after, e.g. `"start"`.
2963
+ * @param browser - Browser launch settings, shared with `take-snapshot`/`verify-selector`/`run-sample`.
2964
+ * @returns The distinct JSON responses observed, at most `RESPONSE_LIMIT`.
2965
+ * @throws Error when the recipe has no start point, or `stepPath` is not `"start"`.
2966
+ */
2967
+ async function responsesSeen(input, stepPath, browser) {
2968
+ if (stepPath !== 'start') throw new Error(`responsesSeen: "${stepPath}" is not supported yet — only "start" (the first start point, before any step ran) is`);
2969
+ if (input.mode !== 'web') return [];
2970
+ const point = input.start[0];
2971
+ if (point === undefined) throw new Error(`recipe "${input.id}" has no start point`);
2972
+ const client = await BrowserClient.launch(browser);
2973
+ try {
2974
+ const session = await client.newSession();
2975
+ try {
2976
+ const responses = [];
2977
+ const seen = new Set();
2978
+ const pending = [];
2979
+ session.page.on('response', response => {
2980
+ if (!(response.headers()['content-type'] ?? '').includes('json')) return;
2981
+ const key = `${response.status()} ${response.url()}`;
2982
+ if (seen.has(key)) return;
2983
+ seen.add(key);
2984
+ pending.push(recordResponse(response, responses));
2985
+ });
2986
+ try {
2987
+ await session.page.goto(point.url, {
2988
+ waitUntil: 'networkidle'
2989
+ });
2990
+ } catch {
2991
+ // a page that never idles (long-poll, websocket) still yields whatever was observed by the timeout below
2992
+ }
2993
+ await session.page.waitForTimeout(OBSERVE_MS);
2994
+ await Promise.all(pending);
2995
+ return responses.slice(0, RESPONSE_LIMIT);
2996
+ } finally {
2997
+ await session.close();
2998
+ }
2999
+ } finally {
3000
+ await client.close();
3001
+ }
3002
+ }
3003
+ /** Reads one response's body, when it still can be (not aborted, not redirected away); a response that cannot be read is silently left out rather than failing the whole capture. */
3004
+ async function recordResponse(response, into) {
3005
+ try {
3006
+ const body = await response.text();
3007
+ const value = tryParseJson(body);
3008
+ into.push({
3009
+ url: response.url(),
3010
+ status: response.status(),
3011
+ size: body.length,
3012
+ shape: value === undefined ? 'text' : describeJson(value, 'json').type
3013
+ });
3014
+ } catch {
3015
+ // response body unavailable (aborted, redirected, the page navigated away) — skip it
3016
+ }
3017
+ }
3018
+
3019
+ /**
3020
+ * Handles `inspect-page`: the Inspect panel's DOM tree and data-in-the-page
3021
+ * findings, off the same cached snapshot `take-snapshot`/`infer-selector`
3022
+ * already use (`page-inspector`, issue #93). `domTree` reads the cached,
3023
+ * rewritten snapshot (the same markup the content pane's iframe shows, so a
3024
+ * tree row's `nodeId` lines up with it exactly); `pageData` reads the raw
3025
+ * capture (`SnapshotResult.rawHtml`), since the display snapshot has had its
3026
+ * `<script>` tags stripped for the sandboxed iframe.
3027
+ *
3028
+ * @param state - The server state.
3029
+ * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
3030
+ * @returns The tree and the data-in-the-page findings.
3031
+ * @throws Error when no workspace is open, or `command.path` has no cached snapshot yet.
3032
+ */
3033
+ function handleInspectPage(state, command) {
3034
+ const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
3035
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
3036
+ return {
3037
+ tree: domTree(snapshot.html),
3038
+ pageData: pageData(snapshot.rawHtml)
3039
+ };
3040
+ }
3041
+
3042
+ /**
3043
+ * The outline model: an `InputRecipe`'s `steps` (and only `steps`; the rest
3044
+ * of the recipe passes through untouched, see `recipe-to-outline.mapper.ts`)
3045
+ * read as a tree of cards and brackets instead of raw JSON.
3046
+ *
3047
+ * A **card** is one step that reads as a sentence (`sentence`, built by
3048
+ * `card-sentence.mapper.ts`). A **bracket** is a container step (`forEach`,
3049
+ * `paginate`, `if`) whose body is itself a list of cards/brackets: the
3050
+ * indentation the plan doc describes is this nesting, not a separate layout
3051
+ * concept. Both kinds carry `step`, the step's own raw JSON exactly as
3052
+ * parsed (every key, in its original order): `outline-to-recipe.mapper.ts`
3053
+ * rebuilds the recipe from it, substituting only the (possibly edited)
3054
+ * `children`/`elseChildren` for whatever `steps`/`else` value `step` itself
3055
+ * holds, so an edit to one field never disturbs another's position or a
3056
+ * step type this outline cannot make sense of.
3057
+ */
3058
+ /** The step types the outline renders as a bracket: their body is nested steps, not a single value. */
3059
+ const BRACKET_STEP_TYPES = ['forEach', 'paginate', 'if'];
3060
+ function isBracket(node) {
3061
+ return node.kind === 'bracket';
3062
+ }
3063
+ function isBracketStepType(type) {
3064
+ return BRACKET_STEP_TYPES.includes(type);
3065
+ }
3066
+
3067
+ /** Step types the plan doc names as always going to a "custom" card: nothing here renders their meaning as a sentence. */
3068
+ const ALWAYS_CUSTOM = new Set(['evaluate', 'hook', 'captcha']);
3069
+ /**
3070
+ * Turns one step's raw JSON into its sentence parts (word/pill/code), the
3071
+ * way the plan doc's examples read: *Go to…*, *Read… from…*, *For each… in…
3072
+ * → one record*, and so on. Never throws: a step type this cannot read
3073
+ * becomes a short, honest fallback with `custom: true`, so the outline never
3074
+ * drops a step for looking unfamiliar.
3075
+ *
3076
+ * @param step - A step's raw JSON (its `type` and whatever fields it has).
3077
+ * @returns The sentence parts, and whether they are a real reading or a fallback.
3078
+ */
3079
+ function cardSentence(step) {
3080
+ const type = typeof step.type === 'string' ? step.type : undefined;
3081
+ if (type === undefined || ALWAYS_CUSTOM.has(type)) return fallback(type);
3082
+ const build = BUILDERS[type];
3083
+ if (build === undefined) return fallback(type);
3084
+ try {
3085
+ return {
3086
+ parts: build(step),
3087
+ custom: false
3088
+ };
3089
+ } catch {
3090
+ return fallback(type);
3091
+ }
3092
+ }
3093
+ function fallback(type) {
3094
+ return {
3095
+ parts: [word(type ?? 'custom step')],
3096
+ custom: true
3097
+ };
3098
+ }
3099
+ function word(text) {
3100
+ return {
3101
+ kind: 'word',
3102
+ text
3103
+ };
3104
+ }
3105
+ function pill(text) {
3106
+ return {
3107
+ kind: 'pill',
3108
+ text
3109
+ };
3110
+ }
3111
+ function code(text) {
3112
+ return {
3113
+ kind: 'code',
3114
+ text
3115
+ };
3116
+ }
3117
+ /** A field that names an id: shown as a pill, `fallbackText` when it is absent. */
1244
3118
  function pillField(step, key, fallbackText) {
1245
3119
  const value = step[key];
1246
3120
  return pill(typeof value === 'string' && value.length > 0 ? value : fallbackText);
1247
3121
  }
1248
- /** A field shown as a literal (a selector, a template, a value): stringified when it is not already a string. */
1249
- function codeField(step, key, fallbackText = '') {
1250
- const value = step[key];
1251
- if (typeof value === 'string') return code(value);
1252
- if (value === undefined) return code(fallbackText);
1253
- return code(safeJson(value));
3122
+ /** A field shown as a literal (a selector, a template, a value): stringified when it is not already a string. */
3123
+ function codeField(step, key, fallbackText = '') {
3124
+ const value = step[key];
3125
+ if (typeof value === 'string') return code(value);
3126
+ if (value === undefined) return code(fallbackText);
3127
+ return code(safeJson(value));
3128
+ }
3129
+ function safeJson(value) {
3130
+ try {
3131
+ return JSON.stringify(value) ?? String(value);
3132
+ } catch {
3133
+ return String(value);
3134
+ }
3135
+ }
3136
+ /** `selector` or `target`, whichever the step gives (`step.contract.ts`'s `TargetFields`: exactly one is present). */
3137
+ function targetField(step) {
3138
+ if (typeof step.target === 'string') return code(step.target);
3139
+ return codeField(step, 'selector');
3140
+ }
3141
+ const BUILDERS = {
3142
+ goto: step => [word('Go to'), codeField(step, 'url')],
3143
+ click: step => [word('Click'), targetField(step)],
3144
+ fill: step => [word('Fill'), targetField(step), word('with'), codeField(step, 'value')],
3145
+ press: step => [word('Press'), codeField(step, 'key'), word('in'), targetField(step)],
3146
+ select: step => [word('Choose'), selectChoice(step), word('in'), targetField(step)],
3147
+ scroll: step => [word('Scroll to'), codeField(step, 'to')],
3148
+ wait: step => [word('Wait for'), waitFor(step)],
3149
+ screenshot: step => [word('Screenshot'), codeField(step, 'path')],
3150
+ request: step => [word('Request'), code(`${typeof step.method === 'string' ? step.method : 'GET'} `), codeField(step, 'url')],
3151
+ extract: step => [word('Read'), pillField(step, 'id', '(unnamed)'), word('from'), pillField(step, 'from', 'page'), word('←'), codeField(step, 'selector')],
3152
+ set: step => [word('Set'), pillField(step, 'id', '(unnamed)'), word('to'), codeField(step, 'value')],
3153
+ collect: step => [word('Collect'), codeField(step, 'value'), word('into'), pillField(step, 'into', '(unnamed)')],
3154
+ forEach: step => forEachSentence(step),
3155
+ paginate: step => paginateSentence(step),
3156
+ if: step => [word('If'), codeField(step, 'test')],
3157
+ emit: step => emitSentence(step)
3158
+ };
3159
+ function selectChoice(step) {
3160
+ if (typeof step.value === 'string') return code(step.value);
3161
+ if (typeof step.label === 'string') return code(step.label);
3162
+ if (typeof step.index === 'number') return code(String(step.index));
3163
+ if (Array.isArray(step.values)) return code(step.values.join(', '));
3164
+ return code('');
3165
+ }
3166
+ function waitFor(step) {
3167
+ if (typeof step.selector === 'string') return code(step.selector);
3168
+ if (typeof step.ms === 'number') return code(`${step.ms}ms`);
3169
+ if (typeof step.state === 'string') return code(step.state);
3170
+ return code('');
3171
+ }
3172
+ function forEachSentence(step) {
3173
+ const parts = [word('For each'), pillField(step, 'as', '(item)'), word('in'), typeof step.over === 'string' ? pill(step.over) : targetField(step)];
3174
+ if (step.emit !== undefined) parts.push(word('→'), word('one record'));
3175
+ return parts;
3176
+ }
3177
+ function paginateSentence(step) {
3178
+ const parts = [word('For every page')];
3179
+ const next = step.next;
3180
+ if (next !== null && typeof next === 'object') {
3181
+ const record = next;
3182
+ if (typeof record.selector === 'string') parts.push(word('via'), code(record.selector));else if (typeof record.url === 'string') parts.push(word('via'), code(record.url));else if (typeof record.jsonpath === 'string') parts.push(word('via'), code(record.jsonpath));
3183
+ }
3184
+ if (typeof step.until === 'string') parts.push(word('until'), code(step.until));
3185
+ return parts;
3186
+ }
3187
+ function emitSentence(step) {
3188
+ if (typeof step.output === 'string') return [word('Emit'), word('to'), pill(step.output)];
3189
+ return [word('Emit')];
3190
+ }
3191
+
3192
+ /**
3193
+ * Reads a recipe's `steps` as an outline. Works on whatever JSON parsed
3194
+ * (not a validated `InputRecipe`): a recipe mid-edit in the JSON tab may be
3195
+ * missing fields or have the wrong shape, and the outline still has to
3196
+ * render something rather than crash the tab. Anything that is not a
3197
+ * well-formed step becomes a custom card holding what was there; anything
3198
+ * that is not an object at the top becomes an outline with no steps.
3199
+ *
3200
+ * @param content - The recipe's parsed JSON (the JSON tab's own `JSON.parse` of its text).
3201
+ * @returns The outline: the untouched top-level record plus the step tree.
3202
+ */
3203
+ function recipeToOutline(content) {
3204
+ const recipe = isRecord(content) ? content : {};
3205
+ const steps = Array.isArray(recipe.steps) ? recipe.steps : [];
3206
+ return {
3207
+ recipe,
3208
+ steps: stepsToNodes(steps, 'steps')
3209
+ };
3210
+ }
3211
+ function stepsToNodes(steps, pathPrefix) {
3212
+ return steps.map((step, index) => stepToNode(step, `${pathPrefix}.${index}`));
1254
3213
  }
1255
- function safeJson(value) {
3214
+ function stepToNode(step, path) {
3215
+ if (!isRecord(step)) {
3216
+ return {
3217
+ kind: 'card',
3218
+ path,
3219
+ stepType: 'unknown',
3220
+ sentence: [{
3221
+ kind: 'word',
3222
+ text: 'custom step'
3223
+ }],
3224
+ step: {},
3225
+ custom: true
3226
+ };
3227
+ }
3228
+ const stepType = typeof step.type === 'string' ? step.type : 'unknown';
3229
+ const {
3230
+ parts,
3231
+ custom
3232
+ } = cardSentence(step);
3233
+ if (!isBracketStepType(stepType)) return {
3234
+ kind: 'card',
3235
+ path,
3236
+ stepType,
3237
+ sentence: parts,
3238
+ step,
3239
+ custom
3240
+ };
3241
+ const children = Array.isArray(step.steps) ? stepsToNodes(step.steps, `${path}.steps`) : [];
3242
+ const elseChildren = stepType === 'if' && Array.isArray(step.else) ? stepsToNodes(step.else, `${path}.else`) : undefined;
3243
+ return {
3244
+ kind: 'bracket',
3245
+ path,
3246
+ stepType,
3247
+ sentence: parts,
3248
+ step,
3249
+ children,
3250
+ ...(elseChildren !== undefined && {
3251
+ elseChildren
3252
+ })
3253
+ };
3254
+ }
3255
+ function isRecord(value) {
3256
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
3257
+ }
3258
+
3259
+ /**
3260
+ * Rebuilds a recipe from its outline: `recipe`'s keys, in their original
3261
+ * order, with `steps` (when the recipe had one) replaced by the outline's
3262
+ * (possibly edited) step tree. A card's own raw `step` is used as-is; a
3263
+ * bracket's `step` supplies every field but `steps`/`else`, which come from
3264
+ * `children`/`elseChildren` instead, put back at the position `step` itself
3265
+ * held that key at (or appended, for a bracket a `+` menu just added, whose
3266
+ * `step` never had one) — this, together with `recipe-to-outline.mapper.ts`
3267
+ * always keeping the step's whole original object, is what makes
3268
+ * `recipe → outline → recipe` preserve every key and its order.
3269
+ *
3270
+ * @param outline - An outline, from `recipe-to-outline.mapper.ts` or edited by the UI.
3271
+ * @returns The recipe's plain JSON, ready for `save-recipe`.
3272
+ */
3273
+ function outlineToRecipe(outline) {
3274
+ if (!('steps' in outline.recipe) && outline.steps.length === 0) return {
3275
+ ...outline.recipe
3276
+ };
3277
+ return {
3278
+ ...outline.recipe,
3279
+ steps: nodesToSteps(outline.steps)
3280
+ };
3281
+ }
3282
+ function nodesToSteps(nodes) {
3283
+ return nodes.map(node => nodeToStep(node));
3284
+ }
3285
+ function nodeToStep(node) {
3286
+ if (!isBracket(node)) return node.step;
3287
+ const keys = Object.keys(node.step);
3288
+ const step = {};
3289
+ for (const key of keys) {
3290
+ if (key === 'steps') step.steps = nodesToSteps(node.children);else if (key === 'else' && node.stepType === 'if') step.else = nodesToSteps(node.elseChildren ?? []);else step[key] = node.step[key];
3291
+ }
3292
+ if (!keys.includes('steps')) step.steps = nodesToSteps(node.children);
3293
+ if (node.stepType === 'if' && node.elseChildren !== undefined && !keys.includes('else')) step.else = nodesToSteps(node.elseChildren);
3294
+ return step;
3295
+ }
3296
+
3297
+ /**
3298
+ * Opens a recipe folder: every `.json`/`.jsonl` recipe directly inside it
3299
+ * (core's own directory reading, so relative `file:` URLs resolve the same
3300
+ * way a real run would), parsed and, for each output recipe found, bound to
3301
+ * the input recipes that name it. Nothing throws for a bad recipe or a
3302
+ * recipe that does not bind: every problem is reported on its file with the
3303
+ * JSON path core already computes, exactly as `opencraw validate` prints it.
3304
+ *
3305
+ * @param folder - The workspace folder.
3306
+ * @returns Every recipe file found, each with its validation and binding issues.
3307
+ * @throws Error when the folder does not exist or cannot be read.
3308
+ */
3309
+ async function openWorkspace(folder) {
3310
+ const documents = await readRecipeSource(folder);
3311
+ const entries = documents.map(document => parseDocument(document));
3312
+ const outputs = entries.filter(isParsed('output'));
3313
+ const inputs = entries.filter(isParsed('input'));
3314
+ for (const output of outputs) bindOutput(output, inputs);
3315
+ for (const input of inputs) {
3316
+ if (outputs.every(output => output.recipe.id !== input.recipe.output)) {
3317
+ input.issues.push({
3318
+ path: 'output',
3319
+ message: `no output recipe "${input.recipe.output}" in this workspace`,
3320
+ kind: 'binding'
3321
+ });
3322
+ }
3323
+ }
3324
+ return {
3325
+ folder,
3326
+ recipes: entries.map(({
3327
+ file,
3328
+ kind,
3329
+ id,
3330
+ issues,
3331
+ content
3332
+ }) => ({
3333
+ file,
3334
+ kind,
3335
+ id,
3336
+ issues,
3337
+ text: `${JSON.stringify(content, null, 2)}\n`,
3338
+ // The Steps tab needs steps to render: an output/unknown file has none. recipeToOutline never
3339
+ // throws, so this covers a file that fails validation too (its outline just has "custom" cards).
3340
+ outline: kind === 'input' ? recipeToOutline(content) : undefined
3341
+ }))
3342
+ };
3343
+ }
3344
+ /** Binds one output recipe to the inputs that name it, filing every binding issue back on the recipe it names. */
3345
+ function bindOutput(output, inputs) {
3346
+ const matching = inputs.filter(input => input.recipe.output === output.recipe.id);
1256
3347
  try {
1257
- return JSON.stringify(value) ?? String(value);
1258
- } catch {
1259
- return String(value);
3348
+ bindRecipeSet(output.recipe, matching.map(input => input.recipe));
3349
+ } catch (error) {
3350
+ if (!(error instanceof RecipeBindingError)) throw error;
3351
+ for (const issue of error.issues) {
3352
+ const target = issue.recipeId === output.recipe.id ? output : matching.find(input => input.recipe.id === issue.recipeId);
3353
+ target?.issues.push({
3354
+ path: issue.path,
3355
+ message: issue.message,
3356
+ kind: 'binding'
3357
+ });
3358
+ }
3359
+ }
3360
+ }
3361
+ function isParsed(kind) {
3362
+ return entry => entry.kind === kind && entry.recipe !== undefined;
3363
+ }
3364
+ function parseDocument(document) {
3365
+ const kind = document.content?.kind;
3366
+ if (kind === 'output') return parseOne(document.source, document.content, 'output', parseOutputRecipe);
3367
+ if (kind === 'input') return parseOne(document.source, document.content, 'input', parseInputRecipe);
3368
+ return {
3369
+ file: document.source,
3370
+ kind: 'unknown',
3371
+ content: document.content,
3372
+ issues: [{
3373
+ path: 'kind',
3374
+ message: '"kind" is missing or not "input"/"output"',
3375
+ kind: 'validation'
3376
+ }]
3377
+ };
3378
+ }
3379
+ function parseOne(file, content, kind, parse) {
3380
+ try {
3381
+ const recipe = parse(content, file);
3382
+ return {
3383
+ file,
3384
+ kind,
3385
+ content,
3386
+ id: recipe.id,
3387
+ issues: [],
3388
+ recipe
3389
+ };
3390
+ } catch (error) {
3391
+ if (!(error instanceof RecipeValidationError)) throw error;
3392
+ return {
3393
+ file,
3394
+ kind,
3395
+ content,
3396
+ issues: error.issues.map(issue => ({
3397
+ path: issue.path === '' ? '(root)' : issue.path,
3398
+ message: issue.message,
3399
+ kind: 'validation'
3400
+ }))
3401
+ };
3402
+ }
3403
+ }
3404
+
3405
+ /**
3406
+ * Writes a recipe back to its file: pretty JSON, a 2-space indent, a
3407
+ * trailing newline. Phase 0 takes the JSON tab's whole parsed object rather
3408
+ * than a patch, so this is a full replacement, not a splice: loading a file
3409
+ * the studio has not touched and saving it again keeps every key, value and
3410
+ * key order (`JSON.parse` preserves insertion order, and `JSON.stringify`
3411
+ * writes it back), but not necessarily the file's exact original formatting
3412
+ * (alignment, how arrays wrap, a trailing comment). Byte-identical
3413
+ * round-trip for a hand-formatted file is future work; see this file's test
3414
+ * for what is guaranteed today.
3415
+ *
3416
+ * @param path - The recipe file to write.
3417
+ * @param recipe - The whole recipe object.
3418
+ */
3419
+ async function saveRecipe(path, recipe) {
3420
+ await writeFile(path, `${JSON.stringify(recipe, null, 2)}\n`, 'utf8');
3421
+ }
3422
+
3423
+ /** Where the studio's own recorder profile lives when `StudioServerOptions.recorderProfileDir` is not given: never a crawl's own `session.browserProfile` directory, never the person's default browser profile. */
3424
+ function defaultRecorderProfileDir() {
3425
+ return join(tmpdir(), 'opencraw-studio', 'recorder-profile');
3426
+ }
3427
+ /** @returns Fresh state for a new server instance: no workspace open, no run, no recording, no sockets, an empty snapshot cache, no last runs, Playwright's own default browser, the default recorder profile directory. */
3428
+ function createStudioState() {
3429
+ return {
3430
+ sockets: new Set(),
3431
+ snapshots: createSnapshotCache(),
3432
+ lastRuns: createLastRunCache(),
3433
+ recorderProfileDir: defaultRecorderProfileDir()
3434
+ };
3435
+ }
3436
+ /**
3437
+ * Sends one event to every connected client. A socket that errors while
3438
+ * sending (gone away without a clean close yet reaching us) is dropped
3439
+ * rather than left to fail every later broadcast too.
3440
+ *
3441
+ * @param state - The server state holding the connected sockets.
3442
+ * @param event - The event to send.
3443
+ */
3444
+ function broadcast(state, event) {
3445
+ const text = JSON.stringify(event);
3446
+ for (const socket of state.sockets) {
3447
+ try {
3448
+ socket.send(text);
3449
+ } catch {
3450
+ state.sockets.delete(socket);
3451
+ }
1260
3452
  }
1261
3453
  }
1262
- /** `selector` or `target`, whichever the step gives (`step.contract.ts`'s `TargetFields`: exactly one is present). */
1263
- function targetField(step) {
1264
- if (typeof step.target === 'string') return code(step.target);
1265
- return codeField(step, 'selector');
1266
- }
1267
- const BUILDERS = {
1268
- goto: step => [word('Go to'), codeField(step, 'url')],
1269
- click: step => [word('Click'), targetField(step)],
1270
- fill: step => [word('Fill'), targetField(step), word('with'), codeField(step, 'value')],
1271
- press: step => [word('Press'), codeField(step, 'key'), word('in'), targetField(step)],
1272
- select: step => [word('Choose'), selectChoice(step), word('in'), targetField(step)],
1273
- scroll: step => [word('Scroll to'), codeField(step, 'to')],
1274
- wait: step => [word('Wait for'), waitFor(step)],
1275
- screenshot: step => [word('Screenshot'), codeField(step, 'path')],
1276
- request: step => [word('Request'), code(`${typeof step.method === 'string' ? step.method : 'GET'} `), codeField(step, 'url')],
1277
- extract: step => [word('Read'), pillField(step, 'id', '(unnamed)'), word('from'), pillField(step, 'from', 'page'), word('←'), codeField(step, 'selector')],
1278
- set: step => [word('Set'), pillField(step, 'id', '(unnamed)'), word('to'), codeField(step, 'value')],
1279
- collect: step => [word('Collect'), codeField(step, 'value'), word('into'), pillField(step, 'into', '(unnamed)')],
1280
- forEach: step => forEachSentence(step),
1281
- paginate: step => paginateSentence(step),
1282
- if: step => [word('If'), codeField(step, 'test')],
1283
- emit: step => emitSentence(step)
1284
- };
1285
- function selectChoice(step) {
1286
- if (typeof step.value === 'string') return code(step.value);
1287
- if (typeof step.label === 'string') return code(step.label);
1288
- if (typeof step.index === 'number') return code(String(step.index));
1289
- if (Array.isArray(step.values)) return code(step.values.join(', '));
1290
- return code('');
3454
+
3455
+ /**
3456
+ * Handles `open-workspace`: reads the folder, remembers it as the server's
3457
+ * current workspace (every later command that names a recipe resolves it
3458
+ * against this folder), and tells every connected client the workspace changed.
3459
+ *
3460
+ * @param state - The server state to update.
3461
+ * @param command - The command.
3462
+ * @returns The workspace's recipes and their issues.
3463
+ */
3464
+ async function handleOpenWorkspace(state, command) {
3465
+ const view = await openWorkspace(command.folder);
3466
+ state.folder = command.folder;
3467
+ broadcast(state, {
3468
+ type: 'workspace-changed'
3469
+ });
3470
+ return view;
1291
3471
  }
1292
- function waitFor(step) {
1293
- if (typeof step.selector === 'string') return code(step.selector);
1294
- if (typeof step.ms === 'number') return code(`${step.ms}ms`);
1295
- if (typeof step.state === 'string') return code(step.state);
1296
- return code('');
3472
+
3473
+ /**
3474
+ * Handles `GET /api/pdf-bytes`: the raw bytes of a cached PDF snapshot, for
3475
+ * the PDF canvas's `pdf.js` to render in the browser (studio plan §3.4,
3476
+ * issue #94's 5b). Not a `studio-api` command — the other commands all
3477
+ * answer JSON, and pdf.js wants the file itself — so this is its own route,
3478
+ * read directly by `studio-http.use-case.ts` (mirrors `static-file.handler.ts`'s
3479
+ * own reach outside the command dispatch).
3480
+ *
3481
+ * @param state - The server state.
3482
+ * @param recipeId - The recipe.
3483
+ * @param path - The step path; must already have a cached PDF snapshot (call `take-snapshot` first).
3484
+ * @returns The raw PDF bytes.
3485
+ * @throws Error when `path` has no cached snapshot yet, or the snapshot's document is not a PDF.
3486
+ */
3487
+ async function handlePdfBytes(state, recipeId, path) {
3488
+ const snapshot = cachedSnapshot(state.snapshots, recipeId, path);
3489
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${recipeId}" at "${path}": call take-snapshot first`);
3490
+ if (snapshot.format !== 'pdf') throw new Error(`"${recipeId}" at "${path}" is not a PDF snapshot`);
3491
+ return pdfBytes(snapshot.baseUrl);
1297
3492
  }
1298
- function forEachSentence(step) {
1299
- const parts = [word('For each'), pillField(step, 'as', '(item)'), word('in'), typeof step.over === 'string' ? pill(step.over) : targetField(step)];
1300
- if (step.emit !== undefined) parts.push(word('→'), word('one record'));
1301
- return parts;
3493
+
3494
+ /**
3495
+ * Handles `pdf-view`: the PDF canvas's cells and rows (`document-view`'s
3496
+ * `pdf-view.mapper.ts`, studio plan §3.4, issue #94's 5b), off the same
3497
+ * cached snapshot `take-snapshot`/`document-tree` already use.
3498
+ *
3499
+ * @param state - The server state.
3500
+ * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
3501
+ * @returns The document's view.
3502
+ * @throws Error when no workspace is open, `command.path` has no cached snapshot yet, or the snapshot's document is not a read PDF.
3503
+ */
3504
+ function handlePdfView(state, command) {
3505
+ const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
3506
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
3507
+ if (!isPdfDocument(snapshot.body)) throw new Error(`"${command.recipeId}" at "${command.path}" is not a PDF snapshot: the PDF canvas only reads a read PDF document`);
3508
+ return pdfDocumentView(snapshot.body);
1302
3509
  }
1303
- function paginateSentence(step) {
1304
- const parts = [word('For every page')];
1305
- const next = step.next;
1306
- if (next !== null && typeof next === 'object') {
1307
- const record = next;
1308
- if (typeof record.selector === 'string') parts.push(word('via'), code(record.selector));else if (typeof record.url === 'string') parts.push(word('via'), code(record.url));else if (typeof record.jsonpath === 'string') parts.push(word('via'), code(record.jsonpath));
3510
+
3511
+ /**
3512
+ * Turns one resolved recorded action into the recipe step it reads as
3513
+ * (issue #95's card sentences): `Click <selector>`, `Fill <selector> with
3514
+ * <value>`, `Select <selector> = <value>`, `Press <key>`, `Scroll to
3515
+ * bottom`. Pure: `recorder-session.use-case.ts` has already resolved the
3516
+ * selector (`selector-inference`) and, for a secret field, already replaced
3517
+ * `value` with its `{{env.NAME}}` placeholder (`secret-field.policy.ts`) —
3518
+ * this mapper never sees, and so can never leak, a real secret value.
3519
+ *
3520
+ * @param action - The resolved action.
3521
+ * @returns The step it becomes, or `undefined` for an action this mapper cannot place (a click/fill/select with no verified selector).
3522
+ */
3523
+ function actionToStep(action) {
3524
+ switch (action.kind) {
3525
+ case 'click':
3526
+ {
3527
+ if (action.selector === undefined) return undefined;
3528
+ const step = {
3529
+ type: 'click',
3530
+ selector: action.selector
3531
+ };
3532
+ return step;
3533
+ }
3534
+ case 'fill':
3535
+ {
3536
+ if (action.selector === undefined) return undefined;
3537
+ const step = {
3538
+ type: 'fill',
3539
+ selector: action.selector,
3540
+ value: action.value ?? ''
3541
+ };
3542
+ return step;
3543
+ }
3544
+ case 'select':
3545
+ {
3546
+ if (action.selector === undefined) return undefined;
3547
+ const step = {
3548
+ type: 'select',
3549
+ selector: action.selector,
3550
+ value: action.value
3551
+ };
3552
+ return step;
3553
+ }
3554
+ case 'keypress':
3555
+ {
3556
+ const key = action.key ?? 'Enter';
3557
+ const step = action.selector === undefined ? {
3558
+ type: 'press',
3559
+ key
3560
+ } : {
3561
+ type: 'press',
3562
+ selector: action.selector,
3563
+ key
3564
+ };
3565
+ return step;
3566
+ }
3567
+ case 'scroll':
3568
+ {
3569
+ const step = {
3570
+ type: 'scroll',
3571
+ to: 'bottom'
3572
+ };
3573
+ return step;
3574
+ }
1309
3575
  }
1310
- if (typeof step.until === 'string') parts.push(word('until'), code(step.until));
1311
- return parts;
1312
3576
  }
1313
- function emitSentence(step) {
1314
- if (typeof step.output === 'string') return [word('Emit'), word('to'), pill(step.output)];
1315
- return [word('Emit')];
3577
+
3578
+ /** A `rel` token that means "the next page" on an `<a>`/`<link>`. */
3579
+ const REL_NEXT = /(?:^|\s)next(?:\s|$)/i;
3580
+ /** Link text that reads as "next", loosely: "Next", "Next »", "Next page", punctuation and arrows ignored. */
3581
+ const TEXT_NEXT = /^\s*next(?:\s*page)?\s*(?:[›»>]\s*)?$/i;
3582
+ /** The selector itself names the pattern (`li.next a`, `a[aria-label=Next]`) even when the reported text/rel do not. */
3583
+ const SELECTOR_NEXT = /(?:^|[\s>.])li\.next\b|\[aria-label\s*=\s*['"]?next['"]?\]/i;
3584
+ /**
3585
+ * Whether a clicked, navigating element looks like a "next" link (issue
3586
+ * #95's exact heuristics): `rel="next"`, link text matching "next", or the
3587
+ * resolved selector itself carrying `li.next a`/`a[aria-label=Next]`. Only
3588
+ * meaningful for a click that navigated — the caller checks that first.
3589
+ *
3590
+ * @param link - The anchor's own `rel`/text/`aria-label`, as reported by `recorder-script.client.ts`.
3591
+ * @param selector - The action's resolved selector (`selector-inference`'s own candidate).
3592
+ * @returns Whether the studio should offer to turn this click into a `paginate` step.
3593
+ */
3594
+ function looksLikeNextLink(link, selector) {
3595
+ if (link?.rel !== undefined && REL_NEXT.test(link.rel)) return true;
3596
+ if (link?.ariaLabel !== undefined && /^next$/i.test(link.ariaLabel.trim())) return true;
3597
+ if (link?.text !== undefined && TEXT_NEXT.test(link.text)) return true;
3598
+ if (selector !== undefined && SELECTOR_NEXT.test(selector)) return true;
3599
+ return false;
1316
3600
  }
1317
3601
 
3602
+ /** Why a report was refused before it became an action: out of this phase's scope (issue #95). */
3603
+ const UNSUPPORTED_REASONS = ['iframe', 'shadow-dom'];
1318
3604
  /**
1319
- * Reads a recipe's `steps` as an outline. Works on whatever JSON parsed
1320
- * (not a validated `InputRecipe`): a recipe mid-edit in the JSON tab may be
1321
- * missing fields or have the wrong shape, and the outline still has to
1322
- * render something rather than crash the tab. Anything that is not a
1323
- * well-formed step becomes a custom card holding what was there; anything
1324
- * that is not an object at the top becomes an outline with no steps.
3605
+ * The type/name/id of the form field a `fill`/`select` landed on, exactly as
3606
+ * the live DOM reports them — `secret-field.policy.ts`'s only input. Never
3607
+ * carries the field's value: that travels separately, on the action itself.
3608
+ */
3609
+ const recordedFieldSchema = z.object({
3610
+ type: z.string().optional(),
3611
+ name: z.string().optional(),
3612
+ id: z.string().optional()
3613
+ });
3614
+ /** What `next-link.policy.ts` needs off a clicked anchor: never the whole element. */
3615
+ const recordedLinkSchema = z.object({
3616
+ rel: z.string().optional(),
3617
+ text: z.string().optional(),
3618
+ ariaLabel: z.string().optional()
3619
+ });
3620
+ /**
3621
+ * The wire shape of one action as `recorder-script.client.ts` reports it,
3622
+ * over the Playwright binding `recorder-session.use-case.ts` exposes: the
3623
+ * `data-oc-node` id of the element involved (`selector-inference`'s own
3624
+ * marker attribute, `@opencraw/core`'s `NODE_ID_ATTRIBUTE`), never a
3625
+ * selector — the studio, not the page, decides that (studio plan §3.2,
3626
+ * issue #95). Never the field's real value when it might be a secret: the
3627
+ * script itself does not know which fields are secret (`secret-field.policy.ts`
3628
+ * runs server-side, once a `RecordedField` is known), but nothing here
3629
+ * *requires* a value to be typed out either way — `recorder-session.use-case.ts`
3630
+ * discards it the moment a secret is detected.
1325
3631
  *
1326
- * @param content - The recipe's parsed JSON (the JSON tab's own `JSON.parse` of its text).
1327
- * @returns The outline: the untouched top-level record plus the step tree.
3632
+ * `html` is the document's own markup, captured by the script itself at the
3633
+ * moment of the event (`document.documentElement.outerHTML`), not fetched
3634
+ * again afterwards: a click that navigates can leave the page before a
3635
+ * later `page.content()` call would resolve, reading the *next* page's
3636
+ * markup instead of the one the click actually happened on. Capturing it
3637
+ * synchronously, in the same event the node id is stamped in, means the
3638
+ * selector is always resolved against the exact document the action
3639
+ * happened on — still "verified against the page" (issue #95), just without
3640
+ * the race a second round trip would risk.
1328
3641
  */
1329
- function recipeToOutline(content) {
1330
- const recipe = isRecord(content) ? content : {};
1331
- const steps = Array.isArray(recipe.steps) ? recipe.steps : [];
1332
- return {
1333
- recipe,
1334
- steps: stepsToNodes(steps, 'steps')
1335
- };
3642
+ const rawActionSchema = z.discriminatedUnion('kind', [z.object({
3643
+ kind: z.literal('click'),
3644
+ nodeId: z.string().min(1),
3645
+ html: z.string().min(1),
3646
+ link: recordedLinkSchema.optional()
3647
+ }), z.object({
3648
+ kind: z.literal('fill'),
3649
+ nodeId: z.string().min(1),
3650
+ html: z.string().min(1),
3651
+ value: z.string(),
3652
+ field: recordedFieldSchema.optional()
3653
+ }), z.object({
3654
+ kind: z.literal('select'),
3655
+ nodeId: z.string().min(1),
3656
+ html: z.string().min(1),
3657
+ value: z.string(),
3658
+ field: recordedFieldSchema.optional()
3659
+ }), z.object({
3660
+ kind: z.literal('keypress'),
3661
+ nodeId: z.string().min(1).optional(),
3662
+ html: z.string().optional(),
3663
+ key: z.string().min(1)
3664
+ }), z.object({
3665
+ kind: z.literal('scroll'),
3666
+ to: z.literal('bottom')
3667
+ }), z.object({
3668
+ kind: z.literal('unsupported'),
3669
+ reason: z.enum(UNSUPPORTED_REASONS)
3670
+ })]);
3671
+
3672
+ /**
3673
+ * The in-page script `recorder-session.use-case.ts` installs on every page
3674
+ * of the studio's own headed browser window (`context.addInitScript`),
3675
+ * reporting the person's actions to the binding it also exposes on the
3676
+ * context (`context.exposeBinding`) — issue #95's "a script injected into
3677
+ * every page ... reports the user's actions: click, type, select, key
3678
+ * press, scroll".
3679
+ *
3680
+ * Kept as a plain string, not a TypeScript function passed to
3681
+ * `page.evaluate`/`addInitScript`, for the same reason as
3682
+ * `page-snapshot/hidden-marks.algorithm.ts`'s `hiddenMarksScript`: it
3683
+ * references `window`/`document` as globals, which `AGENTS.md`'s "things
3684
+ * that bit before" and the repo's `isolated-functions` lint rule forbid for
3685
+ * an in-page TypeScript function.
3686
+ *
3687
+ * What it reports, and what it deliberately does not:
3688
+ *
3689
+ * - **click**: the nearest clickable ancestor (`a`, `button`,
3690
+ * `input[type=submit|button]`) of the element clicked, or the element
3691
+ * itself; for an `<a>`, also its `rel`, text and `aria-label` —
3692
+ * `next-link.policy.ts`'s only input.
3693
+ * - **fill**: on `blur` of a text-like input/textarea whose value changed
3694
+ * since it was focused (never on every keystroke) — the field's `type`,
3695
+ * `name` and `id`, and the value *as typed*; whether that value is a
3696
+ * secret, and what replaces it, is `secret-field.policy.ts`'s call,
3697
+ * server-side, not this script's.
3698
+ * - **select**: on `change` of a `<select>`.
3699
+ * - **keypress**: `Enter` only (issue #95's own example, "Press Enter");
3700
+ * every other key is noise a recipe step has no shape for.
3701
+ * - **scroll**: once per "scroll session" (debounced), when the page has
3702
+ * been scrolled within `NEAR_BOTTOM_PX` of its bottom.
3703
+ * - **unsupported**: once, the first time an event's target sits inside a
3704
+ * shadow root, or this script runs in a frame that is not the top window
3705
+ * (an iframe) — issue #95's explicit out-of-scope cases, reported instead
3706
+ * of attempted.
3707
+ *
3708
+ * Every reported element is marked with `attribute` (`data-oc-node` by
3709
+ * default, `@opencraw/core`'s `NODE_ID_ATTRIBUTE`) if it does not already
3710
+ * carry one, the same attribute `selector-inference`'s `pathToNode` reads —
3711
+ * so `recorder-session.use-case.ts` can turn a report straight into a
3712
+ * verified selector with the phase 2 generator, never inventing its own.
3713
+ *
3714
+ * @param bindingName - The name Playwright's `exposeBinding` gave the reporting function (`window[bindingName]`).
3715
+ * @param attribute - The marker attribute; defaults to `NODE_ID_ATTRIBUTE`.
3716
+ * @returns A self-invoking script, ready for `context.addInitScript({ content: recorderScript(...) })`.
3717
+ */
3718
+ function recorderScript(bindingName, attribute = NODE_ID_ATTRIBUTE) {
3719
+ return `(() => {
3720
+ if (window.top !== window.self) {
3721
+ try { window.${bindingName}({ kind: 'unsupported', reason: 'iframe' }) } catch (e) {}
3722
+ return
3723
+ }
3724
+ var attribute = ${JSON.stringify(attribute)}
3725
+ var seq = 0
3726
+ var unsupportedReported = false
3727
+ var scrollTimer = null
3728
+ var focusValues = new WeakMap()
3729
+
3730
+ function report (payload) {
3731
+ try { window.${bindingName}(payload) } catch (e) {}
3732
+ }
3733
+
3734
+ function inShadowDom (element) {
3735
+ var root = element.getRootNode ? element.getRootNode() : document
3736
+ return root !== document
3737
+ }
3738
+
3739
+ function reportUnsupportedOnce (reason) {
3740
+ if (unsupportedReported) return
3741
+ unsupportedReported = true
3742
+ report({ kind: 'unsupported', reason: reason })
3743
+ }
3744
+
3745
+ function markNode (element) {
3746
+ var id = element.getAttribute(attribute)
3747
+ if (id) return id
3748
+ seq += 1
3749
+ id = 'rec-' + Date.now().toString(36) + '-' + seq
3750
+ element.setAttribute(attribute, id)
3751
+ return id
3752
+ }
3753
+
3754
+ function clickable (element) {
3755
+ return element.closest('a, button, input[type="submit"], input[type="button"]') || element
3756
+ }
3757
+
3758
+ function isTextEntry (element) {
3759
+ var tag = element.tagName
3760
+ if (tag === 'TEXTAREA' || tag === 'SELECT') return true
3761
+ if (tag !== 'INPUT') return false
3762
+ var type = (element.getAttribute('type') || 'text').toLowerCase()
3763
+ return type !== 'submit' && type !== 'button' && type !== 'reset'
3764
+ }
3765
+
3766
+ function linkInfo (element) {
3767
+ if (element.tagName !== 'A') return undefined
3768
+ var aria = element.getAttribute('aria-label')
3769
+ return {
3770
+ rel: element.getAttribute('rel') || undefined,
3771
+ text: (element.textContent || '').trim() || undefined,
3772
+ ariaLabel: aria || undefined,
3773
+ }
3774
+ }
3775
+
3776
+ document.addEventListener('click', function (event) {
3777
+ var target = event.target
3778
+ if (!(target instanceof Element)) return
3779
+ if (inShadowDom(target)) { reportUnsupportedOnce('shadow-dom'); return }
3780
+ // Clicking into a text field, a textarea or a select to focus it is not itself a recordable
3781
+ // action — the fill/select the person then does is what becomes a step, on blur/change.
3782
+ if (isTextEntry(target)) return
3783
+ var element = clickable(target)
3784
+ var id = markNode(element)
3785
+ report({ kind: 'click', nodeId: id, html: document.documentElement.outerHTML, link: linkInfo(element) })
3786
+ }, true)
3787
+
3788
+ function flushFill (target) {
3789
+ if (!(target instanceof HTMLInputElement) && !(target instanceof HTMLTextAreaElement)) return
3790
+ if (!focusValues.has(target)) return
3791
+ var before = focusValues.get(target)
3792
+ focusValues.delete(target)
3793
+ if (before === target.value) return
3794
+ if (inShadowDom(target)) { reportUnsupportedOnce('shadow-dom'); return }
3795
+ var id = markNode(target)
3796
+ report({
3797
+ kind: 'fill',
3798
+ nodeId: id,
3799
+ html: document.documentElement.outerHTML,
3800
+ value: target.value,
3801
+ field: { type: target.type || undefined, name: target.name || undefined, id: target.id || undefined },
3802
+ })
3803
+ }
3804
+
3805
+ document.addEventListener('focusin', function (event) {
3806
+ var target = event.target
3807
+ if (!(target instanceof HTMLInputElement) && !(target instanceof HTMLTextAreaElement)) return
3808
+ focusValues.set(target, target.value)
3809
+ }, true)
3810
+
3811
+ // Losing focus (Tab, clicking elsewhere) is the ordinary way a fill is reported.
3812
+ document.addEventListener('focusout', function (event) { flushFill(event.target) }, true)
3813
+
3814
+ document.addEventListener('change', function (event) {
3815
+ var target = event.target
3816
+ if (!(target instanceof HTMLSelectElement)) return
3817
+ if (inShadowDom(target)) { reportUnsupportedOnce('shadow-dom'); return }
3818
+ var id = markNode(target)
3819
+ report({
3820
+ kind: 'select',
3821
+ nodeId: id,
3822
+ html: document.documentElement.outerHTML,
3823
+ value: target.value,
3824
+ field: { type: 'select', name: target.name || undefined, id: target.id || undefined },
3825
+ })
3826
+ }, true)
3827
+
3828
+ document.addEventListener('keydown', function (event) {
3829
+ if (event.key !== 'Enter') return
3830
+ var target = event.target
3831
+ // Enter often submits the very field it was pressed in, which never blurs first — flush its
3832
+ // fill now, so typing a query then pressing Enter still reports the fill before the press.
3833
+ flushFill(target)
3834
+ var id = target instanceof Element && target !== document.body ? markNode(target) : undefined
3835
+ report({ kind: 'keypress', nodeId: id, html: id === undefined ? undefined : document.documentElement.outerHTML, key: 'Enter' })
3836
+ }, true)
3837
+
3838
+ var NEAR_BOTTOM_PX = 40
3839
+ window.addEventListener('scroll', function () {
3840
+ if (scrollTimer !== null) clearTimeout(scrollTimer)
3841
+ scrollTimer = setTimeout(function () {
3842
+ scrollTimer = null
3843
+ var atBottom = window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - NEAR_BOTTOM_PX
3844
+ if (atBottom) report({ kind: 'scroll', to: 'bottom' })
3845
+ }, 300)
3846
+ }, true)
3847
+ })()`;
1336
3848
  }
1337
- function stepsToNodes(steps, pathPrefix) {
1338
- return steps.map((step, index) => stepToNode(step, `${pathPrefix}.${index}`));
3849
+
3850
+ /** Field names that mark a value as a secret even outside a `type="password"` input (issue #95). */
3851
+ const SECRET_NAME = /token|secret|api[-_]?key/i;
3852
+ /**
3853
+ * Whether a recorded `fill`/`select` field's typed value is a secret: a
3854
+ * `type="password"` input, or a field the person explicitly marked (`marked`,
3855
+ * a future "mark as secret" UI action — not built by this phase, but the
3856
+ * flag is honoured wherever it comes from), or a name/id matching `token`,
3857
+ * `secret` or `api[-_]?key` (issue #95's exact wording).
3858
+ *
3859
+ * @param field - The field's type/name/id, as reported by `recorder-script.client.ts`.
3860
+ * @param marked - The person explicitly marked this field as secret; default `false`.
3861
+ * @returns Whether the field's value must become `{{env.NAME}}` instead of the real text.
3862
+ */
3863
+ function isSecretField(field, marked = false) {
3864
+ if (marked) return true;
3865
+ if (field.type === 'password') return true;
3866
+ const name = field.name ?? field.id ?? '';
3867
+ return name.length > 0 && SECRET_NAME.test(name);
1339
3868
  }
1340
- function stepToNode(step, path) {
1341
- if (!isRecord(step)) {
1342
- return {
1343
- kind: 'card',
1344
- path,
1345
- stepType: 'unknown',
1346
- sentence: [{
1347
- kind: 'word',
1348
- text: 'custom step'
1349
- }],
1350
- step: {},
1351
- custom: true
1352
- };
1353
- }
1354
- const stepType = typeof step.type === 'string' ? step.type : 'unknown';
1355
- const {
1356
- parts,
1357
- custom
1358
- } = cardSentence(step);
1359
- if (!isBracketStepType(stepType)) return {
1360
- kind: 'card',
1361
- path,
1362
- stepType,
1363
- sentence: parts,
1364
- step,
1365
- custom
1366
- };
1367
- const children = Array.isArray(step.steps) ? stepsToNodes(step.steps, `${path}.steps`) : [];
1368
- const elseChildren = stepType === 'if' && Array.isArray(step.else) ? stepsToNodes(step.else, `${path}.else`) : undefined;
1369
- return {
1370
- kind: 'bracket',
1371
- path,
1372
- stepType,
1373
- sentence: parts,
1374
- step,
1375
- children,
1376
- ...(elseChildren !== undefined && {
1377
- elseChildren
1378
- })
1379
- };
3869
+ /**
3870
+ * The `{{env.NAME}}` variable name a secret field suggests, from its own
3871
+ * name/id (falling back to `PASSWORD` for a bare `type="password"` field
3872
+ * with neither): `camelCase`/`kebab-case`/`snake_case` all become
3873
+ * `SCREAMING_SNAKE_CASE`, punctuation dropped, so `user-pass`, `userPass`
3874
+ * and `user_pass` all suggest `USER_PASS`.
3875
+ *
3876
+ * @param field - The field's type/name/id.
3877
+ * @returns A valid env-var-shaped name (letters, digits, underscores, not starting with a digit).
3878
+ */
3879
+ function secretEnvName(field) {
3880
+ const source = field.name ?? field.id ?? (field.type === 'password' ? 'password' : 'secret');
3881
+ const withBoundaries = source.replaceAll(/([a-z0-9])([A-Z])/g, '$1_$2');
3882
+ const screaming = withBoundaries.replaceAll(/[^A-Z0-9]+/gi, '_').toUpperCase().replace(/^_+/, '').replace(/_+$/, '');
3883
+ const named = screaming.length === 0 ? 'SECRET' : screaming;
3884
+ return /^\d/.test(named) ? `_${named}` : named;
1380
3885
  }
1381
- function isRecord(value) {
1382
- return typeof value === 'object' && value !== null && !Array.isArray(value);
3886
+ /**
3887
+ * The placeholder a secret field's value becomes in the recorded recipe:
3888
+ * never the real text, in the recipe JSON or anywhere the studio logs.
3889
+ *
3890
+ * @param field - The field's type/name/id.
3891
+ * @returns `{{env.NAME}}`.
3892
+ */
3893
+ function secretPlaceholder(field) {
3894
+ return `{{env.${secretEnvName(field)}}}`;
1383
3895
  }
1384
3896
 
1385
3897
  /**
1386
- * Rebuilds a recipe from its outline: `recipe`'s keys, in their original
1387
- * order, with `steps` (when the recipe had one) replaced by the outline's
1388
- * (possibly edited) step tree. A card's own raw `step` is used as-is; a
1389
- * bracket's `step` supplies every field but `steps`/`else`, which come from
1390
- * `children`/`elseChildren` instead, put back at the position `step` itself
1391
- * held that key at (or appended, for a bracket a `+` menu just added, whose
1392
- * `step` never had one) — this, together with `recipe-to-outline.mapper.ts`
1393
- * always keeping the step's whole original object, is what makes
1394
- * `recipe → outline → recipe` preserve every key and its order.
3898
+ * Whether a `Wait for <selector>` step belongs between the action that just
3899
+ * happened and the next one (issue #95): after a navigation or an XHR, the
3900
+ * recorder inserts a wait for the element the person interacts with next, so
3901
+ * a replayed recipe does not race the page's own load. Nothing to wait for
3902
+ * without a next selector (a page-level key press, a scroll) — and no
3903
+ * navigation or fetch either: a recording of a page that never moves should
3904
+ * not fill up with waits nobody asked for.
1395
3905
  *
1396
- * @param outline - An outline, from `recipe-to-outline.mapper.ts` or edited by the UI.
1397
- * @returns The recipe's plain JSON, ready for `save-recipe`.
3906
+ * @param context - What happened since the previous action.
3907
+ * @param nextSelector - The next action's own resolved selector, when it has one.
3908
+ * @returns The wait step to insert before the next action's own step, or `undefined`.
1398
3909
  */
1399
- function outlineToRecipe(outline) {
1400
- if (!('steps' in outline.recipe) && outline.steps.length === 0) return {
1401
- ...outline.recipe
1402
- };
3910
+ function insertWaitStep(context, nextSelector) {
3911
+ if (nextSelector === undefined || !context.navigated && !context.xhr) return undefined;
1403
3912
  return {
1404
- ...outline.recipe,
1405
- steps: nodesToSteps(outline.steps)
3913
+ type: 'wait',
3914
+ selector: nextSelector
1406
3915
  };
1407
3916
  }
1408
- function nodesToSteps(nodes) {
1409
- return nodes.map(node => nodeToStep(node));
1410
- }
1411
- function nodeToStep(node) {
1412
- if (!isBracket(node)) return node.step;
1413
- const keys = Object.keys(node.step);
1414
- const step = {};
1415
- for (const key of keys) {
1416
- if (key === 'steps') step.steps = nodesToSteps(node.children);else if (key === 'else' && node.stepType === 'if') step.else = nodesToSteps(node.elseChildren ?? []);else step[key] = node.step[key];
1417
- }
1418
- if (!keys.includes('steps')) step.steps = nodesToSteps(node.children);
1419
- if (node.stepType === 'if' && node.elseChildren !== undefined && !keys.includes('else')) step.else = nodesToSteps(node.elseChildren);
1420
- return step;
1421
- }
1422
3917
 
3918
+ /** The name Playwright's `context.exposeBinding` gives the function `recorder-script.client.ts` calls. */
3919
+ const REPORT_BINDING = '__opencraw_recorder_report__';
3920
+ /** The studio's own recorder profile: never a crawl's own profile, never the person's default browser (issue #95). */
3921
+ const RECORDER_PROFILE_NAME = 'studio-recorder';
3922
+ /** How long a click/key press is given to start a navigation before it counts as not having navigated. */
3923
+ const NAVIGATION_GRACE_MS = 600;
1423
3924
  /**
1424
- * Opens a recipe folder: every `.json`/`.jsonl` recipe directly inside it
1425
- * (core's own directory reading, so relative `file:` URLs resolve the same
1426
- * way a real run would), parsed and, for each output recipe found, bound to
1427
- * the input recipes that name it. Nothing throws for a bad recipe or a
1428
- * recipe that does not bind: every problem is reported on its file with the
1429
- * JSON path core already computes, exactly as `opencraw validate` prints it.
3925
+ * Opens the headed browser window a flow is recorded in (issue #95, studio
3926
+ * plan §3.1's last paragraph / phase 6): a Playwright persistent context, in
3927
+ * the studio's own profile (`BrowserProfiles`, `headless: false` — the only
3928
+ * precedent this codebase already has for a headed launch,
3929
+ * `packages/core/src/browser-session/browser-profile.store.ts`), with
3930
+ * `recorder-script.client.ts` installed on every page
3931
+ * (`context.addInitScript`) and reporting through a context-wide binding
3932
+ * (`context.exposeBinding`, so a page opened mid-recording — a link with
3933
+ * `target=_blank`, say — is covered too, not only the first page).
1430
3934
  *
1431
- * @param folder - The workspace folder.
1432
- * @returns Every recipe file found, each with its validation and binding issues.
1433
- * @throws Error when the folder does not exist or cannot be read.
3935
+ * Each reported action is resolved into a verified selector
3936
+ * (`selector-inference`, against the page's own current markup — issue
3937
+ * #95's "verified against the page"), turned into a step
3938
+ * (`action-to-step.mapper.ts`), and broadcast as an outline card built by
3939
+ * `scope-outline`'s own `recipeToOutline` — the same sentence/bracket logic
3940
+ * every other phase's cards already go through, never a second card format.
3941
+ * A secret field's value is replaced by its `{{env.NAME}}` placeholder
3942
+ * before it reaches a step, a card, or this function's return value: the
3943
+ * real value is discarded the instant `secret-field.policy.ts` flags the
3944
+ * field, never logged, never kept.
3945
+ *
3946
+ * Recording inside an iframe or shadow DOM is out of this phase's scope
3947
+ * (issue #95): `recorder-script.client.ts` detects both and reports them as
3948
+ * `unsupported` notes instead of guessing at a selector that would not
3949
+ * verify.
3950
+ *
3951
+ * @param options - The start URL, the profile directory, and browser launch settings.
3952
+ * @param callbacks - Where cards and notices go as they happen.
3953
+ * @returns A handle: `stop` closes the window and returns every step recorded.
1434
3954
  */
1435
- async function openWorkspace(folder) {
1436
- const documents = await readRecipeSource(folder);
1437
- const entries = documents.map(document => parseDocument(document));
1438
- const outputs = entries.filter(isParsed('output'));
1439
- const inputs = entries.filter(isParsed('input'));
1440
- for (const output of outputs) bindOutput(output, inputs);
1441
- for (const input of inputs) {
1442
- if (outputs.every(output => output.recipe.id !== input.recipe.output)) {
1443
- input.issues.push({
1444
- path: 'output',
1445
- message: `no output recipe "${input.recipe.output}" in this workspace`,
1446
- kind: 'binding'
3955
+ async function openRecorderSession(options, callbacks) {
3956
+ const profiles = new BrowserProfiles(options.profileDir, {
3957
+ ...options.browser,
3958
+ headless: false
3959
+ });
3960
+ const session = await profiles.open(RECORDER_PROFILE_NAME, {}, {});
3961
+ const context = session.context;
3962
+ const steps = [];
3963
+ let closed = false;
3964
+ let waitPending = {
3965
+ navigated: false,
3966
+ xhr: false
3967
+ };
3968
+ let xhrSeen = false;
3969
+ context.on('response', response => {
3970
+ const type = response.request().resourceType();
3971
+ if (type === 'xhr' || type === 'fetch') xhrSeen = true;
3972
+ });
3973
+ await context.exposeBinding(REPORT_BINDING, (_source, raw) => {
3974
+ void handleRaw(raw);
3975
+ });
3976
+ await context.addInitScript({
3977
+ content: recorderScript(REPORT_BINDING)
3978
+ });
3979
+ await session.page.goto(options.startUrl);
3980
+ async function handleRaw(raw) {
3981
+ if (closed) return;
3982
+ const parsed = rawActionSchema.safeParse(raw);
3983
+ if (!parsed.success) return;
3984
+ const action = parsed.data;
3985
+ if (action.kind === 'unsupported') {
3986
+ callbacks.onNote({
3987
+ kind: 'unsupported',
3988
+ message: `recording inside ${action.reason === 'iframe' ? 'an iframe' : 'shadow DOM'} is not supported; continue outside it, or add this step by hand`
1447
3989
  });
3990
+ return;
1448
3991
  }
3992
+ await handleAction(action);
1449
3993
  }
1450
- return {
1451
- folder,
1452
- recipes: entries.map(({
1453
- file,
1454
- kind,
1455
- id,
1456
- issues,
1457
- content
1458
- }) => ({
1459
- file,
1460
- kind,
1461
- id,
1462
- issues,
1463
- text: `${JSON.stringify(content, null, 2)}\n`,
1464
- // The Steps tab needs steps to render: an output/unknown file has none. recipeToOutline never
1465
- // throws, so this covers a file that fails validation too (its outline just has "custom" cards).
1466
- outline: kind === 'input' ? recipeToOutline(content) : undefined
1467
- }))
1468
- };
1469
- }
1470
- /** Binds one output recipe to the inputs that name it, filing every binding issue back on the recipe it names. */
1471
- function bindOutput(output, inputs) {
1472
- const matching = inputs.filter(input => input.recipe.output === output.recipe.id);
1473
- try {
1474
- bindRecipeSet(output.recipe, matching.map(input => input.recipe));
1475
- } catch (error) {
1476
- if (!(error instanceof RecipeBindingError)) throw error;
1477
- for (const issue of error.issues) {
1478
- const target = issue.recipeId === output.recipe.id ? output : matching.find(input => input.recipe.id === issue.recipeId);
1479
- target?.issues.push({
1480
- path: issue.path,
1481
- message: issue.message,
1482
- kind: 'binding'
3994
+ async function handleAction(action) {
3995
+ const page = session.page;
3996
+ const urlBefore = page.url();
3997
+ xhrSeen = false;
3998
+ const resolved = resolveAction(action);
3999
+ if (resolved === undefined) return;
4000
+ if (action.kind === 'click' || action.kind === 'keypress') resolved.navigated = await detectNavigation(page, urlBefore);
4001
+ if (resolved.field !== undefined && resolved.value !== undefined && isSecretField(resolved.field)) resolved.value = secretPlaceholder(resolved.field);
4002
+ const waitStep = insertWaitStep(waitPending, resolved.selector);
4003
+ if (waitStep !== undefined) pushStep(waitStep, false);
4004
+ waitPending = {
4005
+ navigated: resolved.navigated !== undefined,
4006
+ xhr: xhrSeen
4007
+ };
4008
+ const step = actionToStep(resolved);
4009
+ if (step === undefined) return;
4010
+ pushStep(step, resolved.field !== undefined && isSecretField(resolved.field));
4011
+ if (resolved.navigated !== undefined && action.kind === 'click' && looksLikeNextLink(resolved.link, resolved.selector)) {
4012
+ callbacks.onNote({
4013
+ kind: 'next-link',
4014
+ message: `this click looks like a "next" link — turn it into "For every page — click ${resolved.selector ?? ''}"?`
1483
4015
  });
1484
4016
  }
1485
4017
  }
1486
- }
1487
- function isParsed(kind) {
1488
- return entry => entry.kind === kind && entry.recipe !== undefined;
1489
- }
1490
- function parseDocument(document) {
1491
- const kind = document.content?.kind;
1492
- if (kind === 'output') return parseOne(document.source, document.content, 'output', parseOutputRecipe);
1493
- if (kind === 'input') return parseOne(document.source, document.content, 'input', parseInputRecipe);
4018
+ function pushStep(step, secret) {
4019
+ steps.push(step);
4020
+ const outline = recipeToOutline({
4021
+ steps
4022
+ });
4023
+ const node = outline.steps.at(-1);
4024
+ if (node !== undefined) callbacks.onCard(node, secret);
4025
+ }
1494
4026
  return {
1495
- file: document.source,
1496
- kind: 'unknown',
1497
- content: document.content,
1498
- issues: [{
1499
- path: 'kind',
1500
- message: '"kind" is missing or not "input"/"output"',
1501
- kind: 'validation'
1502
- }]
4027
+ page: session.page,
4028
+ stop: async () => {
4029
+ if (!closed) {
4030
+ closed = true;
4031
+ await session.close();
4032
+ }
4033
+ return {
4034
+ steps: steps
4035
+ };
4036
+ }
1503
4037
  };
1504
4038
  }
1505
- function parseOne(file, content, kind, parse) {
1506
- try {
1507
- const recipe = parse(content, file);
1508
- return {
1509
- file,
1510
- kind,
1511
- content,
1512
- id: recipe.id,
1513
- issues: [],
1514
- recipe
1515
- };
1516
- } catch (error) {
1517
- if (!(error instanceof RecipeValidationError)) throw error;
1518
- return {
1519
- file,
1520
- kind,
1521
- content,
1522
- issues: error.issues.map(issue => ({
1523
- path: issue.path === '' ? '(root)' : issue.path,
1524
- message: issue.message,
1525
- kind: 'validation'
1526
- }))
1527
- };
4039
+ /**
4040
+ * Resolves one raw action's `nodeId` (when it has one — `scroll` and a
4041
+ * page-level `keypress` do not) into a verified selector, the same way
4042
+ * `infer-selector.handler.ts` does for a pick: `pathToNode` off the
4043
+ * document the action itself carries (`action.html`, captured by
4044
+ * `recorder-script.client.ts` at the moment of the event — see
4045
+ * `recorded-action.contract.ts` for why that beats a later `page.content()`),
4046
+ * `candidatesFor`/`bestCandidate` off that path.
4047
+ *
4048
+ * @param action - The raw action, `unsupported` already ruled out by the caller.
4049
+ * @returns The resolved action (selector filled in where relevant), or `undefined` when no candidate verified — the action is dropped rather than recorded with a guessed, unverified selector.
4050
+ */
4051
+ function resolveAction(action) {
4052
+ if (action.kind === 'scroll') return {
4053
+ kind: 'scroll'
4054
+ };
4055
+ if (action.kind === 'keypress' && action.nodeId === undefined) return {
4056
+ kind: 'keypress',
4057
+ key: action.key
4058
+ };
4059
+ const nodeId = action.nodeId;
4060
+ const html = action.html;
4061
+ if (nodeId === undefined || html === undefined) return undefined;
4062
+ const path = pathToNode(html, nodeId);
4063
+ if (path === undefined || crossesShadowRoot(path)) return undefined;
4064
+ const best = bestCandidate(html, candidatesFor(path), nodeId);
4065
+ if (best === undefined) return undefined;
4066
+ switch (action.kind) {
4067
+ case 'click':
4068
+ {
4069
+ return {
4070
+ kind: 'click',
4071
+ selector: best.selector,
4072
+ link: action.link
4073
+ };
4074
+ }
4075
+ case 'fill':
4076
+ {
4077
+ return {
4078
+ kind: 'fill',
4079
+ selector: best.selector,
4080
+ value: action.value,
4081
+ field: action.field
4082
+ };
4083
+ }
4084
+ case 'select':
4085
+ {
4086
+ return {
4087
+ kind: 'select',
4088
+ selector: best.selector,
4089
+ value: action.value,
4090
+ field: action.field
4091
+ };
4092
+ }
4093
+ case 'keypress':
4094
+ {
4095
+ return {
4096
+ kind: 'keypress',
4097
+ selector: best.selector,
4098
+ key: action.key
4099
+ };
4100
+ }
1528
4101
  }
1529
4102
  }
1530
-
1531
4103
  /**
1532
- * Writes a recipe back to its file: pretty JSON, a 2-space indent, a
1533
- * trailing newline. Phase 0 takes the JSON tab's whole parsed object rather
1534
- * than a patch, so this is a full replacement, not a splice: loading a file
1535
- * the studio has not touched and saving it again keeps every key, value and
1536
- * key order (`JSON.parse` preserves insertion order, and `JSON.stringify`
1537
- * writes it back), but not necessarily the file's exact original formatting
1538
- * (alignment, how arrays wrap, a trailing comment). Byte-identical
1539
- * round-trip for a hand-formatted file is future work; see this file's test
1540
- * for what is guaranteed today.
4104
+ * Whether, and where to, the page navigated within `NAVIGATION_GRACE_MS` of
4105
+ * a click or key press: polled rather than driven (the navigation, if any,
4106
+ * was started by the person in the live window, not by this function), with
4107
+ * the main frame's own navigation response (for its status) picked up
4108
+ * alongside.
1541
4109
  *
1542
- * @param path - The recipe file to write.
1543
- * @param recipe - The whole recipe object.
4110
+ * @param page - The recorder's own page.
4111
+ * @param urlBefore - `page.url()` right before the action was reported.
4112
+ * @returns The page reached and its status, or `undefined` when the URL never changed.
1544
4113
  */
1545
- async function saveRecipe(path, recipe) {
1546
- await writeFile(path, `${JSON.stringify(recipe, null, 2)}\n`, 'utf8');
4114
+ async function detectNavigation(page, urlBefore) {
4115
+ let response;
4116
+ const onResponse = candidate => {
4117
+ if (candidate.request().isNavigationRequest() && candidate.frame() === page.mainFrame()) response = candidate;
4118
+ };
4119
+ page.on('response', onResponse);
4120
+ const deadline = Date.now() + NAVIGATION_GRACE_MS;
4121
+ try {
4122
+ while (Date.now() < deadline && page.url() === urlBefore) {
4123
+ await new Promise(resolve => setTimeout(resolve, 25));
4124
+ }
4125
+ } finally {
4126
+ page.off('response', onResponse);
4127
+ }
4128
+ if (page.url() === urlBefore) return undefined;
4129
+ try {
4130
+ await page.waitForLoadState('load', {
4131
+ timeout: NAVIGATION_GRACE_MS
4132
+ });
4133
+ } catch {
4134
+ // The load event never fired within the grace period; the page still moved, so report it anyway.
4135
+ }
4136
+ return {
4137
+ url: page.url(),
4138
+ status: response?.status()
4139
+ };
1547
4140
  }
1548
4141
 
1549
- /** @returns Fresh state for a new server instance: no workspace open, no run, no sockets, an empty snapshot cache, Playwright's own default browser. */
1550
- function createStudioState() {
4142
+ /**
4143
+ * Handles `start-recording`: opens the studio's own headed browser window
4144
+ * on the named recipe's start point (issue #95, phase 6) and streams
4145
+ * `recording-card`/`recording-note` events as the person drives it. One
4146
+ * recording at a time, like one sample run at a time: a second
4147
+ * `start-recording` stops whatever was already open first.
4148
+ *
4149
+ * @param state - The server state: `folder` must already be set, `recorderProfileDir` names the studio's own recorder profile (never a crawl's own, never the person's default browser).
4150
+ * @param command - The command.
4151
+ * @returns `{ started: true }` once the window is open.
4152
+ * @throws Error when no workspace is open, or the recipe cannot be found or loaded.
4153
+ */
4154
+ async function handleStartRecording(state, command) {
4155
+ if (state.folder === undefined) throw new Error('open a workspace first');
4156
+ await state.activeRecording?.stop();
4157
+ const {
4158
+ input
4159
+ } = await loadRecipePair(state.folder, command.recipeId);
4160
+ const point = input.start[0];
4161
+ if (point === undefined) throw new Error(`recipe "${command.recipeId}" has no start point`);
4162
+ const handle = await openRecorderSession({
4163
+ startUrl: point.url,
4164
+ profileDir: state.recorderProfileDir,
4165
+ browser: state.browser
4166
+ }, {
4167
+ onCard: (node, secret) => {
4168
+ broadcast(state, {
4169
+ type: 'recording-card',
4170
+ node,
4171
+ secret
4172
+ });
4173
+ },
4174
+ onNote: note => {
4175
+ broadcast(state, {
4176
+ type: 'recording-note',
4177
+ kind: note.kind,
4178
+ message: note.message
4179
+ });
4180
+ }
4181
+ });
4182
+ state.activeRecording = handle;
1551
4183
  return {
1552
- sockets: new Set(),
1553
- snapshots: createSnapshotCache()
4184
+ started: true
1554
4185
  };
1555
4186
  }
1556
4187
  /**
1557
- * Sends one event to every connected client. A socket that errors while
1558
- * sending (gone away without a clean close yet reaching us) is dropped
1559
- * rather than left to fail every later broadcast too.
4188
+ * Handles `stop-recording`: closes the recording window, if one is open,
4189
+ * and answers with every step recorded, in order — enough for the caller to
4190
+ * build "make this the login" (`session.bootstrap`) or "keep as steps"
4191
+ * without asking the server again. Also broadcasts `recording-stopped`, so
4192
+ * every connected client (not only the one that asked) knows the window
4193
+ * closed.
1560
4194
  *
1561
- * @param state - The server state holding the connected sockets.
1562
- * @param event - The event to send.
4195
+ * @param state - The server state holding the active recording, if any.
4196
+ * @param _command - The command (carries nothing beyond its type).
4197
+ * @returns Every step recorded, in order; empty when nothing was recording.
1563
4198
  */
1564
- function broadcast(state, event) {
1565
- const text = JSON.stringify(event);
1566
- for (const socket of state.sockets) {
1567
- try {
1568
- socket.send(text);
1569
- } catch {
1570
- state.sockets.delete(socket);
1571
- }
1572
- }
4199
+ async function handleStopRecording(state, _command) {
4200
+ const recording = state.activeRecording;
4201
+ state.activeRecording = undefined;
4202
+ if (recording === undefined) return {
4203
+ steps: []
4204
+ };
4205
+ const {
4206
+ steps
4207
+ } = await recording.stop();
4208
+ broadcast(state, {
4209
+ type: 'recording-stopped',
4210
+ steps
4211
+ });
4212
+ return {
4213
+ steps
4214
+ };
1573
4215
  }
1574
4216
 
1575
4217
  /**
1576
- * Handles `open-workspace`: reads the folder, remembers it as the server's
1577
- * current workspace (every later command that names a recipe resolves it
1578
- * against this folder), and tells every connected client the workspace changed.
4218
+ * Handles `responses-seen`: the JSON responses observed while a recipe's
4219
+ * start page rendered (`page-inspector`'s `responses-seen.use-case.ts`,
4220
+ * issue #93), for the Inspect panel's "responses seen" list — picking one
4221
+ * is the seam the UI uses to switch the recipe to api mode (issue #93's
4222
+ * acceptance; the JSON tree canvas that follows is phase 5's).
1579
4223
  *
1580
- * @param state - The server state to update.
4224
+ * @param state - The server state: `folder` must already be set.
1581
4225
  * @param command - The command.
1582
- * @returns The workspace's recipes and their issues.
4226
+ * @returns The observed responses.
4227
+ * @throws Error when no workspace is open, or the recipe cannot be found or loaded.
1583
4228
  */
1584
- async function handleOpenWorkspace(state, command) {
1585
- const view = await openWorkspace(command.folder);
1586
- state.folder = command.folder;
1587
- broadcast(state, {
1588
- type: 'workspace-changed'
1589
- });
1590
- return view;
4229
+ async function handleResponsesSeen(state, command) {
4230
+ if (state.folder === undefined) throw new Error('open a workspace first');
4231
+ const {
4232
+ input
4233
+ } = await loadRecipePair(state.folder, command.recipeId);
4234
+ const responses = await responsesSeen(input, command.path, state.browser);
4235
+ return {
4236
+ responses
4237
+ };
1591
4238
  }
1592
4239
 
1593
4240
  /**
@@ -1616,13 +4263,25 @@ async function handleRunSample(state, command) {
1616
4263
  type: 'record',
1617
4264
  recipeId: command.recipeId,
1618
4265
  key: record.key,
1619
- data: record.data
4266
+ data: record.data,
4267
+ scope: record.scope,
4268
+ mapping: record.mapping
4269
+ });
4270
+ },
4271
+ onRejected: rejected => {
4272
+ broadcast(state, {
4273
+ type: 'record-rejected',
4274
+ recipeId: command.recipeId,
4275
+ field: rejected.field,
4276
+ reason: rejected.reason,
4277
+ scope: rejected.scope
1620
4278
  });
1621
4279
  }
1622
4280
  }, state.browser);
1623
4281
  state.activeRun = handle;
1624
4282
  void handle.result.then(result => {
1625
4283
  if (state.activeRun === handle) state.activeRun = undefined;
4284
+ recordLastRun(state.lastRuns, command.recipeId, result);
1626
4285
  broadcast(state, {
1627
4286
  type: 'run-finished',
1628
4287
  recipeId: result.recipeId,
@@ -1738,6 +4397,25 @@ function serveStatic(root, pathname, response) {
1738
4397
  createReadStream(file).pipe(response);
1739
4398
  }
1740
4399
 
4400
+ /**
4401
+ * Handles `table-preview`: the PDF canvas's live preview of a `table`
4402
+ * extract's options (`document-view`'s `table-preview.use-case.ts`, studio
4403
+ * plan §3.4, issue #94's 5b), off the same cached snapshot `take-snapshot`
4404
+ * already captured — a pure computation over it, so this answers instantly
4405
+ * on every option change, no re-fetch or re-parse.
4406
+ *
4407
+ * @param state - The server state.
4408
+ * @param command - The command; `command.path` must already have a cached snapshot (call `take-snapshot` first).
4409
+ * @returns The matches, or `error` when an option's pattern does not compile.
4410
+ * @throws Error when no workspace is open, or `command.path` has no cached snapshot yet.
4411
+ */
4412
+ function handleTablePreview(state, command) {
4413
+ const snapshot = cachedSnapshot(state.snapshots, command.recipeId, command.path);
4414
+ if (snapshot === undefined) throw new Error(`no cached snapshot for "${command.recipeId}" at "${command.path}": call take-snapshot first`);
4415
+ if (!isPdfDocument(snapshot.body)) throw new Error(`"${command.recipeId}" at "${command.path}" is not a PDF snapshot: table-preview only reads a read PDF document`);
4416
+ return previewPdfTable(snapshot.body, command.options);
4417
+ }
4418
+
1741
4419
  /**
1742
4420
  * Handles `take-snapshot`: loads the named input recipe, captures its
1743
4421
  * snapshot at `command.path` (only `"start"` is captured for now — see
@@ -1753,13 +4431,24 @@ function serveStatic(root, pathname, response) {
1753
4431
  async function handleTakeSnapshot(state, command) {
1754
4432
  if (state.folder === undefined) throw new Error('open a workspace first');
1755
4433
  const cached = cachedSnapshot(state.snapshots, command.recipeId, command.path);
1756
- if (cached !== undefined) return cached;
4434
+ if (cached !== undefined) return snapshotView(cached);
1757
4435
  const {
1758
4436
  input
1759
4437
  } = await loadRecipePair(state.folder, command.recipeId);
1760
4438
  const snapshot = await takeSnapshot(input, command.path, state.browser);
1761
4439
  putSnapshot(state.snapshots, command.recipeId, command.path, snapshot);
1762
- return snapshot;
4440
+ return snapshotView(snapshot);
4441
+ }
4442
+ /** The wire view of a cached snapshot: `html`/`nodeCount`/`baseUrl`/`format`, never `rawHtml` or the parsed `body` (server-side only — see `SnapshotResult`'s own doc comments). */
4443
+ function snapshotView(snapshot) {
4444
+ return {
4445
+ html: snapshot.html,
4446
+ nodeCount: snapshot.nodeCount,
4447
+ baseUrl: snapshot.baseUrl,
4448
+ ...(snapshot.format !== undefined && {
4449
+ format: snapshot.format
4450
+ })
4451
+ };
1763
4452
  }
1764
4453
 
1765
4454
  /**
@@ -1925,6 +4614,7 @@ async function startStudioServer(options = {}) {
1925
4614
  const state = createStudioState();
1926
4615
  if (options.initialFolder !== undefined) state.folder = options.initialFolder;
1927
4616
  state.browser = options.browser;
4617
+ if (options.recorderProfileDir !== undefined) state.recorderProfileDir = options.recorderProfileDir;
1928
4618
  const server = createServer((request, response) => {
1929
4619
  void handleRequest(request, response, token, state, uiRoot);
1930
4620
  });
@@ -1946,6 +4636,7 @@ async function startStudioServer(options = {}) {
1946
4636
  }
1947
4637
  async function stop(server, state) {
1948
4638
  await state.activeRun?.stop();
4639
+ await state.activeRecording?.stop();
1949
4640
  server.closeAllConnections();
1950
4641
  await new Promise((resolve, reject) => {
1951
4642
  server.close(error => error === undefined ? resolve() : reject(error));
@@ -1966,9 +4657,41 @@ async function handleRequest(request, response, token, state, uiRoot) {
1966
4657
  await handleCommand(request, response, state);
1967
4658
  return;
1968
4659
  }
1969
- if (url.pathname === '/' && url.searchParams.get('token') !== null) response.setHeader('set-cookie', `${TOKEN_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/`);
4660
+ if (url.pathname === '/api/pdf-bytes' && request.method === 'GET') {
4661
+ await handlePdfBytesRequest(response, state, url.searchParams);
4662
+ return;
4663
+ }
4664
+ if (url.pathname === '/' && url.searchParams.has('token')) response.setHeader('set-cookie', `${TOKEN_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/`);
1970
4665
  serveStatic(uiRoot, url.pathname, response);
1971
4666
  }
4667
+ /**
4668
+ * `GET /api/pdf-bytes?recipeId=…&path=…`: the raw bytes of a cached PDF
4669
+ * snapshot (`pdf-bytes.handler.ts`, studio plan §3.4, issue #94's 5b) — the
4670
+ * PDF canvas's `pdf.js` fetches this URL directly, so it answers bytes, not
4671
+ * the JSON envelope every `/api/command` response uses.
4672
+ */
4673
+ async function handlePdfBytesRequest(response, state, query) {
4674
+ const recipeId = query.get('recipeId');
4675
+ const path = query.get('path');
4676
+ if (recipeId === null || path === null) {
4677
+ respondJson(response, 400, {
4678
+ error: '"recipeId" and "path" are required query params'
4679
+ });
4680
+ return;
4681
+ }
4682
+ try {
4683
+ const bytes = await handlePdfBytes(state, recipeId, path);
4684
+ response.writeHead(200, {
4685
+ 'content-type': 'application/pdf',
4686
+ 'content-length': bytes.byteLength
4687
+ });
4688
+ response.end(Buffer.from(bytes));
4689
+ } catch (error) {
4690
+ respondJson(response, 500, {
4691
+ error: error instanceof Error ? error.message : String(error)
4692
+ });
4693
+ }
4694
+ }
1972
4695
  async function handleCommand(request, response, state) {
1973
4696
  let command;
1974
4697
  try {
@@ -2033,6 +4756,54 @@ function dispatch(command, state) {
2033
4756
  {
2034
4757
  return Promise.resolve(handleInferSelector(state, command));
2035
4758
  }
4759
+ case 'explain-why':
4760
+ {
4761
+ return handleExplainWhy(state, command);
4762
+ }
4763
+ case 'inspect-page':
4764
+ {
4765
+ return Promise.resolve(handleInspectPage(state, command));
4766
+ }
4767
+ case 'responses-seen':
4768
+ {
4769
+ return handleResponsesSeen(state, command);
4770
+ }
4771
+ case 'document-tree':
4772
+ {
4773
+ return Promise.resolve(handleDocumentTree(state, command));
4774
+ }
4775
+ case 'pdf-view':
4776
+ {
4777
+ return Promise.resolve(handlePdfView(state, command));
4778
+ }
4779
+ case 'table-preview':
4780
+ {
4781
+ return Promise.resolve(handleTablePreview(state, command));
4782
+ }
4783
+ case 'grid-view':
4784
+ {
4785
+ return handleGridView(state, command);
4786
+ }
4787
+ case 'grid-preview':
4788
+ {
4789
+ return Promise.resolve(handleGridPreview(state, command));
4790
+ }
4791
+ case 'deck-view':
4792
+ {
4793
+ return Promise.resolve(handleDeckView(state, command));
4794
+ }
4795
+ case 'deck-preview':
4796
+ {
4797
+ return Promise.resolve(handleDeckPreview(state, command));
4798
+ }
4799
+ case 'start-recording':
4800
+ {
4801
+ return handleStartRecording(state, command);
4802
+ }
4803
+ case 'stop-recording':
4804
+ {
4805
+ return handleStopRecording(state);
4806
+ }
2036
4807
  }
2037
4808
  }
2038
4809
  function respondJson(response, status, body) {
@@ -2089,5 +4860,5 @@ async function main(argv) {
2089
4860
  process.on('SIGTERM', shutdown);
2090
4861
  }
2091
4862
 
2092
- export { fetchStartPage, fetchStartPageCommandSchema, fieldPickSchema, inferSelectorCommandSchema, inferSelectorViewSchema, loadRecipePair, main, openWorkspace, openWorkspaceCommandSchema, outlineBracketSchema, outlineCardSchema, outlineNodeSchema, outlineViewSchema, recipeIssueSchema, recipeListingSchema, recordEventSchema, runFinishedEventSchema, runSample, runSampleCommandSchema, sampleBudgetSchema, sampleRunRecordSchema, sampleRunViewSchema, saveOutlineCommandSchema, saveRecipe, saveRecipeCommandSchema, sentencePartSchema, snapshotViewSchema, startPageViewSchema, startStudioServer, stopRunCommandSchema, stoppedBySchema, studioCommandSchema, studioEventSchema, takeSnapshotCommandSchema, traceLineEventSchema, verifySelectorCommandSchema, verifySelectorViewSchema, workspaceChangedEventSchema, workspaceViewSchema };
4863
+ export { deckChartSeriesViewSchema, deckChartViewSchema, deckDocumentViewSchema, deckPreviewCommandSchema, deckPreviewOptionsSchema, deckShapeViewSchema, deckSlideViewSchema, deckTablePreviewMatchSchema, deckTablePreviewViewSchema, deckViewCommandSchema, documentTreeCommandSchema, documentTreeNodeViewSchema, documentTreeViewSchema, domTreeNodeViewSchema, explainWhyCommandSchema, fetchStartPage, fetchStartPageCommandSchema, fieldPickSchema, fieldTraceSchema, gridCellViewSchema, gridMergeViewSchema, gridPreviewCommandSchema, gridPreviewOptionsSchema, gridSheetViewSchema, gridTablePreviewMatchSchema, gridTablePreviewViewSchema, gridViewCommandSchema, inferSelectorCommandSchema, inferSelectorViewSchema, inspectPageCommandSchema, inspectViewSchema, loadRecipePair, main, missingWhyTargetSchema, observedResponseSchema, openWorkspace, openWorkspaceCommandSchema, outlineBracketSchema, outlineCardSchema, outlineNodeSchema, outlineViewSchema, pageDataFindingSchema, pdfCellViewSchema, pdfDocumentViewSchema, pdfPageViewSchema, pdfRowViewSchema, pdfViewCommandSchema, recipeIssueSchema, recipeListingSchema, recordEventSchema, recordRejectedEventSchema, recordingCardEventSchema, recordingNoteEventSchema, recordingStoppedEventSchema, rejectedWhyTargetSchema, responsesSeenCommandSchema, responsesSeenViewSchema, runFinishedEventSchema, runSample, runSampleCommandSchema, sampleBudgetSchema, sampleRunRecordSchema, sampleRunViewSchema, saveOutlineCommandSchema, saveRecipe, saveRecipeCommandSchema, sentencePartSchema, snapshotViewSchema, startPageViewSchema, startRecordingCommandSchema, startStudioServer, stopRecordingCommandSchema, stopRunCommandSchema, stoppedBySchema, studioCommandSchema, studioEventSchema, tablePreviewBandSchema, tablePreviewCommandSchema, tablePreviewMatchSchema, tablePreviewOptionsSchema, tablePreviewTableSchema, tablePreviewViewSchema, takeSnapshotCommandSchema, traceLineEventSchema, verifySelectorCommandSchema, verifySelectorViewSchema, whyTargetSchema, whyViewSchema, workbookDocumentViewSchema, workspaceChangedEventSchema, workspaceViewSchema };
2093
4864
  //# sourceMappingURL=index.esm.js.map