effect-browser 0.3.0-beta.15 → 0.3.0-beta.19

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 (96) hide show
  1. package/README.md +18 -6
  2. package/dist/{Browser-BBSySyjT.mjs → Browser-H63wiekr.mjs} +5 -5
  3. package/dist/Browser-H63wiekr.mjs.map +1 -0
  4. package/dist/Browser.d.mts +2 -2
  5. package/dist/Browser.mjs +1 -1
  6. package/dist/{BrowserEvent-BRu2juEz.d.mts → BrowserEvent-dq6FRT8w.d.mts} +9 -9
  7. package/dist/BrowserEvent.d.mts +1 -1
  8. package/dist/BrowserEvent.mjs +7 -12
  9. package/dist/BrowserEvent.mjs.map +1 -1
  10. package/dist/Cdp.mjs +2 -2
  11. package/dist/{Change-iMlcuzVD.d.mts → Change-DqING3tr.d.mts} +2 -2
  12. package/dist/Change.d.mts +1 -1
  13. package/dist/Chromium.mjs +2 -2
  14. package/dist/Expect.d.mts +1 -1
  15. package/dist/Expect.mjs +2 -2
  16. package/dist/Moment.d.mts +1 -1
  17. package/dist/Moment.mjs +3 -3
  18. package/dist/Moment.mjs.map +1 -1
  19. package/dist/Motion-DND6I-64.d.mts +40 -0
  20. package/dist/Motion.d.mts +2 -40
  21. package/dist/Motion.mjs +1 -2
  22. package/dist/Motion.mjs.map +1 -1
  23. package/dist/{Page-B5cjaUp5.mjs → Page-CfB6Zn1p.mjs} +7 -15
  24. package/dist/Page-CfB6Zn1p.mjs.map +1 -0
  25. package/dist/{Page-DL9eylp3.d.mts → Page-ku6j5t-v.d.mts} +23 -25
  26. package/dist/Page.d.mts +1 -1
  27. package/dist/Page.mjs +1 -1
  28. package/dist/{Plan-CXA2BW-G.mjs → Plan-C-d5QYS8.mjs} +6 -9
  29. package/dist/Plan-C-d5QYS8.mjs.map +1 -0
  30. package/dist/{Plan-6As6vcwT.d.mts → Plan-DJ4hEEna.d.mts} +3 -3
  31. package/dist/Plan.d.mts +1 -1
  32. package/dist/Plan.mjs +1 -1
  33. package/dist/Policy.d.mts +1 -1
  34. package/dist/Policy.mjs +1 -1
  35. package/dist/Presentation.d.mts +2 -2
  36. package/dist/Presentation.mjs +3 -2
  37. package/dist/Presentation.mjs.map +1 -1
  38. package/dist/Stage.d.mts +1 -1
  39. package/dist/Stage.mjs +8 -7
  40. package/dist/Stage.mjs.map +1 -1
  41. package/dist/{Supervisor-DaGlQVjv.mjs → Supervisor-2mPjIYYs.mjs} +44 -19
  42. package/dist/Supervisor-2mPjIYYs.mjs.map +1 -0
  43. package/dist/{Supervisor-5tZ47hR5.d.mts → Supervisor-mVPeEUp8.d.mts} +12 -5
  44. package/dist/Supervisor.d.mts +1 -1
  45. package/dist/Supervisor.mjs +1 -1
  46. package/dist/{context-BAjRj7Xo.mjs → context-w3NJPRLo.mjs} +81 -9
  47. package/dist/context-w3NJPRLo.mjs.map +1 -0
  48. package/dist/index.d.mts +6 -6
  49. package/dist/index.mjs +4 -4
  50. package/dist/match.inpage-xOe9LNO0.mjs.map +1 -1
  51. package/dist/{page-B92QnRfT.mjs → page-BKwfF6z-.mjs} +213 -218
  52. package/dist/page-BKwfF6z-.mjs.map +1 -0
  53. package/package.json +1 -1
  54. package/src/Browser.ts +1 -1
  55. package/src/BrowserEvent.ts +7 -12
  56. package/src/Moment.ts +3 -6
  57. package/src/Motion.ts +1 -2
  58. package/src/Page.ts +18 -20
  59. package/src/Plan.ts +1 -2
  60. package/src/Presentation.ts +2 -1
  61. package/src/Stage.ts +10 -17
  62. package/src/Supervisor.ts +9 -9
  63. package/src/internal/input/actions.ts +53 -97
  64. package/src/internal/input/dispatch.ts +25 -11
  65. package/src/internal/input/edit.inpage.ts +26 -11
  66. package/src/internal/input/evidence.inpage.ts +1 -21
  67. package/src/internal/input/guard.inpage.ts +9 -29
  68. package/src/internal/input/guard.ts +15 -10
  69. package/src/internal/input/perform.ts +14 -10
  70. package/src/internal/input/pointer.ts +3 -6
  71. package/src/internal/input/replies.ts +19 -8
  72. package/src/internal/input/settle.ts +3 -2
  73. package/src/internal/input/targets.inpage.ts +6 -13
  74. package/src/internal/input/targets.ts +13 -32
  75. package/src/internal/page/bridge.ts +2 -2
  76. package/src/internal/page/context.ts +15 -3
  77. package/src/internal/page/navigation.ts +3 -12
  78. package/src/internal/pictures/capture.ts +11 -4
  79. package/src/internal/pictures/clock.ts +8 -3
  80. package/src/internal/pictures/motion.ts +65 -0
  81. package/src/internal/pictures/pictures.ts +22 -17
  82. package/src/internal/pictures/utilsBundle.d.ts +9 -0
  83. package/src/internal/reading/controls.inpage.ts +8 -1
  84. package/src/internal/reading/match.inpage.ts +2 -1
  85. package/src/internal/reading/outline.inpage.ts +7 -4
  86. package/src/internal/reading/reading.ts +10 -25
  87. package/src/internal/reading/ready.ts +66 -14
  88. package/src/internal/supervisor/lifecycle.ts +56 -13
  89. package/src/internal/timeline/events.ts +5 -8
  90. package/src/internal/timeline/window.ts +3 -8
  91. package/dist/Browser-BBSySyjT.mjs.map +0 -1
  92. package/dist/Page-B5cjaUp5.mjs.map +0 -1
  93. package/dist/Plan-CXA2BW-G.mjs.map +0 -1
  94. package/dist/Supervisor-DaGlQVjv.mjs.map +0 -1
  95. package/dist/context-BAjRj7Xo.mjs.map +0 -1
  96. package/dist/page-B92QnRfT.mjs.map +0 -1
