effect-browser 0.3.0-beta.16 → 0.3.0-beta.20

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 (70) hide show
  1. package/README.md +14 -7
  2. package/dist/{Browser-DRPthkNO.mjs → Browser-BwCldIff.mjs} +6 -5
  3. package/dist/Browser-BwCldIff.mjs.map +1 -0
  4. package/dist/Browser.d.mts +1 -1
  5. package/dist/Browser.mjs +1 -1
  6. package/dist/Cdp.mjs +2 -2
  7. package/dist/Chromium.mjs +2 -2
  8. package/dist/Expect.d.mts +1 -1
  9. package/dist/Expect.mjs +2 -2
  10. package/dist/Moment.d.mts +1 -1
  11. package/dist/Moment.mjs +3 -3
  12. package/dist/Moment.mjs.map +1 -1
  13. package/dist/{Page-C_VkBVwI.mjs → Page-CfB6Zn1p.mjs} +3 -7
  14. package/dist/Page-CfB6Zn1p.mjs.map +1 -0
  15. package/dist/{Page-jHmSJeOS.d.mts → Page-ku6j5t-v.d.mts} +18 -13
  16. package/dist/Page.d.mts +1 -1
  17. package/dist/Page.mjs +1 -1
  18. package/dist/{Plan-X-JkACAB.mjs → Plan-C-d5QYS8.mjs} +3 -3
  19. package/dist/{Plan-X-JkACAB.mjs.map → Plan-C-d5QYS8.mjs.map} +1 -1
  20. package/dist/{Plan-DLtRksuu.d.mts → Plan-DJ4hEEna.d.mts} +2 -2
  21. package/dist/Plan.d.mts +1 -1
  22. package/dist/Plan.mjs +1 -1
  23. package/dist/Policy.d.mts +1 -1
  24. package/dist/Policy.mjs +1 -1
  25. package/dist/Presentation.d.mts +1 -1
  26. package/dist/Presentation.mjs +2 -2
  27. package/dist/Stage.d.mts +1 -1
  28. package/dist/Stage.mjs +7 -6
  29. package/dist/Stage.mjs.map +1 -1
  30. package/dist/{Supervisor-BgWP8o9t.mjs → Supervisor-2mPjIYYs.mjs} +4 -3
  31. package/dist/{Supervisor-BgWP8o9t.mjs.map → Supervisor-2mPjIYYs.mjs.map} +1 -1
  32. package/dist/Supervisor.mjs +1 -1
  33. package/dist/{context-rYTk-3Cv.mjs → context-w3NJPRLo.mjs} +15 -8
  34. package/dist/context-w3NJPRLo.mjs.map +1 -0
  35. package/dist/index.d.mts +2 -2
  36. package/dist/index.mjs +4 -4
  37. package/dist/{page-ClEMFnBG.mjs → page-BKwfF6z-.mjs} +169 -112
  38. package/dist/page-BKwfF6z-.mjs.map +1 -0
  39. package/package.json +4 -4
  40. package/src/Moment.ts +3 -6
  41. package/src/Page.ts +13 -11
  42. package/src/Stage.ts +8 -16
  43. package/src/Supervisor.ts +6 -7
  44. package/src/internal/input/actions.ts +28 -64
  45. package/src/internal/input/dispatch.ts +22 -7
  46. package/src/internal/input/edit.inpage.ts +23 -2
  47. package/src/internal/input/evidence.inpage.ts +0 -2
  48. package/src/internal/input/guard.ts +3 -9
  49. package/src/internal/input/perform.ts +14 -10
  50. package/src/internal/input/pointer.ts +3 -6
  51. package/src/internal/input/replies.ts +19 -8
  52. package/src/internal/input/settle.ts +3 -2
  53. package/src/internal/input/targets.inpage.ts +2 -5
  54. package/src/internal/input/targets.ts +5 -21
  55. package/src/internal/page/bridge.ts +2 -2
  56. package/src/internal/page/context.ts +13 -1
  57. package/src/internal/page/identity.ts +2 -4
  58. package/src/internal/page/navigation.ts +3 -12
  59. package/src/internal/pictures/capture.ts +7 -0
  60. package/src/internal/pictures/motion.ts +65 -0
  61. package/src/internal/pictures/pictures.ts +20 -16
  62. package/src/internal/pictures/utilsBundle.d.ts +9 -0
  63. package/src/internal/reading/reading.ts +5 -9
  64. package/src/internal/reading/ready.ts +66 -14
  65. package/src/internal/timeline/events.ts +5 -8
  66. package/src/internal/timeline/window.ts +3 -8
  67. package/dist/Browser-DRPthkNO.mjs.map +0 -1
  68. package/dist/Page-C_VkBVwI.mjs.map +0 -1
  69. package/dist/context-rYTk-3Cv.mjs.map +0 -1
  70. package/dist/page-ClEMFnBG.mjs.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-browser",
