effect-browser 0.3.0-beta.16 → 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 (69) hide show
  1. package/README.md +12 -4
  2. package/dist/{Browser-DRPthkNO.mjs → Browser-H63wiekr.mjs} +4 -4
  3. package/dist/Browser-H63wiekr.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 +1 -1
  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/navigation.ts +3 -12
  58. package/src/internal/pictures/capture.ts +7 -0
  59. package/src/internal/pictures/motion.ts +65 -0
  60. package/src/internal/pictures/pictures.ts +20 -16
  61. package/src/internal/pictures/utilsBundle.d.ts +9 -0
  62. package/src/internal/reading/reading.ts +5 -9
  63. package/src/internal/reading/ready.ts +66 -14
  64. package/src/internal/timeline/events.ts +5 -8
  65. package/src/internal/timeline/window.ts +3 -8
  66. package/dist/Browser-DRPthkNO.mjs.map +0 -1
  67. package/dist/Page-C_VkBVwI.mjs.map +0 -1
  68. package/dist/context-rYTk-3Cv.mjs.map +0 -1
  69. package/dist/page-ClEMFnBG.mjs.map +0 -1
@@ -8,7 +8,7 @@ import { Effect } from "effect";
8
8
  import { InvalidRequest, NotActionable, StaleRef } from "../../BrowserError.ts";
9
9
  import { ResolvedTarget, type Target } from "../../Page.ts";
10
10
  import { scriptCall } from "../page/bridge.ts";
11
- import { decodeWith, failWith } from "../page/context.ts";
11
+ import { decodeWith, failWith, refuse } from "../page/context.ts";
12
12
  import type { Viewport } from "../page/viewport.ts";
13
13
  import type { Dispatch } from "./dispatch.ts";
14
14
  import { type Approval, check, type Guard } from "./guard.ts";
@@ -31,18 +31,8 @@ const pointFailure = (
31
31
  : new NotActionable({ detail: result.detail }),
32
32
  );
33
33
 
34
- const resolvedTarget = (result: Script.ResolvedPoint): ResolvedTarget =>
35
- new ResolvedTarget({
36
- point: { x: result.x, y: result.y },
37
- element: result.element,
38
- tag: result.tag,
39
- role: result.role,
40
- name: result.name,
41
- context: result.context,
42
- box: result.box,
43
- cursor: result.cursor,
44
- ...(result.href === undefined ? {} : { href: result.href }),
45
- });
34
+ const resolvedTarget = ({ x, y, box, ...target }: Script.ResolvedPoint): ResolvedTarget =>
35
+ new ResolvedTarget({ point: { x, y }, box, ...target });
46
36
 