@@ -6,12 +6,13 @@
6
6
  */
7
7
  import { Duration, Effect } from "effect";
8
8
 
9
- import { InvalidRequest, NotActionable, StaleRef } from "../../BrowserError.ts";
9
+ import { NotActionable, StaleRef } from "../../BrowserError.ts";
10
10
  import type { ClickOptions, PressOptions, ScrollOptions, Target, TypeOptions } from "../../Page.ts";
11
11
  import { type Bridge, scriptCall } from "../page/bridge.ts";
12
- import { decodeWith, failWith, type PageContext } from "../page/context.ts";
12
+ import { decodeWith, failWith, type PageContext, refuse } from "../page/context.ts";
13
13
  import type { Viewport } from "../page/viewport.ts";
14
14
  import * as Dispatch from "./dispatch.ts";
15
+ import { buttonAt } from "./dispatch.ts";
15
16
  import * as Script from "./edit.inpage.ts";
16
17
  import * as Guard from "./guard.ts";
17
18
  import * as Keyboard from "./keyboard.ts";
@@ -22,8 +23,6 @@ import * as Settle from "./settle.ts";
22
23
  import { plain, type Style } from "./style.ts";
23
24
  import * as Targets from "./targets.ts";
24
25
 
25
- const buttonMask = { none: 0, left: 1, right: 2, middle: 4 } as const;
26
-
27
26
  const partsOf = (page: PageContext, bridge: Bridge, viewport: Viewport) => {
28
27
  const dispatch = Dispatch.make(page);
29
28
  const pointer = Pointer.make(page, dispatch, viewport);
@@ -32,12 +31,11 @@ const partsOf = (page: PageContext, bridge: Bridge, viewport: Viewport) => {
32
31
 
33
32
  return {
34
33
  page,
35
- bridge,
36
34
  ...settling,
37
35
  viewportFor: viewport.viewportFor,
38
36
  perform: Perform.make(page, dispatch),
39
37
  ...guard,
40
- ...Targets.make(bridge, viewport, guard, pointer, dispatch, settling),
38
+ ...Targets.make(viewport, guard, pointer, dispatch, settling),
41
39
  ...pointer,
42
40
  ...Keyboard.make(page, bridge, dispatch),
43
41
  sendMouse: dispatch.sendMouse,
@@ -53,25 +51,23 @@ const finite = (...values: ReadonlyArray<number | undefined>) =>
53
51
 
54
52
  const named = (target: Target) => (typeof target === "string" ? target : `${target.x},${target.y}`);
55
53
 
54
+ /** The ref a target names, if it names one rather than a point. */
55
+ const refOf = (target: Target | undefined) => (typeof target === "string" ? target : undefined);
56
+
56
57
  /** A click's options, once its counts are finite. */
57
58
  const clickable = (options: ClickOptions) =>
58
59
  finite(options.clickCount, options.holdMillis)
59
60
  ? Effect.void
60
- : failWith("click", new InvalidRequest({ detail: "clickCount and holdMillis must be finite" }));
61
+ : refuse("click", "clickCount and holdMillis must be finite");
61
62
 
62
63
  /** The keys a press sends, once they and its options are valid. */
63
64
  const pressed = (keys: string, options: PressOptions) => {
64
65
  const parts = Keys.parts(keys);
65
66
 
66
67
  if (!finite(options.times, options.holdMillis))
67
- return failWith("press", new InvalidRequest({ detail: "times and holdMillis must be finite" }));
68
+ return refuse("press", "times and holdMillis must be finite");
68
69
  if (parts === undefined)
69
- return failWith(
70
- "press",
71
- new InvalidRequest({
72
- detail: `"${keys}" is not a key; try Enter, Space, ArrowLeft or Control+A`,
73
- }),
74
- );
70
+ return refuse("press", `"${keys}" is not a key; try Enter, Space, ArrowLeft or Control+A`);
75
71
 
76
72
  return Effect.succeed(parts);
77
73
  };
@@ -93,11 +89,7 @@ const click = (input: Parts) => {
93
89
  },
94
90
  settings.actionTimeout,
95
91
  Effect.suspend(() => clickable(clickOptions)).pipe(
96
- Effect.andThen(
97
- preparePolicy("click", { target: typeof target === "string" ? target : undefined }, [
98
- target,
99
- ]),
100
- ),
92
+ Effect.andThen(preparePolicy("click", { target: refOf(target) }, [target])),
101
93
  ),
102
94
  (marks, approval) =>
103
95
  Effect.gen(function* () {
@@ -110,16 +102,19 @@ const click = (input: Parts) => {
110
102
  yield* marks.at(point);
111
103
  yield* marks.on(resolved);
112
104
  yield* moveTo("click", marks, point, resolved.cursor);
113
- if (approval !== undefined) yield* approval.check({ presses: [{ index: 0, ...point }] });
105
+ yield* Guard.check(approval, { presses: [{ index: 0, ...point }] });
114
106
  const before = mark();
115
107
 
116
108
  yield* marks.sent;
117
109
  for (let index = 1; index <= count; index++) {
118
110
  // An earlier click of a multi-click can replace the approved control under the
119
111
  // pointer. Under a guard, its handlers run before the next press is checked and sent.
120
- if (approval !== undefined && index > 1) {
121
- yield* flush("click", marks.input);
122
- yield* approval.check({ presses: [{ index: 0, ...point }] }).pipe(
112
+ if (index > 1)
113
+ yield* Guard.check(
114
+ approval,
115
+ { presses: [{ index: 0, ...point }] },
116
+ flush("click", marks.input),
117
+ ).pipe(
123
118
  Effect.catchIf(
124
119
  (error) => error.reason._tag === "StaleRef",
125
120
  () =>
@@ -132,27 +127,14 @@ const click = (input: Parts) => {
132
127
  ),
133
128
  ),
134
129
  );
135
- }
136
- yield* sendMouse("click", marks.input, {
137
- type: "mousePressed",
138
- ...point,
139
- button,
140
- buttons: buttonMask[button],
141
- clickCount: index,
142
- });
130
+ yield* sendMouse("click", marks.input, buttonAt("mousePressed", point, button, index));
143
131
 
144
132
  // A hold the caller asks for is the click's own; the style's is presentation.
145
133
  const hold = clickOptions.holdMillis ?? (yield* marks.style.hold);
146
134
 
147
135
  if (clickOptions.holdMillis === undefined) yield* marks.present(hold);
148
136
  if (hold > 0) yield* Effect.sleep(Duration.millis(hold));
149
- yield* sendMouse("click", marks.input, {
150
- type: "mouseReleased",
151
- ...point,
152
- button,
153
- buttons: 0,
154
- clickCount: index,
155
- });
137
+ yield* sendMouse("click", marks.input, buttonAt("mouseReleased", point, button, index));
156
138
  }
157
139
  yield* flush("click", marks.input);
158
140
  yield* settle("click", before);
@@ -172,7 +154,7 @@ const hover = (input: Parts) => {
172
154
  "hover",
173
155
  { target: named(target) },
174
156
  settings.actionTimeout,
175
- preparePolicy("hover", { target: typeof target === "string" ? target : undefined }, [target]),
157
+ preparePolicy("hover", { target: refOf(target) }, [target]),
176
158
  (marks, approval) =>
177
159
  Effect.gen(function* () {
178
160
  const resolved = yield* targetFor("hover", target, approval, marks);
@@ -236,29 +218,16 @@ const drag = (input: Parts) => {
236
218
  yield* moveTo("drag", marks, start.point, start.cursor);
237
219
  // Both ends are checked before the button goes down: once it is down, a release cannot
238
220
  // be withheld, and a dragged element under the pointer would hide the drop target.
239
- if (approval !== undefined)
240
- yield* approval.check({
241
- presses: [
242
- { index: 0, ...start.point },
243
- { index: 1, ...end.point },
244
- ],
245
- });
246
- yield* marks.sent;
247
- yield* sendMouse("drag", marks.input, {
248
- type: "mousePressed",
249
- ...start.point,
250
- button: "left",
251
- buttons: 1,
252
- clickCount: 1,
221
+ yield* Guard.check(approval, {
222
+ presses: [
223
+ { index: 0, ...start.point },
224
+ { index: 1, ...end.point },
225
+ ],
253
226
  });
227
+ yield* marks.sent;
228
+ yield* sendMouse("drag", marks.input, buttonAt("mousePressed", start.point));
254
229
  yield* moveTo("drag", marks, end.point, end.cursor, "drag");
255
- yield* sendMouse("drag", marks.input, {
256
- type: "mouseReleased",
257
- ...end.point,
258
- button: "left",
259
- buttons: 0,
260
- clickCount: 1,
261
- });
230
+ yield* sendMouse("drag", marks.input, buttonAt("mouseReleased", end.point));
262
231
  yield* flush("drag", marks.input);
263
232
  }),
264
233
  style,
@@ -267,7 +236,7 @@ const drag = (input: Parts) => {
267
236
 
268
237
  const typeText = (input: Parts) => {
269
238
  const { settings, now } = input.page;
270
- const { perform, preparePolicy, mutate, targetFor, moveTo, sendMouse, flush, bridge } = input;
239
+ const { perform, preparePolicy, mutate, targetFor, moveTo, sendMouse, flush, evaluate } = input;
271
240
  const { currentDocument, sameDocument, keyStroke, typeEvent, mark, settle } = input;
272
241
 
273
242
  return (text: string, typeOptions: TypeOptions = {}, style: Style = plain) =>
@@ -292,20 +261,15 @@ const typeText = (input: Parts) => {
292
261
  const into = typeOptions.into;
293
262
 
294
263
  if (into !== undefined && !/^e\d+$/.test(into))
295
- return yield* failWith(
296
- "type",
297
- new InvalidRequest({ detail: `"${into}" is not a ref; refs look like e12` }),
298
- );
264
+ return yield* refuse("type", `"${into}" is not a ref; refs look like e12`);
299
265
 
300
266
  // A typed space or letter can press a focused button or change a control. Refuse
301
267
  // those before any input, on every path.
302
268
  const asked = scriptCall("typeable", into ?? null, typeOptions.secret === true);
303
269
 
304
- const typeable = yield* (
305
- approval === undefined
306
- ? bridge.evaluate("type", asked)
307
- : mutate("type", asked, approval)
308
- ).pipe(Effect.flatMap(decodeWith("type", Script.TypeableResultSchema)));
270
+ const typeable = yield* evaluate("type", asked, approval).pipe(
271
+ Effect.flatMap(decodeWith("type", Script.TypeableResultSchema)),
272
+ );
309
273
 
310
274
  if ("error" in typeable)
311
275
  return yield* failWith(
@@ -327,26 +291,27 @@ const typeText = (input: Parts) => {
327
291
  // Viewers see the pointer click into the field.
328
292
  if (marks.style.shown) {
329
293
  yield* moveTo("type", marks, target.point, target.cursor);
330
- if (approval !== undefined)
331
- yield* approval.check({ presses: [{ index: 0, ...target.point }] });
294
+ yield* Guard.check(approval, { presses: [{ index: 0, ...target.point }] });
332
295
  yield* marks.sent;
333
296
  for (const type of ["mousePressed", "mouseReleased"] as const)
334
- yield* sendMouse("type", marks.input, {
335
- type,
336
- ...target.point,
337
- button: "left",
338
- buttons: type === "mousePressed" ? 1 : 0,
339
- clickCount: 1,
340
- });
297
+ yield* sendMouse("type", marks.input, buttonAt(type, target.point));
341
298
  yield* flush("type", marks.input);
342
299
  }
343
300
 
344
301
  // Focusing can run page handlers, including navigation, before the script returns.
345
302
  yield* marks.sent;
346
303
 
304
+ // A page may answer the press with another field of the same name in the field's
305
+ // place, focused, as a typeahead does: the keys follow it, unless a guard approved
306
+ // this field and no other, or the field is secret, as its replacement may not be.
307
+ const follow =
308
+ marks.style.shown && approval === undefined && !secret && target.name !== ""
309
+ ? target.name
310
+ : null;
311
+
347
312
  const focused = yield* mutate(
348
313
  "type",
349
- scriptCall("focus", into, replace),
314
+ scriptCall("focus", into, replace, follow),
350
315
  approval,
351
316
  ).pipe(Effect.flatMap(decodeWith("type", Script.FocusResultSchema)));
352
317
 
@@ -354,7 +319,7 @@ const typeText = (input: Parts) => {
354
319
  secret ||= focused.secret;
355
320
  }
356
321
  if (!secret) yield* marks.reveal;
357
- if (approval !== undefined) yield* approval.check({ focused: true });
322
+ yield* Guard.check(approval, { focused: true });
358
323
  yield* marks.sent;
359
324
 
360
325
  // A key's handlers can move focus, and later keys then reach a control nobody approved.
@@ -362,10 +327,7 @@ const typeText = (input: Parts) => {
362
327
  // handler can split, and keys typed one at a time check that the field still has focus,
363
328
  // once the keys before are answered, before each space, which could press a button, and
364
329
  // after the last key.
365
- const focusHeld =
366
- approval === undefined
367
- ? Effect.void
368
- : flush("type", marks.input).pipe(Effect.andThen(approval.check({ focused: true })));
330
+ const focusHeld = Guard.check(approval, { focused: true }, flush("type", marks.input));
369
331
 
370
332
  if (text === "" && replace && into !== undefined) {
371
333
  yield* sameDocument("type", since);
@@ -396,7 +358,7 @@ const typeText = (input: Parts) => {
396
358
  yield* flush("type", marks.input);
397
359
  if (typeOptions.submit === true) {
398
360
  // Typing can move focus or change the form; Enter goes only to the approved field.
399
- if (approval !== undefined) yield* approval.check({ focused: true });
361
+ yield* Guard.check(approval, { focused: true });
400
362
  yield* sameDocument("type", since);
401
363
  const before = mark();
402
364
 
@@ -447,7 +409,7 @@ const press = (input: Parts) => {
447
409
  );
448
410
 
449
411
  if ("error" in focused) return yield* Guard.editFailure("press", on, focused);
450
- if (approval !== undefined) yield* approval.check({ focused: true });
412
+ yield* Guard.check(approval, { focused: true });
451
413
  }
452
414
  const before = mark();
453
415
  let due = now();
@@ -456,10 +418,8 @@ const press = (input: Parts) => {
456
418
  for (let index = 0; index < times; index++) {
457
419
  // A repeated key stays in the document it began in. Under a guard, a repeated Enter
458
420
  // or Space must also reach the approved element after the previous press settled.
459
- if (index > 0 && approval !== undefined && activates) {
460
- yield* flush("press", marks.input);
461
- yield* approval.check({ focused: true });
462
- }
421
+ if (index > 0 && activates)
422
+ yield* Guard.check(approval, { focused: true }, flush("press", marks.input));
463
423
  yield* sameDocument("press", since);
464
424
  yield* keyStroke("press", marks.input, parts, hold);
465
425
  if (index + 1 < times) {
@@ -488,7 +448,7 @@ const scroll = (input: Parts) => {
488
448
  const valid =
489
449
  Number.isFinite(scrollOptions.dx ?? 0) && Number.isFinite(scrollOptions.dy ?? 0)
490
450
  ? Effect.void
491
- : failWith("scroll", new InvalidRequest({ detail: "scroll deltas must be finite" }));
451
+ : refuse("scroll", "scroll deltas must be finite");
492
452
 
493
453
  return perform(
494
454
  "scroll",
@@ -500,11 +460,7 @@ const scroll = (input: Parts) => {
500
460
  valid.pipe(
501
461
  Effect.andThen(
502
462
  // Scrolling the page has no target: whatever sits mid-viewport is not what is scrolled.
503
- preparePolicy(
504
- "scroll",
505
- { target: typeof target === "string" ? target : undefined },
506
- target === undefined ? [] : [target],
507
- ),
463
+ preparePolicy("scroll", { target: refOf(target) }, target === undefined ? [] : [target]),
508
464
  ),
509
465
  ),
510
466
  (marks, approval) =>
@@ -12,20 +12,35 @@ import {
12
12
  TextInserted,
13
13
  WheelScrolled,
14
14
  } from "../../BrowserEvent.ts";
15
+ import type { Point } from "../../Motion.ts";
15
16
  import { type PageContext, reasonOf, undispatched } from "../page/context.ts";
16
17
  import * as BrowserClock from "../pictures/clock.ts";
17
18
  import type * as Replies from "./replies.ts";
18
19
 
19
- export type MouseEvent = {
20
+ export interface MouseEvent extends Point {
20
21
  readonly type: "mouseMoved" | "mousePressed" | "mouseReleased" | "mouseWheel";
21
- readonly x: number;
22
- readonly y: number;
23
22
  readonly button?: "none" | "left" | "right" | "middle";
24
23
  readonly buttons?: number;
25
24
  readonly clickCount?: number;
26
25
  readonly deltaX?: number;
27
26
  readonly deltaY?: number;
28
- };
27
+ }
28
+
29
+ const buttonMask = { none: 0, left: 1, right: 2, middle: 4 } as const;
30
+
31
+ /** A mouse button going down or up at a point, as the `clickCount`th click. */
32
+ export const buttonAt = (
33
+ type: "mousePressed" | "mouseReleased",
34
+ point: Point,
35
+ button: keyof typeof buttonMask = "left",
36
+ clickCount = 1,
37
+ ): MouseEvent => ({
38
+ type,
39
+ ...point,
40
+ button,
41
+ buttons: type === "mousePressed" ? buttonMask[button] : 0,
42
+ clickCount,
43
+ });
29
44
 
30
45
  export const make = (page: PageContext) => {
31
46
  const { id, pointer, publish, now, noteInput, closedBy } = page;
@@ -99,13 +114,12 @@ export const make = (page: PageContext) => {
99
114
  () => dispatchMouse(event, estimate, submitted),
100
115
  () =>
101
116
  dispatchMouse(
102
- {
103
- type: "mouseReleased",
104
- ...Option.getOrElse(MutableRef.get(pointer), () => ({ x: event.x, y: event.y })),
105
- button: event.button ?? "left",
106
- buttons: 0,
107
- clickCount: event.clickCount ?? 1,
108
- },
117
+ buttonAt(
118
+ "mouseReleased",
119
+ Option.getOrElse(MutableRef.get(pointer), () => ({ x: event.x, y: event.y })),
120
+ event.button,
121
+ event.clickCount,
122
+ ),
109
123
  estimate,
110
124
  ),
111
125
  );
@@ -10,28 +10,23 @@ import type { ContextReader } from "../reading/context.inpage.ts";
10
10
  import type { Names } from "../reading/names.inpage.ts";
11
11
  import type { Guard } from "./guard.inpage.ts";
12
12
 
13
- export type EditResult =
14
- | { readonly ok: true; readonly detail: string }
15
- | { readonly error: string; readonly stale?: boolean };
13
+ export type EditResult = typeof EditResultSchema.Encoded;
16
14
 
17
15
  /** A focused field, and whether it is secret. */
18
- export type FocusResult =
19
- | { readonly ok: true; readonly detail: string; readonly secret: boolean }
20
- | { readonly error: string; readonly stale?: boolean };
16
+ export type FocusResult = typeof FocusResultSchema.Encoded;
21
17
 
22
18
  /**
23
19
  * Whether text may be typed, and whether its field is secret. Typing into focus also names the
24
20
  * focused field, when it is one, so the action records what it typed into.
25
21
  */
26
- export type TypeableResult =
27
- | { readonly ok: true; readonly secret: boolean; readonly subject?: typeof Subject.Encoded }
28
- | { readonly error: "stale" | "untypeable"; readonly detail: string };
22
+ export type TypeableResult = typeof TypeableResultSchema.Encoded;
29
23
 
30
24
  export const edit = (names: Names, guard: Guard, placing: ContextReader) => {
31
25
  const {
32
26
  activeElement,
33
27
  clean,
34
28
  describe,
29
+ inCurrentDocument,
35
30
  isDisabled,
36
31
  isHtml,
37
32
  isInput,
@@ -78,8 +73,28 @@ export const edit = (names: Names, guard: Guard, placing: ContextReader) => {
78
73
  };
79
74
  };
80
75
 
81
- const focus = (ref: string, replace: boolean): FocusResult => {
82
- const element = lookup(ref);
76
+ /**
77
+ * The focused field, when it is a text field named `name`: one a page put in place of a field
78
+ * just pressed and gave focus to, as a typeahead does.
79
+ */
80
+ const replacement = (name: string): Element | undefined => {
81
+ const active = activeElement();
82
+
83
+ return active !== null &&
84
+ textEntry(active) &&
85
+ inCurrentDocument(active) &&
86
+ nameOf(active, roleOf(active)) === name
87
+ ? active
88
+ : undefined;
89
+ };
90
+
91
+ /**
92
+ * Focus a field to type into. `follow` is the field's name when the action pressed it first: a
93
+ * page may answer the press with another field of that name in its place, focused, and the keys
94
+ * follow focus there, as a person's would.
95
+ */
96
+ const focus = (ref: string, replace: boolean, follow: string | null): FocusResult => {
97
+ const element = lookup(ref) ?? (follow === null ? undefined : replacement(follow));
83
98
 
84
99
  if (element === undefined) return { error: `${ref} is not on the page any more`, stale: true };
85
100
  if (isDisabled(element)) return { error: `${ref} is disabled` };
@@ -7,28 +7,8 @@ import { Schema } from "effect";
7
7
  import type { ContextReader } from "../reading/context.inpage.ts";
8
8
  import type { Names } from "../reading/names.inpage.ts";
9
9
 
10
- export interface FormField {
11
- readonly type: string;
12
- readonly name: string;
13
- readonly autocomplete?: string | undefined;
14
- readonly filled: boolean;
15
- }
16
-
17
10
  /** Page text about the first target. It never includes what a field holds. */
18
- export interface Evidence {
19
- readonly title: string;
20
- readonly description?: string | undefined;
21
- readonly dialog?: string | undefined;
22
- readonly heading?: string | undefined;
23
- readonly nearby?: string | undefined;
24
- readonly form?:
25
- | {
26
- readonly method: string;
27
- readonly action: string;
28
- readonly fields: ReadonlyArray<FormField>;
29
- }
30
- | undefined;
31
- }
11
+ export type Evidence = typeof EvidenceSchema.Encoded;
32
12
 
33
13
  type Field = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
34
14
 
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { Schema } from "effect";
7
7
 
8
+ import type { Point } from "../../Motion.ts";
8
9
  import type { Names } from "../reading/names.inpage.ts";
9
10
  import { type Evidence, type EvidenceReader, EvidenceSchema } from "./evidence.inpage.ts";
10
11
  import type { Targets } from "./targets.inpage.ts";
@@ -23,48 +24,26 @@ export type Fact = typeof Fact.Type;
23
24
 
24
25
  export interface InputPlan {
25
26
  readonly action: string;
26
- readonly targets: ReadonlyArray<string | { readonly x: number; readonly y: number } | null>;
27
+ readonly targets: ReadonlyArray<string | Point | null>;
27
28
  readonly submit: boolean;
28
29
  readonly keys: string | null;
29
30
  readonly destination: string | null;
30
31
  }
31
32
 
32
- export interface InspectedTarget {
33
- readonly ref: string;
34
- readonly element: string;
35
- readonly role: string | null;
36
- readonly name: string;
37
- readonly cursor: string;
38
- readonly href?: string | undefined;
39
- /** A field the DOM marks as holding a password, a one-time code or a card's details. */
40
- readonly secret: boolean;
41
- readonly fingerprint: string;
42
- }
33
+ export type InspectedTarget = NonNullable<PreparedInput["targets"][number]>;
43
34
 
44
- export interface PreparedInput {
45
- readonly url: string;
46
- readonly targets: ReadonlyArray<InspectedTarget | null>;
47
- readonly facts: ReadonlyArray<Fact>;
48
- readonly destination?: string | undefined;
49
- readonly evidence: Evidence;
50
- }
35
+ export type PreparedInput = Exclude<PreparedInputResult, InputFailure>;
51
36
 
52
37
  /** A refusal; a stale target is named by its index in the plan. */
53
- export interface InputFailure {
54
- readonly error: string;
55
- readonly detail: string;
56
- readonly index?: number | undefined;
57
- }
38
+ export type InputFailure = typeof InputPreparationError.Encoded;
58
39
 
59
- export type PreparedInputResult = PreparedInput | InputFailure;
40
+ export type PreparedInputResult = typeof PreparedInputResultSchema.Encoded;
60
41
 
61
- export type ValidatedInputResult = { readonly ok: true } | InputFailure;
42
+ export type ValidatedInputResult = typeof ValidatedInputResultSchema.Encoded;
62
43
 
63
44
  /** A point where a target, by its index in the plan, is about to receive a press. */
64
- export interface Press {
45
+ export interface Press extends Point {
65
46
  readonly index: number;
66
- readonly x: number;
67
- readonly y: number;
68
47
  }
69
48
 
70
49
  export interface ValidationOptions {
@@ -587,6 +566,7 @@ export const PreparedInputResultSchema = Schema.Union([
587
566
  name: Schema.String,
588
567
  cursor: Schema.String,
589
568
  href: Schema.optional(Schema.String),
569
+ /** A field the DOM marks as holding a password, a one-time code or a card's details. */
590
570
  secret: Schema.Boolean,
591
571
  fingerprint: Schema.String,
592
572
  }),
@@ -8,7 +8,7 @@ import { Effect } from "effect";
8
8
  import { type BrowserError, InvalidRequest, NotActionable, StaleRef } from "../../BrowserError.ts";
9
9
  import { InputRequest, redacted } from "../../Page.ts";
10
10
  import { type Bridge, scriptCall } from "../page/bridge.ts";
11
- import { contextGone, decodeWith, failWith, type PageContext } from "../page/context.ts";
11
+ import { contextGone, decodeWith, failWith, type PageContext, refuse } from "../page/context.ts";
12
12
  import * as Url from "../page/url.ts";
13
13
  import * as Script from "./guard.inpage.ts";
14
14
 
@@ -22,6 +22,13 @@ export interface PolicyPlan {
22
22
  readonly validate: Effect.Effect<Approval, BrowserError>;
23
23
  }
24
24
 
25
+ /** Recheck an approval, after any pending input it must see; neither runs without a guard. */
26
+ export const check = (
27
+ approval: Approval | undefined,
28
+ options: Script.ValidationOptions = {},
29
+ before: Effect.Effect<void, BrowserError> = Effect.void,
30
+ ) => (approval === undefined ? Effect.void : before.pipe(Effect.andThen(approval.check(options))));
31
+
25
32
  // A ref that names nothing is stale on every path, named by the target that failed.
26
33
  export const inputFailure = (
27
34
  operation: string,
@@ -67,19 +74,13 @@ export const make = (page: PageContext, bridge: Bridge) => {
67
74
  Effect.gen(function* () {
68
75
  for (const target of targets) {
69
76
  if (typeof target === "string" && !/^e\d+$/.test(target))
70
- return yield* failWith(
71
- action,
72
- new InvalidRequest({ detail: `"${target}" is not a ref; refs look like e12` }),
73
- );
77
+ return yield* refuse(action, `"${target}" is not a ref; refs look like e12`);
74
78
  if (
75
79
  target !== null &&
76
80
  typeof target !== "string" &&
77
81
  (!Number.isFinite(target.x) || !Number.isFinite(target.y))
78
82
  )
79
- return yield* failWith(
80
- action,
81
- new InvalidRequest({ detail: "point coordinates must be finite" }),
82
- );
83
+ return yield* refuse(action, "point coordinates must be finite");
83
84
  }
84
85
 
85
86
  const input: Script.InputPlan = {
@@ -178,7 +179,11 @@ export const make = (page: PageContext, bridge: Bridge) => {
178
179
  ),
179
180
  );
180
181
 
181
- return { preparePolicy, mutate };
182
+ /** A read may follow the current document only when no approval binds it to one. */
183
+ const evaluate = (operation: string, call: string, approval: Approval | undefined) =>
184
+ approval === undefined ? bridge.evaluate(operation, call) : mutate(operation, call, approval);
185
+
186
+ return { preparePolicy, mutate, evaluate };
182
187
  };
183
188
 
184
189
  export type Guard = ReturnType<typeof make>;
@@ -137,19 +137,14 @@ const useRun = <A>(
137
137
  name: string,
138
138
  run: Replies.Run,
139
139
  action: Effect.Effect<A, BrowserError>,
140
+ canStop: Effect.Effect<boolean>,
140
141
  ) =>
141
142
  action.pipe(
142
- Effect.matchEffect({
143
- onFailure: (error) =>
144
- run.close.pipe(
145
- Effect.andThen(sender.flush(name, run).pipe(Effect.ignore)),
146
- Effect.andThen(Effect.fail(error)),
147
- ),
148
- onSuccess: (value) =>
149
- run.close.pipe(Effect.andThen(sender.flush(name, run)), Effect.as(value)),
150
- }),
143
+ Effect.tapError(() => run.close.pipe(Effect.andThen(sender.flush(name, run)), Effect.ignore)),
144
+ Effect.tap(() => run.close.pipe(Effect.andThen(sender.flush(name, run)))),
151
145
  // Cleanup submits missing releases; the page retains replies after caller interruption.
152
146
  Effect.ensuring(run.close.pipe(Effect.andThen(page.openings.clear))),
147
+ Effect.onError(() => run.recover(page.stopLoading, page.scope).pipe(Effect.when(canStop))),
153
148
  Effect.provideService(Replies.Current, run),
154
149
  );
155
150
 
@@ -224,7 +219,16 @@ export const make = (page: PageContext, sender: Dispatch) => {
224
219
  if (estimate !== undefined) inputClocks.set(current, estimate);
225
220
  }),
226
221
  ),
227
- Effect.flatMap((run) => useRun(page, sender, name, run, action(run))),
222
+ Effect.flatMap((run) =>
223
+ useRun(
224
+ page,
225
+ sender,
226
+ name,
227
+ run,
228
+ action(run),
229
+ info.input === false ? Effect.succeed(false) : Ref.get(sent),
230
+ ),
231
+ ),
228
232
  );
229
233
 
230
234
  // How long its turn and the page's replies kept the action waiting, once it had both.