3
- "version": "0.3.0-beta.16",
3
+ "version": "0.3.0-beta.20",
4
4
  "description": "Effect browser sessions, page control, screencast and moments over Playwright and CDP.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -115,9 +115,9 @@
115
115
  "test": "vp test --run"
116
116
  },
117
117
  "devDependencies": {
118
- "@effect/vitest": "4.0.0",
118
+ "@effect/vitest": "4.0.3",
119
119
  "@types/node": "26.1.2",
120
- "effect": "4.0.0",
120
+ "effect": "4.0.3",
121
121
  "playwright-core": "1.63.0",
122
122
  "typescript": "7.0.2",
123
123
  "vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
@@ -125,7 +125,7 @@
125
125
  "vitest": "5.0.3"
126
126
  },
127
127
  "peerDependencies": {
128
- "effect": "~4.0.0",
128
+ "effect": "~4.0.3",
129
129
  "playwright-core": "^1.63.0"
130
130
  },
131
131
  "engines": {
package/src/Moment.ts CHANGED
@@ -27,11 +27,11 @@
27
27
  import { Duration, Effect, Option, Result, Schema } from "effect";
28
28
  import { Prompt } from "effect/ai";
29
29
 
30
- import { BrowserError, InvalidRequest } from "./BrowserError.ts";
30
+ import { BrowserError } from "./BrowserError.ts";
31
31
  import { type Action, BrowserEvent } from "./BrowserEvent.ts";
32
32
  import { type Change, Changes } from "./Change.ts";
33
33
  import { Frame } from "./Frame.ts";
34
- import { undispatched } from "./internal/page/context.ts";
34
+ import { refuse } from "./internal/page/context.ts";
35
35
  import { describe, named, quoted, where } from "./internal/timeline/words.ts";
36
36
  import type * as Page from "./Page.ts";
37
37
  import { Snapshot, type SnapshotOptions } from "./Snapshot.ts";
@@ -116,10 +116,7 @@ export const capture = Effect.fn("Moment.capture")(function* (
116
116
  const count = options.frames ?? 2;
117
117
 
118
118
  if (!Number.isSafeInteger(count) || count < 1)
119
- return yield* undispatched(
120
- "moment",
121
- new InvalidRequest({ detail: "frames must be a positive safe integer" }),
122
- );
119
+ return yield* refuse("moment", "frames must be a positive safe integer");
123
120
  const outline = options.snapshot ?? false;
124
121
 
125
122
  // The picture comes first, at no call with a running screencast, and the window ends at its
package/src/Page.ts CHANGED
@@ -19,7 +19,7 @@ import { type Duration, Effect, Schema, type Stream } from "effect";
19
19
  import type { Page as PlaywrightPage } from "playwright-core";
20
20
 
21
21
  import { type BrowserError, type PolicyDenied } from "./BrowserError.ts";
22
- import { Box, type BrowserEvent, PageLoaded, Subject, SubjectContext } from "./BrowserEvent.ts";
22
+ import { Box, type BrowserEvent, PageLoaded, Subject } from "./BrowserEvent.ts";
23
23
  import type { Changes } from "./Change.ts";
24
24
  import { type CaptureStats, Frame, Image, type ScreencastOptions } from "./Frame.ts";
25
25
  import { FormFieldSchema } from "./internal/input/evidence.inpage.ts";
@@ -53,12 +53,8 @@ export class Zoom extends Schema.Class<Zoom>("effect-browser/Zoom")({
53
53
  /** The target as it read before input. Point coordinates are never snapped to another position. */
54
54
  export class ResolvedTarget extends Schema.Class<ResolvedTarget>("effect-browser/ResolvedTarget")({
55
55
  point: Motion.Point,
56
+ ...Subject.fields,
56
57
  element: Schema.String,
57
- /** The element's lowercase tag name. */
58
- tag: Schema.String,
59
- role: Schema.NullOr(Schema.String),
60
- name: Schema.String,
61
- context: SubjectContext,
62
58
  /** For a point target, the box of what it found there. */
63
59
  box: Schema.optional(Box),
64
60
  cursor: Schema.String,
@@ -222,8 +218,11 @@ export interface WaitCondition {
222
218
  }
223
219
 
224
220
  export interface ReadyOptions {
225
- /** What must stay still during quietMillis: screen paint (default), or recorded content. */
226
- readonly still?: "screen" | "content" | undefined;
221
+ /**
222
+ * What must stay still during quietMillis: screen paint (default); recorded content; or the
223
+ * `"foreground"`, the screen but for what was already moving before the page's latest input.
224
+ */
225
+ readonly still?: "screen" | "content" | "foreground" | undefined;
227
226
  /** How long the selected evidence must stay still; screen paint by default. */
228
227
  readonly quietMillis?: number | undefined;
229
228
  readonly timeout?: Duration.Input | undefined;
@@ -493,9 +492,12 @@ export interface Page {
493
492
  * view have loaded, and the viewport shows something. With `quietMillis`, the screen must then
494
493
  * also stay still that long: nothing in view changed, where the page's changes are recorded, and
495
494
  * no frame comes, counted from the first frame of a capture the wait starts itself. A canvas
496
- * that keeps drawing, such as a live chart, is never still, and a page
497
- * is never ready without a painted frame, as a hidden tab may be. `Timeout` after `timeout`,
498
- * 15 seconds by default.
495
+ * that keeps drawing, such as a live chart, is never still. With `still: "foreground"`, frames
496
+ * may keep coming, but nothing may move in them outside what moved in the frames of the 1.5 s
497
+ * before the page's latest input, such as an idle animation; reels set spinning, or a counter
498
+ * set rolling, by the input keep the wait going. Without those frames, as where they are older
499
+ * than the browser's `frameHistory`, nothing may move at all. A page is never ready without a
500
+ * painted frame, as a hidden tab may be. `Timeout` after `timeout`, 15 seconds by default.
499
501
  */
500
502
  readonly ready: (options?: ReadyOptions) => Effect.Effect<void, BrowserError>;
501
503
 
package/src/Stage.ts CHANGED
@@ -24,7 +24,7 @@ import {
24
24
 
25
25
  import { type BrowserError, Closed, consequence, InvalidRequest, Timeout } from "./BrowserError.ts";
26
26
  import type { CaptureStats, Frame, ScreencastOptions } from "./Frame.ts";
27
- import { failWith, undispatched } from "./internal/page/context.ts";
27
+ import { failWith, refuse, undispatched } from "./internal/page/context.ts";
28
28
  import { internalsOf } from "./internal/page/page.ts";
29
29
  import { monotonicMillis } from "./internal/pictures/clock.ts";
30
30
  import type { Page } from "./Page.ts";
@@ -240,16 +240,14 @@ const evidence = (
240
240
 
241
241
  const probe = Effect.gen(function* () {
242
242
  yield* until(Math.max(checkedAt, current()?.changedAt ?? checkedAt) + millis, now);
243
- checkedAt = now();
244
243
  const before = current();
245
244
  const frame = before?.frame;
246
245
 
247
- if (
248
- before === undefined ||
249
- frame === undefined ||
250
- gone === before.feed ||
251
- checkedAt - before.changedAt < millis
252
- )
246
+ // Paint during the wait moves the deadline; an early wake is not a silence check.
247
+ if (before !== undefined && now() - before.changedAt < millis)
248
+ return Option.none<Evidence>();
249
+ checkedAt = now();
250
+ if (before === undefined || frame === undefined || gone === before.feed)
253
251
  return Option.none<Evidence>();
254
252
  const { feed } = before;
255
253
 
@@ -399,10 +397,7 @@ export const make = Effect.fn("Stage.make")(function* (
399
397
  !Duration.isFinite(waited.value) ||
400
398
  !Duration.isPositive(waited.value)
401
399
  )
402
- return yield* failWith(
403
- "stage",
404
- new InvalidRequest({ detail: "firstFrame must be a finite positive duration" }),
405
- );
400
+ return yield* refuse("stage", "firstFrame must be a finite positive duration");
406
401
  const firstFrame = waited.value;
407
402
  const settings = { quality: options.quality, size: options.size };
408
403
  const output = yield* PubSub.sliding<Paint>(16);
@@ -434,10 +429,7 @@ export const make = Effect.fn("Stage.make")(function* (
434
429
  return switching(
435
430
  Effect.gen(function* () {
436
431
  if (!Number.isFinite(asked))
437
- return yield* failWith(
438
- "cue",
439
- new InvalidRequest({ detail: "at must be finite host milliseconds" }),
440
- );
432
+ return yield* refuse("cue", "at must be finite host milliseconds");
441
433
  yield* firstOf(feed, firstFrame);
442
434
  const old = showing;
443
435
 
package/src/Supervisor.ts CHANGED
@@ -598,21 +598,20 @@ const makeScoped = Effect.fn("Supervisor.make")(function* <E, R, A>(
598
598
 
599
599
  const retire = yield* Effect.cached(stop({ _tag: "Retire" }));
600
600
 
601
+ // The browser of the generation serving, or of the one `target` names once it serves.
602
+ const browserOf = (target?: number) =>
603
+ Effect.map(waitFor(state, waitTimeout, clock, target), (live) => live.value.opened.browser);
604
+
601
605
  yield* Effect.addFinalizer(() => (options.keep === true ? stop({ _tag: "Keep" }) : retire));
602
606
  yield* perform(started);
603
607
 
604
608
  return {
605
- browser: Effect.map(waitFor(state, waitTimeout, clock), (live) => live.value.opened.browser),
609
+ browser: browserOf(),
606
610
  use: leasing(state, apply, waitTimeout, now, clock),
607
611
  states: Stream.fromPubSubTake(events),
608
612
  rotate: apply({ _tag: "Rotate" }).pipe(
609
613
  Effect.flatMap(({ reply }) =>
610
- reply === undefined
611
- ? Effect.fail(retired())
612
- : Effect.map(
613
- waitFor(state, waitTimeout, clock, reply),
614
- (live) => live.value.opened.browser,
615
- ),
614
+ reply === undefined ? Effect.fail(retired()) : browserOf(reply),
616
615
  ),
617
616
  ),
618
617
  retire,
@@ -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);
@@ -52,25 +51,23 @@ const finite = (...values: ReadonlyArray<number | undefined>) =>
52
51
 
53
52
  const named = (target: Target) => (typeof target === "string" ? target : `${target.x},${target.y}`);
54
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
+
55
57
  /** A click's options, once its counts are finite. */
56
58
  const clickable = (options: ClickOptions) =>
57
59
  finite(options.clickCount, options.holdMillis)
58
60
  ? Effect.void
59
- : failWith("click", new InvalidRequest({ detail: "clickCount and holdMillis must be finite" }));
61
+ : refuse("click", "clickCount and holdMillis must be finite");
60
62
 
61
63
  /** The keys a press sends, once they and its options are valid. */
62
64
  const pressed = (keys: string, options: PressOptions) => {
63
65
  const parts = Keys.parts(keys);
64
66
 
65
67
  if (!finite(options.times, options.holdMillis))
66
- return failWith("press", new InvalidRequest({ detail: "times and holdMillis must be finite" }));
68
+ return refuse("press", "times and holdMillis must be finite");
67
69
  if (parts === undefined)
68
- return failWith(
69
- "press",
70
- new InvalidRequest({
71
- detail: `"${keys}" is not a key; try Enter, Space, ArrowLeft or Control+A`,
72
- }),
73
- );
70
+ return refuse("press", `"${keys}" is not a key; try Enter, Space, ArrowLeft or Control+A`);
74
71
 
75
72
  return Effect.succeed(parts);
76
73
  };
@@ -92,11 +89,7 @@ const click = (input: Parts) => {
92
89
  },
93
90
  settings.actionTimeout,
94
91
  Effect.suspend(() => clickable(clickOptions)).pipe(
95
- Effect.andThen(
96
- preparePolicy("click", { target: typeof target === "string" ? target : undefined }, [
97
- target,
98
- ]),
99
- ),
92
+ Effect.andThen(preparePolicy("click", { target: refOf(target) }, [target])),
100
93
  ),
101
94
  (marks, approval) =>
102
95
  Effect.gen(function* () {
@@ -134,26 +127,14 @@ const click = (input: Parts) => {
134
127
  ),
135
128
  ),
136
129
  );
137
- yield* sendMouse("click", marks.input, {
138
- type: "mousePressed",
139
- ...point,
140
- button,
141
- buttons: buttonMask[button],
142
- clickCount: index,
143
- });
130
+ yield* sendMouse("click", marks.input, buttonAt("mousePressed", point, button, index));
144
131
 
145
132
  // A hold the caller asks for is the click's own; the style's is presentation.
146
133
  const hold = clickOptions.holdMillis ?? (yield* marks.style.hold);
147
134
 
148
135
  if (clickOptions.holdMillis === undefined) yield* marks.present(hold);
149
136
  if (hold > 0) yield* Effect.sleep(Duration.millis(hold));
150
- yield* sendMouse("click", marks.input, {
151
- type: "mouseReleased",
152
- ...point,
153
- button,
154
- buttons: 0,
155
- clickCount: index,
156
- });
137
+ yield* sendMouse("click", marks.input, buttonAt("mouseReleased", point, button, index));
157
138
  }
158
139
  yield* flush("click", marks.input);
159
140
  yield* settle("click", before);
@@ -173,7 +154,7 @@ const hover = (input: Parts) => {
173
154
  "hover",
174
155
  { target: named(target) },
175
156
  settings.actionTimeout,
176
- preparePolicy("hover", { target: typeof target === "string" ? target : undefined }, [target]),
157
+ preparePolicy("hover", { target: refOf(target) }, [target]),
177
158
  (marks, approval) =>
178
159
  Effect.gen(function* () {
179
160
  const resolved = yield* targetFor("hover", target, approval, marks);
@@ -244,21 +225,9 @@ const drag = (input: Parts) => {
244
225
  ],
245
226
  });
246
227
  yield* marks.sent;
247
- yield* sendMouse("drag", marks.input, {
248
- type: "mousePressed",
249
- ...start.point,
250
- button: "left",
251
- buttons: 1,
252
- clickCount: 1,
253
- });
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,
@@ -292,10 +261,7 @@ 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.
@@ -328,22 +294,24 @@ const typeText = (input: Parts) => {
328
294
  yield* Guard.check(approval, { presses: [{ index: 0, ...target.point }] });
329
295
  yield* marks.sent;
330
296
  for (const type of ["mousePressed", "mouseReleased"] as const)
331
- yield* sendMouse("type", marks.input, {
332
- type,
333
- ...target.point,
334
- button: "left",
335
- buttons: type === "mousePressed" ? 1 : 0,
336
- clickCount: 1,
337
- });
297
+ yield* sendMouse("type", marks.input, buttonAt(type, target.point));
338
298
  yield* flush("type", marks.input);
339
299
  }
340
300
 
341
301
  // Focusing can run page handlers, including navigation, before the script returns.
342
302
  yield* marks.sent;
343
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
+
344
312
  const focused = yield* mutate(
345
313
  "type",
346
- scriptCall("focus", into, replace),
314
+ scriptCall("focus", into, replace, follow),
347
315
  approval,
348
316
  ).pipe(Effect.flatMap(decodeWith("type", Script.FocusResultSchema)));
349
317
 
@@ -480,7 +448,7 @@ const scroll = (input: Parts) => {
480
448
  const valid =
481
449
  Number.isFinite(scrollOptions.dx ?? 0) && Number.isFinite(scrollOptions.dy ?? 0)
482
450
  ? Effect.void
483
- : failWith("scroll", new InvalidRequest({ detail: "scroll deltas must be finite" }));
451
+ : refuse("scroll", "scroll deltas must be finite");
484
452
 
485
453
  return perform(
486
454
  "scroll",
@@ -492,11 +460,7 @@ const scroll = (input: Parts) => {
492
460
  valid.pipe(
493
461
  Effect.andThen(
494
462
  // Scrolling the page has no target: whatever sits mid-viewport is not what is scrolled.
495
- preparePolicy(
496
- "scroll",
497
- { target: typeof target === "string" ? target : undefined },
498
- target === undefined ? [] : [target],
499
- ),
463
+ preparePolicy("scroll", { target: refOf(target) }, target === undefined ? [] : [target]),
500
464
  ),
501
465
  ),
502
466
  (marks, approval) =>
@@ -26,6 +26,22 @@ export interface MouseEvent extends Point {
26
26
  readonly deltaY?: number;
27
27
  }
28
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
+ });
44
+
29
45
  export const make = (page: PageContext) => {
30
46
  const { id, pointer, publish, now, noteInput, closedBy } = page;
31
47
  const { send } = page.protocol;
@@ -98,13 +114,12 @@ export const make = (page: PageContext) => {
98
114
  () => dispatchMouse(event, estimate, submitted),
99
115
  () =>
100
116
  dispatchMouse(
101
- {
102
- type: "mouseReleased",
103
- ...Option.getOrElse(MutableRef.get(pointer), () => ({ x: event.x, y: event.y })),
104
- button: event.button ?? "left",
105
- buttons: 0,
106
- clickCount: event.clickCount ?? 1,
107
- },
117
+ buttonAt(
118
+ "mouseReleased",
119
+ Option.getOrElse(MutableRef.get(pointer), () => ({ x: event.x, y: event.y })),
120
+ event.button,
121
+ event.clickCount,
122
+ ),
108
123
  estimate,
109
124
  ),
110
125
  );
@@ -26,6 +26,7 @@ export const edit = (names: Names, guard: Guard, placing: ContextReader) => {
26
26
  activeElement,
27
27
  clean,
28
28
  describe,
29
+ inCurrentDocument,
29
30
  isDisabled,
30
31
  isHtml,
31
32
  isInput,
@@ -72,8 +73,28 @@ export const edit = (names: Names, guard: Guard, placing: ContextReader) => {
72
73
  };
73
74
  };
74
75
 
75
- const focus = (ref: string, replace: boolean): FocusResult => {
76
- 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));
77
98
 
78
99
  if (element === undefined) return { error: `${ref} is not on the page any more`, stale: true };
79
100
  if (isDisabled(element)) return { error: `${ref} is disabled` };
@@ -7,8 +7,6 @@ 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 type FormField = typeof FormFieldSchema.Encoded;
11
-
12
10
  /** Page text about the first target. It never includes what a field holds. */
13
11
  export type Evidence = typeof EvidenceSchema.Encoded;
14
12
 
@@ -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
 
@@ -74,19 +74,13 @@ export const make = (page: PageContext, bridge: Bridge) => {
74
74
  Effect.gen(function* () {
75
75
  for (const target of targets) {
76
76
  if (typeof target === "string" && !/^e\d+$/.test(target))
77
- return yield* failWith(
78
- action,
79
- new InvalidRequest({ detail: `"${target}" is not a ref; refs look like e12` }),
80
- );
77
+ return yield* refuse(action, `"${target}" is not a ref; refs look like e12`);
81
78
  if (
82
79
  target !== null &&
83
80
  typeof target !== "string" &&
84
81
  (!Number.isFinite(target.x) || !Number.isFinite(target.y))
85
82
  )
86
- return yield* failWith(
87
- action,
88
- new InvalidRequest({ detail: "point coordinates must be finite" }),
89
- );
83
+ return yield* refuse(action, "point coordinates must be finite");
90
84
  }
91
85
 
92
86
  const input: Script.InputPlan = {
@@ -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.
@@ -5,11 +5,10 @@
5
5
  */
6
6
  import { Duration, Effect, MutableRef, Option, Schema } from "effect";
7
7
 
8
- import { InvalidRequest } from "../../BrowserError.ts";
9
8
  import { CursorChanged, TrackPerformed, TrackPlanned } from "../../BrowserEvent.ts";
10
9
  import * as Motion from "../../Motion.ts";
11
10
  import type { Point } from "../../Page.ts";
12
- import { failWith, type PageContext } from "../page/context.ts";
11
+ import { type PageContext, refuse } from "../page/context.ts";
13
12
  import type { Viewport } from "../page/viewport.ts";
14
13
  import type { Dispatch } from "./dispatch.ts";
15
14
  import type { InputMarks } from "./perform.ts";
@@ -47,11 +46,9 @@ export const make = (page: PageContext, dispatch: Dispatch, viewport: Viewport)
47
46
 
48
47
  // A custom planner is a boundary. Decoding reads each sample once into a fresh copy and
49
48
  // checks that copy, so neither accessors nor later mutation can change what is admitted.
50
- const invalid = failWith(
49
+ const invalid = refuse(
51
50
  operation,
52
- new InvalidRequest({
53
- detail: "motion must be a bounded, ordered schedule ending at the target",
54
- }),
51
+ "motion must be a bounded, ordered schedule ending at the target",
55
52
  );
56
53
 
57
54
  const samples = yield* Schema.decodeEffect(Motion.Plan)(planned).pipe(
@@ -3,7 +3,7 @@
3
3
  * Canceling a caller cannot cancel a protocol command already sent. The next action waits for
4
4
  * those replies, while a stopped run releases only held keys or buttons whose release was never submitted.
5
5
  */
6
- import { Context, Data, Effect } from "effect";
6
+ import { Context, Data, Effect, type Scope } from "effect";
7
7
 
8
8
  import { maximumSamples } from "../../Motion.ts";
9
9
 
@@ -18,6 +18,8 @@ export interface Run {
18
18
  readonly track: <A>(reply: Promise<A>) => Promise<A>;
19
19
  /** End ownership after the record and all this operation's native replies have settled. */
20
20
  readonly afterIdle: (ended: () => void) => void;
21
+ /** Retain authority through scoped cleanup after the replies already submitted. */
22
+ readonly recover: (cleanup: Effect.Effect<void>, scope: Scope.Scope) => Effect.Effect<void>;
21
23
  /** Reserve a complete stroke, including its releases, before sending its first event. */
22
24
  readonly reserve: (commands: number) => Effect.Effect<void, InputFailure>;
23
25
  /** Admit every sample before a motion starts its published clock; ordinary input stays bounded separately. */
@@ -105,17 +107,11 @@ export const make = () => {
105
107
  const submit = (command: () => Promise<unknown>) => {
106
108
  if (reserved < 1) throw new Error("input must reserve capacity before dispatch");
107
109
  reserved--;
108
- let response: Promise<unknown>;
109
-
110
110
  try {
111
- response = command();
111
+ void track(command(), true);
112
112
  } catch (cause) {
113
113
  failure ??= new InputFailure({ cause });
114
-
115
- return;
116
114
  }
117
-
118
- void track(response, true);
119
115
  };
120
116
 
121
117
  const reserve = (commands: number, maximum: number, limit: number) =>
@@ -138,6 +134,21 @@ export const make = () => {
138
134
  if (pending.size === 0) end();
139
135
  else endings.add(end);
140
136
  },
137
+ recover: (cleanup, scope) =>
138
+ Effect.gen(function* () {
139
+ const replies = [...pending];
140
+ const done = Promise.withResolvers<void>();
141
+
142
+ // This reservation prevents a new action or suspension from overtaking cleanup.
143
+ void track(done.promise);
144
+ yield* Effect.promise(() => Promise.all(replies)).pipe(
145
+ Effect.andThen(cleanup),
146
+ Effect.ensuring(Effect.sync(() => done.resolve())),
147
+ Effect.provideService(Current, run),
148
+ Effect.interruptible,
149
+ Effect.forkIn(scope),
150
+ );
151
+ }),
141
152
  reserve: (commands) => reserve(commands, capacity, capacity),
142
153
  // A dense stroke cannot wait for replies midway without changing its published timing.
143
154
  // Its finite reservation also leaves room for the ordinary pool's already-owned releases.
@@ -39,12 +39,13 @@ export const make = (page: PageContext, bridge: Bridge) => {
39
39
  // evaluate after the settling deadline, without retaining browser input authority.
40
40
  Effect.provideService(Current, undefined),
41
41
  Effect.catchIf(contextGone, () => Effect.void),
42
- Effect.onError(() => page.stopLoading),
43
42
  );
44
43
  while (document() > before.document && parsed < document()) yield* Deferred.await(changed);
45
44
  }).pipe(
46
45
  Effect.timeoutOrElse({ duration: settling, orElse: () => Effect.fail("unsettled") }),
47
- Effect.catch(() => Effect.annotateCurrentSpan("settled", false)),
46
+ Effect.catch(() =>
47
+ page.stopLoading.pipe(Effect.andThen(Effect.annotateCurrentSpan("settled", false))),
48
+ ),
48
49
  Effect.andThen(page.openings.settle(before.openings)),
49
50
  page.span("Page.settle", {}, "Debug"),
50
51
  );