47
37
  export const make = (
48
38
  viewport: Viewport,
@@ -67,15 +57,9 @@ export const make = (
67
57
 
68
58
  const resolve = (operation: string, target: Target, approval?: Approval, scroll = true) => {
69
59
  if (typeof target === "string" && !/^e\d+$/.test(target))
70
- return failWith(
71
- operation,
72
- new InvalidRequest({ detail: target + " is not a ref; refs look like e12" }),
73
- );
60
+ return refuse(operation, target + " is not a ref; refs look like e12");
74
61
  if (typeof target !== "string" && (!Number.isFinite(target.x) || !Number.isFinite(target.y)))
75
- return failWith(
76
- operation,
77
- new InvalidRequest({ detail: "point coordinates must be finite" }),
78
- );
62
+ return refuse(operation, "point coordinates must be finite");
79
63
 
80
64
  return readPoint(operation, target, approval, scroll).pipe(
81
65
  Effect.flatMap((result) =>
@@ -90,7 +90,7 @@ const install = (
90
90
  ): PageApi => {
91
91
  const installed = globalThis.__effectBrowser;
92
92
 
93
- if (installed !== undefined && installed.version === 19) return installed;
93
+ if (installed !== undefined && installed.version === 20) return installed;
94
94
  const named = makeNames();
95
95
  const walked = makeWalk(named);
96
96
  const placing = makeContext(named, walked);
@@ -108,7 +108,7 @@ const install = (
108
108
  const readiness = makeReady(walked, texts, kept, recorder);
109
109
 
110
110
  const api: PageApi = {
111
- version: 19,
111
+ version: 20,
112
112
  snapshot: read.snapshot,
113
113
  find: subjected.find,
114
114
  until: makeExpectations(named, subjected).until,
@@ -17,7 +17,14 @@ import {
17
17
  import type { CDPSession, Page as PlaywrightPage } from "playwright-core";
18
18
 
19
19
  import type { CaptureSource } from "../../Browser.ts";
20
- import { BrowserError, Closed, Failed, type Reason, Timeout } from "../../BrowserError.ts";
20
+ import {
21
+ BrowserError,
22
+ Closed,
23
+ Failed,
24
+ InvalidRequest,
25
+ type Reason,
26
+ Timeout,
27
+ } from "../../BrowserError.ts";
21
28
  import type { BrowserEvent } from "../../BrowserEvent.ts";
22
29
  import type { InputGuard, Point } from "../../Page.ts";
23
30
  import type { Gate } from "../input/gate.ts";
@@ -159,6 +166,10 @@ export const undispatched = (operation: string, reason: Reason) =>
159
166
  export const failWith = (operation: string, reason: Reason) =>
160
167
  Effect.fail(undispatched(operation, reason));
161
168
 
169
+ /** Refuse a request to `operation` that could never succeed, before sending anything. */
170
+ export const refuse = (operation: string, detail: string) =>
171
+ failWith(operation, new InvalidRequest({ detail }));
172
+
162
173
  export const decodeWith =
163
174
  <A>(operation: string, schema: Schema.Codec<A, unknown>) =>
164
175
  (value: unknown) =>
@@ -308,6 +319,7 @@ export const make = (options: MakeOptions, scope: Scope.Scope) => {
308
319
  lane,
309
320
  pointer,
310
321
  now,
322
+ scope,
311
323
  owned,
312
324
  span,
313
325
  protocol,
@@ -5,17 +5,11 @@
5
5
  */
6
6
  import { Deferred, Duration, Effect, Schedule } from "effect";
7
7
 
8
- import {
9
- BrowserError,
10
- InvalidRequest,
11
- NavigationFailed,
12
- NotActionable,
13
- NotFound,
14
- } from "../../BrowserError.ts";
8
+ import { BrowserError, NavigationFailed, NotActionable, NotFound } from "../../BrowserError.ts";
15
9
  import type { Guard } from "../input/guard.ts";
16
10
  import type { InputMarks, Perform } from "../input/perform.ts";
17
11
  import { Current } from "../input/replies.ts";
18
- import { failWith, type PageContext } from "./context.ts";
12
+ import { failWith, type PageContext, refuse } from "./context.ts";
19
13
  import * as Url from "./url.ts";
20
14
 
21
15
  export const make = (
@@ -157,10 +151,7 @@ export const make = (
157
151
  parsed === null ||
158
152
  !["http:", "https:", "about:", "data:", "file:"].includes(parsed.protocol)
159
153
  )
160
- return failWith(
161
- "navigate",
162
- new InvalidRequest({ detail: `"${Url.redact(url)}" is not a URL` }),
163
- );
154
+ return refuse("navigate", `"${Url.redact(url)}" is not a URL`);
164
155
 
165
156
  return navigation(
166
157
  "navigate",
@@ -47,6 +47,8 @@ interface Options {
47
47
  readonly frameHistory: Duration.Duration;
48
48
  /** The viewport in CSS pixels; a capture is scaled to fit it, as screenshots are. */
49
49
  readonly viewport: Effect.Effect<Size, BrowserError>;
50
+ /** Copies the restored whole surface before native capture can restart. */
51
+ readonly restore: () => Promise<unknown>;
50
52
  readonly imageSize: (data: Uint8Array) => Size | undefined;
51
53
  readonly error: (cause: unknown) => BrowserError;
52
54
  }
@@ -589,6 +591,11 @@ export const make = (options: Options) =>
589
591
  };
590
592
 
591
593
  if (current === undefined || !resumable()) return;
594
+ // Screenshot replies restore emulation before the compositor has necessarily painted
595
+ // it. Copy the whole restored surface while stopped, retaining its actual native reply.
596
+ void pictures.track("picture restoration", options.restore(), failed(current));
597
+ yield* pictures.wait("picture restoration");
598
+ yield* healthy(current);
592
599
  const viewport = yield* readViewport(options);
593
600
 
594
601
  if (!resumable()) return;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Where a page's screen moves, from its screencast frames: each frame decoded and cut into
3
+ * 16-pixel blocks, a block moving where a few of its pixels, sampled every other one, changed by
4
+ * more than JPEG's noise in any color channel.
5
+ * What moved among the frames before an input is the page's background, such as an idle
6
+ * animation; foreground stillness ignores it.
7
+ *
8
+ * Frames are decoded with the JPEG decoder playwright-core bundles and exports as
9
+ * `playwright-core/lib/utilsBundle`: effect-browser depends on nothing else, and the runtime has
10
+ * no image decoder. That entry point has no types, so `utilsBundle.d.ts` declares what is used.
11
+ */
12
+ // oxlint-disable-next-line typescript/triple-slash-reference -- a declaration of an untyped module cannot be imported, and every program that compiles this source needs it
13
+ /// <reference path="./utilsBundle.d.ts" />
14
+ import { jpegjs } from "playwright-core/lib/utilsBundle";
15
+
16
+ import type { Frame } from "../../Frame.ts";
17
+
18
+ /** A decoded frame: RGBA, `width` pixels a row. */
19
+ export type Picture = ReturnType<typeof jpegjs.decode>;
20
+
21
+ export const picture = (frame: Frame): Picture => jpegjs.decode(frame.data, { useTArray: true });
22
+
23
+ /** A sample changed where any color channel moved more than this, beyond JPEG's noise. */
24
+ const noise = 32;
25
+
26
+ /** The blocks where two pictures differ, by `row * columns + column`; -1 for different sizes. */
27
+ const moved = (from: Picture, to: Picture): ReadonlyArray<number> => {
28
+ if (from.width !== to.width || from.height !== to.height) return [-1];
29
+ const columns = Math.ceil(from.width / 16);
30
+ const counts = new Map<number, number>();
31
+ const differs = (at: number) => Math.abs((from.data[at] ?? 0) - (to.data[at] ?? 0)) > noise;
32
+
33
+ for (let y = 0; y < from.height; y += 2)
34
+ for (let x = 0; x < from.width; x += 2) {
35
+ const at = (y * from.width + x) * 4;
36
+ const block = Math.floor(y / 16) * columns + Math.floor(x / 16);
37
+
38
+ if (differs(at) || differs(at + 1) || differs(at + 2))
39
+ counts.set(block, (counts.get(block) ?? 0) + 1);
40
+ }
41
+
42
+ // At least three changed samples, so a stray one is no motion.
43
+ return [...counts].flatMap(([block, count]) => (count >= 3 ? [block] : []));
44
+ };
45
+
46
+ /** The blocks that moved from one picture to the next and their neighbours; none past a resize. */
47
+ export const background = (pictures: ReadonlyArray<Picture>): ReadonlySet<number> => {
48
+ const columns = Math.ceil((pictures[0]?.width ?? 0) / 16);
49
+
50
+ const around = (block: number) =>
51
+ [-1, 0, 1].flatMap((row) =>
52
+ [-1, 0, 1]
53
+ .filter((step) => block >= 0 && (block % columns) + step >= 0)
54
+ .filter((step) => (block % columns) + step < columns)
55
+ .map((step) => block + row * columns + step),
56
+ );
57
+
58
+ return new Set(
59
+ pictures.flatMap((to, index) => moved(pictures[index - 1] ?? to, to).flatMap(around)),
60
+ );
61
+ };
62
+
63
+ /** Whether anything moved from one picture to another outside the background. */
64
+ export const stirs = (from: Picture, to: Picture, mask: ReadonlySet<number>) =>
65
+ moved(from, to).some((block) => !mask.has(block));
@@ -11,7 +11,14 @@ import { Frame, Image, Screenshot } from "../../Frame.ts";
11
11
  import type { Point } from "../../Motion.ts";
12
12
  import { type FrameOptions, Region, type ScreenshotOptions, Zoom } from "../../Page.ts";
13
13
  import { type Bridge, scriptCall } from "../page/bridge.ts";
14
- import { decodeWith, failWith, type PageContext, reasonOf, undispatched } from "../page/context.ts";
14
+ import {
15
+ decodeWith,
16
+ failWith,
17
+ type PageContext,
18
+ reasonOf,
19
+ refuse,
20
+ undispatched,
21
+ } from "../page/context.ts";
15
22
  import type { Viewport } from "../page/viewport.ts";
16
23
  import * as Capture from "./capture.ts";
17
24
  import * as BrowserClock from "./clock.ts";
@@ -118,9 +125,7 @@ const calibrator = (page: PageContext, bridge: Bridge) => {
118
125
  yield* Effect.forEach(
119
126
  [1, 2, 3],
120
127
  () => Effect.map(probe(contextId), (one) => probes.push(one)),
121
- {
122
- discard: true,
123
- },
128
+ { discard: true },
124
129
  ).pipe(
125
130
  within("calibrate", Duration.seconds(2)),
126
131
  Effect.catch((error) => (probes.length === 0 ? Effect.fail(error) : Effect.void)),
@@ -308,10 +313,7 @@ const reuse =
308
313
  const maxAge = millisOf(options.maxAge ?? defaultMaxAge);
309
314
 
310
315
  if (!(maxAge >= 0))
311
- return yield* failWith(
312
- operation,
313
- new InvalidRequest({ detail: "maxAge must be a duration of zero or more" }),
314
- );
316
+ return yield* refuse(operation, "maxAge must be a duration of zero or more");
315
317
  const latest = yield* capture.latest;
316
318
  const known = viewport.knownViewport();
317
319
  const { activity } = page;
@@ -371,6 +373,14 @@ export const make = Effect.fnUntraced(function* (
371
373
  watch: bridge.currentDocument("screencast"),
372
374
  frameHistory: settings.frameHistory,
373
375
  viewport: Effect.suspend(() => viewport.viewportFor("screencast")),
376
+ // An uncropped snapshot waits for the restored compositor surface. Its pixels are discarded;
377
+ // a renderer animation frame or viewport size alone cannot prove the compositor caught up.
378
+ restore: () =>
379
+ page.protocol.send("Page.captureScreenshot", {
380
+ format: "jpeg",
381
+ quality: 0,
382
+ optimizeForSpeed: true,
383
+ }),
374
384
  imageSize: jpegSize,
375
385
  error,
376
386
  });
@@ -468,10 +478,7 @@ export const make = Effect.fnUntraced(function* (
468
478
  const size = viewport.knownViewport() ?? (yield* viewport.viewportFor("zoom"));
469
479
 
470
480
  if (region.x + region.width > size.width || region.y + region.height > size.height)
471
- return yield* failWith(
472
- "zoom",
473
- new InvalidRequest({ detail: "the crop must fit entirely within the viewport" }),
474
- );
481
+ return yield* refuse("zoom", "the crop must fit entirely within the viewport");
475
482
  const image = yield* picture("zoom", 80, region);
476
483
 
477
484
  return new Zoom({ page: id, region, image });
@@ -485,10 +492,7 @@ export const make = Effect.fnUntraced(function* (
485
492
 
486
493
  return millis > 0 && millis <= Duration.toMillis(Capture.countsKept)
487
494
  ? capture.stats(millis)
488
- : failWith(
489
- "captureStats",
490
- new InvalidRequest({ detail: "window must be over zero and at most a minute" }),
491
- );
495
+ : refuse("captureStats", "window must be over zero and at most a minute");
492
496
  };
493
497
 
494
498
  // The browser's clock mapping, measured on this page only while the browser has none, as the
@@ -0,0 +1,9 @@
1
+ // What `motion.ts` uses of playwright-core's bundled JPEG decoder, which its untyped entry exports.
2
+ declare module "playwright-core/lib/utilsBundle" {
3
+ export const jpegjs: {
4
+ decode(
5
+ data: Uint8Array,
6
+ options: { useTArray: true },
7
+ ): { readonly width: number; readonly height: number; readonly data: Uint8Array };
8
+ };
9
+ }
@@ -5,11 +5,11 @@
5
5
  */
6
6
  import { Duration, Effect, Ref, Schema } from "effect";
7
7
 
8
- import { InvalidRequest, StaleRef, Timeout } from "../../BrowserError.ts";
8
+ import { StaleRef, Timeout } from "../../BrowserError.ts";
9
9
  import { type FindQuery, Found, Text, type TextOptions, type WaitCondition } from "../../Page.ts";
10
10
  import { Snapshot, type SnapshotOptions } from "../../Snapshot.ts";
11
11
  import { type Bridge, scriptCall } from "../page/bridge.ts";
12
- import { contextGone, decodeWith, failWith, type PageContext } from "../page/context.ts";
12
+ import { contextGone, decodeWith, failWith, type PageContext, refuse } from "../page/context.ts";
13
13
  import * as Url from "../page/url.ts";
14
14
  import { type ConditionRequest, ConditionResultSchema } from "./controls.inpage.ts";
15
15
  import type { Want } from "./expect.inpage.ts";
@@ -68,8 +68,7 @@ export const make = Effect.fnUntraced(function* (page: PageContext, bridge: Brid
68
68
  Effect.flatMap(decodeWith("snapshot", SnapshotResultSchema)),
69
69
  );
70
70
 
71
- if ("invalid" in result)
72
- return yield* failWith("snapshot", new InvalidRequest({ detail: result.invalid }));
71
+ if ("invalid" in result) return yield* refuse("snapshot", result.invalid);
73
72
  const { text, truncated, url } = result.snapshot;
74
73
 
75
74
  yield* counted(result.nextRef);
@@ -115,10 +114,7 @@ export const make = Effect.fnUntraced(function* (page: PageContext, bridge: Brid
115
114
 
116
115
  return (
117
116
  scope !== "viewport" && !/^e\d+$/.test(scope)
118
- ? failWith(
119
- "text",
120
- new InvalidRequest({ detail: `${scope} is neither "viewport" nor a ref such as e12` }),
121
- )
117
+ ? refuse("text", `${scope} is neither "viewport" nor a ref such as e12`)
122
118
  : texts(
123
119
  JSON.stringify(request),
124
120
  Effect.gen(function* () {
@@ -159,7 +155,7 @@ export const make = Effect.fnUntraced(function* (page: PageContext, bridge: Brid
159
155
  Effect.flatMap(decodeWith("waitFor", ConditionResultSchema)),
160
156
  Effect.flatMap((result) =>
161
157
  "invalid" in result
162
- ? failWith("waitFor", new InvalidRequest({ detail: result.invalid }))
158
+ ? refuse("waitFor", result.invalid)
163
159
  : result.met
164
160
  ? Effect.void
165
161
  : unmet(),
@@ -10,16 +10,27 @@
10
10
  * its connection, and a frame that arrives first starts the spell again. Where the page's changes
11
11
  * are recorded, its evidence includes them: nothing in view may have changed for the spell, which a
12
12
  * DOM page's changes show better than its paint, and no stall on the way can hide. Content
13
- * stillness starts that record itself and ignores capture silence. Each new document starts a
13
+ * stillness starts that record itself and ignores capture silence. Foreground stillness lets
14
+ * frames keep coming but compares them, so that only motion outside what was moving before the
15
+ * page's latest input counts; it still waits out unanswered acknowledgements while no frame
16
+ * comes, and its last look follows the round trip too. Each new document starts a
14
17
  * new observation, while retries of destroyed script contexts share the caller's deadline.
15
18
  */
16
19
  import { Duration, Effect, Option, Schedule, Stream } from "effect";
17
20
 
18
- import { Timeout } from "../../BrowserError.ts";
21
+ import { Failed, Timeout } from "../../BrowserError.ts";
22
+ import type { Frame } from "../../Frame.ts";
19
23
  import type { ReadyOptions } from "../../Page.ts";
20
24
  import { type Bridge, scriptCall } from "../page/bridge.ts";
21
- import { contextGone, decodeWith, failWith, type PageContext } from "../page/context.ts";
25
+ import {
26
+ contextGone,
27
+ decodeWith,
28
+ failWith,
29
+ type PageContext,
30
+ undispatched,
31
+ } from "../page/context.ts";
22
32
  import type * as Capture from "../pictures/capture.ts";
33
+ import * as Motion from "../pictures/motion.ts";
23
34
  import { ReadinessSchema } from "./ready.inpage.ts";
24
35
 
25
36
  export const make =
@@ -55,27 +66,68 @@ export const make =
55
66
  const quiet = (millis: number, until: number) =>
56
67
  Effect.gen(function* () {
57
68
  const running = yield* capture.active;
58
- let { received } = yield* capture.stats();
69
+ const foreground = options.still === "foreground";
70
+ let counts = yield* capture.stats();
59
71
  let stirred = running ? page.now() : Number.POSITIVE_INFINITY;
72
+ let reference: Motion.Picture | undefined;
60
73
 
61
- // How long the screen has been still: since a frame, or an unanswered acknowledgement.
74
+ const decoded = (frame: Frame) =>
75
+ Effect.try({
76
+ try: () => Motion.picture(frame),
77
+ catch: (error) =>
78
+ undispatched("ready", new Failed({ detail: `no picture: ${String(error)}` })),
79
+ });
80
+
81
+ // The background: what moved in up to eight frames of the document's 1.5 s before its
82
+ // latest input, as far back as the capture keeps them.
83
+ const { inputAt } = page.activity;
84
+ const document = Option.getOrUndefined(yield* capture.latest)?.document;
85
+
86
+ const before = (foreground ? yield* capture.recent : []).filter(
87
+ (frame) =>
88
+ frame.hostTime < inputAt &&
89
+ frame.hostTime >= inputAt - 1500 &&
90
+ frame.document === document,
91
+ );
92
+
93
+ const sampled = before.filter((_, index) => index % Math.ceil(before.length / 8) === 0);
94
+ const mask = Motion.background(yield* Effect.forEach(sampled, decoded));
95
+
96
+ // How long the screen has been still: since a frame, or an unanswered acknowledgement. In
97
+ // the foreground, since a frame moved outside the background, or acknowledgements went
98
+ // unanswered with no frame coming.
62
99
  const look = Effect.gen(function* () {
63
100
  const stats = yield* capture.stats();
101
+ const latest = Option.getOrUndefined(yield* capture.latest);
102
+ const arrived = stats.received !== counts.received;
103
+ const unseen = stats.accepted - counts.accepted;
64
104
 
65
- if (
66
- !Option.exists(yield* capture.latest, capture.current) ||
67
- stats.received !== received ||
68
- stats.ackBacklog > 0
69
- ) {
70
- received = stats.received;
105
+ counts = stats;
106
+ if (latest === undefined || !capture.current(latest)) stirred = page.now();
107
+ else if (!foreground ? arrived || stats.ackBacklog > 0 : !arrived && stats.ackBacklog > 0)
71
108
  stirred = page.now();
109
+ else if (foreground && (unseen > 0 || reference === undefined)) {
110
+ // A move and its return between looks both count. Bound decoding; any frames lost
111
+ // from history, or beyond this batch, deny a quiet spell rather than hiding motion.
112
+ const frames = (yield* capture.recent).slice(-Math.max(1, Math.min(8, unseen)));
113
+
114
+ if (unseen > frames.length) stirred = page.now();
115
+ for (const frame of frames) {
116
+ const seen = yield* decoded(frame);
117
+
118
+ if (reference === undefined || Motion.stirs(reference, seen, mask)) {
119
+ reference = seen;
120
+ stirred = page.now();
121
+ }
122
+ }
72
123
  }
73
124
 
74
125
  return page.now() - stirred;
75
126
  });
76
127
 
128
+ // Decode at a fixed cadence: adding a delay after a full batch lets its backlog grow.
77
129
  const spell = Effect.repeat(look, {
78
- schedule: Schedule.spaced(Duration.millis(25)),
130
+ schedule: Schedule.fixed(Duration.millis(foreground ? 100 : 25)),
79
131
  until: (still) => still >= millis,
80
132
  });
81
133
 
@@ -86,10 +138,10 @@ export const make =
86
138
  const still = Effect.gen(function* () {
87
139
  for (;;) {
88
140
  yield* spell;
89
- const before = received;
141
+ const before = counts.received;
90
142
 
91
143
  yield* Effect.all([settled(until), capture.sync], { concurrency: 2, discard: true });
92
- if ((yield* look) >= millis && received === before) return;
144
+ if ((yield* look) >= millis && (foreground || counts.received === before)) return;
93
145
  }
94
146
  });
95
147
 
@@ -4,9 +4,9 @@
4
4
  */
5
5
  import { Cause, Deferred, Effect, Stream } from "effect";
6
6
 
7
- import { type BrowserError, EventHistoryExpired, InvalidRequest } from "../../BrowserError.ts";
7
+ import { type BrowserError, EventHistoryExpired } from "../../BrowserError.ts";
8
8
  import { type BrowserEvent, RecordedEvent } from "../../BrowserEvent.ts";
9
- import { undispatched } from "../page/context.ts";
9
+ import { refuse, undispatched } from "../page/context.ts";
10
10
 
11
11
  /** The owning Browser validates capacity and closes this timeline with its scope. */
12
12
  export const make = (capacity: number) => {
@@ -41,13 +41,10 @@ export const make = (capacity: number) => {
41
41
  let cursor = after ?? sequence;
42
42
 
43
43
  if (!Number.isSafeInteger(cursor) || cursor < 0 || cursor > sequence)
44
- return Stream.fail(
45
- undispatched(
44
+ return Stream.fromEffect(
45
+ refuse(
46
46
  "events",
47
- new InvalidRequest({
48
- detail:
49
- "the event cursor must be a nonnegative integer no greater than the latest sequence",
50
- }),
47
+ "the event cursor must be a nonnegative integer no greater than the latest sequence",
51
48
  ),
52
49
  );
53
50
 
@@ -5,13 +5,13 @@
5
5
  */
6
6
  import { Duration, Effect, Option, Result, Schema } from "effect";
7
7
 
8
- import { type BrowserError, InvalidRequest } from "../../BrowserError.ts";
8
+ import type { BrowserError } from "../../BrowserError.ts";
9
9
  import { type BrowserEvent, TrackEvent } from "../../BrowserEvent.ts";
10
10
  import type { Changes } from "../../Change.ts";
11
11
  import { Frame } from "../../Frame.ts";
12
12
  import { Window } from "../../Moment.ts";
13
13
  import type { ChangesOptions, WindowOptions } from "../../Page.ts";
14
- import { failWith, type PageContext } from "../page/context.ts";
14
+ import { type PageContext, refuse } from "../page/context.ts";
15
15
 
16
16
  const isWindow = Schema.is(Window);
17
17
  const isFrame = Schema.is(Frame);
@@ -64,12 +64,7 @@ export const make =
64
64
  const asked = boundsOf({ since, until }, page.now());
65
65
 
66
66
  if (Option.isNone(asked))
67
- return yield* failWith(
68
- "window",
69
- new InvalidRequest({
70
- detail: "a window's bounds must be finite, its reach not negative",
71
- }),
72
- );
67
+ return yield* refuse("window", "a window's bounds must be finite, its reach not negative");
73
68
  const bounds = asked.value;
74
69
 
75
70
  // A previous window's changes go on exactly where they ended, on the page's own clock, and a