effect-browser 0.2.0-beta.9 → 0.3.0-beta.5

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 (202) hide show
  1. package/README.md +705 -1184
  2. package/dist/Browser-CM65Uo7f.mjs +437 -0
  3. package/dist/Browser-CM65Uo7f.mjs.map +1 -0
  4. package/dist/Browser.d.mts +115 -3
  5. package/dist/Browser.mjs +2 -22
  6. package/dist/BrowserError-DXXIA6Rk.d.mts +135 -0
  7. package/dist/BrowserError.d.mts +2 -0
  8. package/dist/BrowserError.mjs +202 -0
  9. package/dist/BrowserError.mjs.map +1 -0
  10. package/dist/BrowserEvent-DzdNdvoh.d.mts +277 -0
  11. package/dist/BrowserEvent.d.mts +2 -0
  12. package/dist/BrowserEvent.mjs +295 -0
  13. package/dist/BrowserEvent.mjs.map +1 -0
  14. package/dist/Cdp.d.mts +32 -0
  15. package/dist/Cdp.mjs +61 -0
  16. package/dist/Cdp.mjs.map +1 -0
  17. package/dist/Change-DNvztNLu.mjs +138 -0
  18. package/dist/Change-DNvztNLu.mjs.map +1 -0
  19. package/dist/Change-DVP2IJWm.d.mts +71 -0
  20. package/dist/Change.d.mts +2 -0
  21. package/dist/Change.mjs +2 -0
  22. package/dist/Chromium.d.mts +29 -81
  23. package/dist/Chromium.mjs +57 -392
  24. package/dist/Chromium.mjs.map +1 -1
  25. package/dist/Expect.d.mts +62 -0
  26. package/dist/Expect.mjs +114 -0
  27. package/dist/Expect.mjs.map +1 -0
  28. package/dist/Frame-BERmf_gM.d.mts +117 -0
  29. package/dist/Frame.d.mts +2 -0
  30. package/dist/Frame.mjs +123 -0
  31. package/dist/Frame.mjs.map +1 -0
  32. package/dist/Moment.d.mts +2 -0
  33. package/dist/Moment.mjs +288 -0
  34. package/dist/Moment.mjs.map +1 -0
  35. package/dist/Motion.d.mts +40 -0
  36. package/dist/Motion.mjs +122 -0
  37. package/dist/Motion.mjs.map +1 -0
  38. package/dist/Page-D4281dIi.mjs +618 -0
  39. package/dist/Page-D4281dIi.mjs.map +1 -0
  40. package/dist/Page-DYTdVhYa.d.mts +593 -0
  41. package/dist/Page.d.mts +2 -0
  42. package/dist/Page.mjs +2 -0
  43. package/dist/Plan-CNNOU1Tx.d.mts +112 -0
  44. package/dist/Plan-Dtrb-8im.mjs +311 -0
  45. package/dist/Plan-Dtrb-8im.mjs.map +1 -0
  46. package/dist/Plan.d.mts +2 -3
  47. package/dist/Plan.mjs +2 -2
  48. package/dist/Policy.d.mts +76 -0
  49. package/dist/Policy.mjs +237 -0
  50. package/dist/Policy.mjs.map +1 -0
  51. package/dist/Presentation.d.mts +48 -0
  52. package/dist/Presentation.mjs +117 -0
  53. package/dist/Presentation.mjs.map +1 -0
  54. package/dist/Snapshot-B34hZ4Jl.d.mts +68 -0
  55. package/dist/Snapshot.d.mts +2 -0
  56. package/dist/Snapshot.mjs +79 -0
  57. package/dist/Snapshot.mjs.map +1 -0
  58. package/dist/Stage.d.mts +54 -0
  59. package/dist/Stage.mjs +133 -0
  60. package/dist/Stage.mjs.map +1 -0
  61. package/dist/Supervisor-BtEG93gR.mjs +420 -0
  62. package/dist/Supervisor-BtEG93gR.mjs.map +1 -0
  63. package/dist/Supervisor-Im6-wbg5.d.mts +230 -0
  64. package/dist/Supervisor.d.mts +2 -0
  65. package/dist/Supervisor.mjs +2 -0
  66. package/dist/context-DAYjATgC.mjs +137 -0
  67. package/dist/context-DAYjATgC.mjs.map +1 -0
  68. package/dist/index.d.mts +17 -14
  69. package/dist/index.mjs +18 -13
  70. package/dist/match.inpage-xOe9LNO0.mjs +48 -0
  71. package/dist/match.inpage-xOe9LNO0.mjs.map +1 -0
  72. package/dist/page-Banfm_zc.mjs +5097 -0
  73. package/dist/page-Banfm_zc.mjs.map +1 -0
  74. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  75. package/dist/url-CcL0K7S0.mjs +262 -0
  76. package/dist/url-CcL0K7S0.mjs.map +1 -0
  77. package/package.json +96 -61
  78. package/src/Browser.ts +625 -0
  79. package/src/BrowserError.ts +225 -0
  80. package/src/BrowserEvent.ts +294 -0
  81. package/src/Cdp.ts +101 -0
  82. package/src/Change.ts +88 -0
  83. package/src/Chromium.ts +102 -0
  84. package/src/Expect.ts +180 -0
  85. package/src/Frame.ts +130 -0
  86. package/src/Moment.ts +479 -0
  87. package/src/Motion.ts +166 -0
  88. package/src/Page.ts +520 -0
  89. package/src/Plan.ts +350 -0
  90. package/src/Policy.ts +325 -0
  91. package/src/Presentation.ts +195 -0
  92. package/src/Snapshot.ts +89 -0
  93. package/src/Stage.ts +223 -0
  94. package/src/Supervisor.ts +353 -0
  95. package/src/index.ts +20 -0
  96. package/src/internal/input/actions.ts +607 -0
  97. package/src/internal/input/dispatch.ts +161 -0
  98. package/src/internal/input/edit.inpage.ts +162 -0
  99. package/src/internal/input/evidence.inpage.ts +164 -0
  100. package/src/internal/input/guard.inpage.ts +605 -0
  101. package/src/internal/input/guard.ts +183 -0
  102. package/src/internal/input/keyboard.ts +112 -0
  103. package/src/internal/input/keys.ts +127 -0
  104. package/src/internal/input/perform.ts +322 -0
  105. package/src/internal/input/pointer.ts +153 -0
  106. package/src/internal/input/replies.ts +159 -0
  107. package/src/internal/input/settle.ts +50 -0
  108. package/src/internal/input/style.ts +186 -0
  109. package/src/internal/input/targets.inpage.ts +293 -0
  110. package/src/internal/input/targets.ts +148 -0
  111. package/src/internal/page/bridge.ts +386 -0
  112. package/src/internal/page/context.ts +302 -0
  113. package/src/internal/page/lane.ts +301 -0
  114. package/src/internal/page/navigation.ts +183 -0
  115. package/src/internal/page/openings.ts +87 -0
  116. package/src/internal/page/page.ts +137 -0
  117. package/src/internal/page/url.ts +100 -0
  118. package/src/internal/page/viewport.ts +43 -0
  119. package/src/internal/pictures/capture.ts +778 -0
  120. package/src/internal/pictures/clock.ts +141 -0
  121. package/src/internal/pictures/pictures.ts +520 -0
  122. package/src/internal/pictures/transport.ts +242 -0
  123. package/src/internal/reading/choose.ts +100 -0
  124. package/src/internal/reading/context.inpage.ts +201 -0
  125. package/src/internal/reading/controls.inpage.ts +158 -0
  126. package/src/internal/reading/expect.inpage.ts +70 -0
  127. package/src/internal/reading/match.inpage.ts +92 -0
  128. package/src/internal/reading/names.inpage.ts +306 -0
  129. package/src/internal/reading/outline.inpage.ts +260 -0
  130. package/src/internal/reading/reading.ts +223 -0
  131. package/src/internal/reading/ready.inpage.ts +154 -0
  132. package/src/internal/reading/ready.ts +105 -0
  133. package/src/internal/reading/subjects.inpage.ts +188 -0
  134. package/src/internal/reading/text.inpage.ts +135 -0
  135. package/src/internal/reading/walk.inpage.ts +220 -0
  136. package/src/internal/supervisor/lifecycle.ts +265 -0
  137. package/src/internal/timeline/changes.inpage.ts +216 -0
  138. package/src/internal/timeline/changes.ts +100 -0
  139. package/src/internal/timeline/events.ts +93 -0
  140. package/src/internal/timeline/fold.inpage.ts +123 -0
  141. package/src/internal/timeline/history.inpage.ts +229 -0
  142. package/src/internal/timeline/marks.inpage.ts +207 -0
  143. package/src/internal/timeline/record.inpage.ts +133 -0
  144. package/src/internal/timeline/sight.inpage.ts +122 -0
  145. package/src/internal/timeline/window.ts +96 -0
  146. package/src/internal/timeline/words.ts +65 -0
  147. package/dist/Bootstrap-B9UUXsvF.d.mts +0 -183
  148. package/dist/Bootstrap-CeQOWBy6.mjs +0 -189
  149. package/dist/Bootstrap-CeQOWBy6.mjs.map +0 -1
  150. package/dist/Bootstrap.d.mts +0 -3
  151. package/dist/Bootstrap.mjs +0 -2
  152. package/dist/Browser-BF9_sORP.d.mts +0 -388
  153. package/dist/Browser.mjs.map +0 -1
  154. package/dist/BrowserData-CPIIIHol.d.mts +0 -585
  155. package/dist/BrowserData.d.mts +0 -3
  156. package/dist/BrowserData.mjs +0 -695
  157. package/dist/BrowserData.mjs.map +0 -1
  158. package/dist/BrowserRuntime-B6nT1G4X.d.mts +0 -148
  159. package/dist/BrowserRuntime-C1r2aSRK.mjs +0 -6820
  160. package/dist/BrowserRuntime-C1r2aSRK.mjs.map +0 -1
  161. package/dist/BrowserRuntime.d.mts +0 -3
  162. package/dist/BrowserRuntime.mjs +0 -3
  163. package/dist/Capture.d.mts +0 -56
  164. package/dist/Capture.mjs +0 -678
  165. package/dist/Capture.mjs.map +0 -1
  166. package/dist/CaptureData-CYriZHX1.mjs +0 -201
  167. package/dist/CaptureData-CYriZHX1.mjs.map +0 -1
  168. package/dist/CaptureData.d.mts +0 -219
  169. package/dist/CaptureData.mjs +0 -2
  170. package/dist/Errors.d.mts +0 -158
  171. package/dist/Errors.mjs +0 -207
  172. package/dist/Errors.mjs.map +0 -1
  173. package/dist/Images-yCG9BmRU.mjs +0 -76
  174. package/dist/Images-yCG9BmRU.mjs.map +0 -1
  175. package/dist/OperationOptions-DAeRS6O9.mjs +0 -55
  176. package/dist/OperationOptions-DAeRS6O9.mjs.map +0 -1
  177. package/dist/PageControl.d.mts +0 -15
  178. package/dist/PageControl.mjs +0 -34
  179. package/dist/PageControl.mjs.map +0 -1
  180. package/dist/PageRegistry-B3q9DS3g.mjs +0 -164
  181. package/dist/PageRegistry-B3q9DS3g.mjs.map +0 -1
  182. package/dist/Plan-Dyxu2q8C.mjs +0 -543
  183. package/dist/Plan-Dyxu2q8C.mjs.map +0 -1
  184. package/dist/PlanData-B668RxbQ.d.mts +0 -4831
  185. package/dist/PlanData.d.mts +0 -3
  186. package/dist/PlanData.mjs +0 -268
  187. package/dist/PlanData.mjs.map +0 -1
  188. package/dist/PlanInput-BJcPPncs.mjs +0 -75
  189. package/dist/PlanInput-BJcPPncs.mjs.map +0 -1
  190. package/dist/PublicSession-DCSMMqOj.mjs +0 -5602
  191. package/dist/PublicSession-DCSMMqOj.mjs.map +0 -1
  192. package/dist/Testing.d.mts +0 -479
  193. package/dist/Testing.mjs +0 -2147
  194. package/dist/Testing.mjs.map +0 -1
  195. package/dist/Timeline-fV22Xk-S.d.mts +0 -29
  196. package/dist/Timeline.d.mts +0 -4
  197. package/dist/Timeline.mjs +0 -20
  198. package/dist/Timeline.mjs.map +0 -1
  199. package/dist/TimelineData-BsOn-YGZ.d.mts +0 -3818
  200. package/dist/TimelineData.d.mts +0 -3
  201. package/dist/TimelineData.mjs +0 -482
  202. package/dist/TimelineData.mjs.map +0 -1
@@ -0,0 +1,186 @@
1
+ /**
2
+ * How input is sent: plainly, as fast as the page takes it, or performed for viewers, with a
3
+ * pointer that glides, keys typed at a person's pace, held buttons and wheels in bursts. A page's
4
+ * actions take one, and the input reaches the same controls either way; only its timing, and
5
+ * what viewers see of it, differ. A presenter builds the performed one.
6
+ */
7
+ import { Effect, type MutableRef, type Option, Random } from "effect";
8
+
9
+ import type * as Motion from "../../Motion.ts";
10
+ import type { Point } from "../../Page.ts";
11
+ import * as Keys from "./keys.ts";
12
+
13
+ export interface TypingEvent {
14
+ /** From the start of the typing. */
15
+ readonly afterMillis: number;
16
+ readonly phase: "down" | "up" | "insert";
17
+ readonly key: string;
18
+ }
19
+
20
+ export interface WheelStep {
21
+ readonly dx: number;
22
+ readonly dy: number;
23
+ /** From the first step. */
24
+ readonly afterMillis: number;
25
+ }
26
+
27
+ export interface Style {
28
+ /**
29
+ * Whether viewers watch the input: then the pointer clicks into a field before typing, finds
30
+ * what lies under it as it scrolls, and scrolls to a target out of view with the wheel.
31
+ */
32
+ readonly shown: boolean;
33
+ /** Where viewers last saw the pointer, which the next glide starts from; else the page's own. */
34
+ readonly pointer: MutableRef.MutableRef<Option.Option<Point>> | undefined;
35
+ /** The pointer's path to `to`, ending exactly there. */
36
+ readonly glide: (
37
+ from: Point,
38
+ to: Point,
39
+ dragging: boolean,
40
+ ) => Effect.Effect<ReadonlyArray<Motion.Sample>>;
41
+ /** How long a button stays down when a click does not say. */
42
+ readonly hold: Effect.Effect<number>;
43
+ /** The key events that type `text`, or one insertion of it under a guard, timed from the start. */
44
+ readonly typing: (text: string, guarded: boolean) => Effect.Effect<ReadonlyArray<TypingEvent>>;
45
+ /** The pause between repeated presses of the same keys. */
46
+ readonly keyGap: Effect.Effect<number>;
47
+ /** A wheel scroll as the wheel turns: in one step, or in notches and bursts. */
48
+ readonly wheel: (dx: number, dy: number) => Effect.Effect<ReadonlyArray<WheelStep>>;
49
+ }
50
+
51
+ /** Each key as it comes, with no pause; a drag moves in eight short steps so its target sees it. */
52
+ export const plain: Style = {
53
+ shown: false,
54
+ pointer: undefined,
55
+ glide: (from, to, dragging) =>
56
+ Effect.succeed(
57
+ dragging
58
+ ? Array.from({ length: 8 }, (_, index) => ({
59
+ x: index === 7 ? to.x : from.x + ((to.x - from.x) * (index + 1)) / 8,
60
+ y: index === 7 ? to.y : from.y + ((to.y - from.y) * (index + 1)) / 8,
61
+ afterMillis: (index + 1) * 8,
62
+ }))
63
+ : [{ ...to, afterMillis: 0 }],
64
+ ),
65
+ hold: Effect.succeed(0),
66
+ typing: (text, guarded) =>
67
+ Effect.succeed(
68
+ guarded && text !== ""
69
+ ? [{ afterMillis: 0, phase: "insert", key: text }]
70
+ : [...text].flatMap((key): ReadonlyArray<TypingEvent> =>
71
+ Keys.description(key) === undefined
72
+ ? [{ afterMillis: 0, phase: "insert", key }]
73
+ : [
74
+ { afterMillis: 0, phase: "down", key },
75
+ { afterMillis: 0, phase: "up", key },
76
+ ],
77
+ ),
78
+ ),
79
+ keyGap: Effect.succeed(0),
80
+ wheel: (dx, dy) => Effect.succeed([{ dx, dy, afterMillis: 0 }]),
81
+ };
82
+
83
+ // A right-skewed draw, clamped so that cleanup and deadlines stay predictable; 1 - U keeps the
84
+ // logarithm defined when Random returns zero.
85
+ const lognormal = Effect.fnUntraced(function* (
86
+ median: number,
87
+ sigma: number,
88
+ minimum: number,
89
+ maximum: number,
90
+ ) {
91
+ const radius = Math.sqrt(-2 * Math.log(1 - (yield* Random.next)));
92
+ const normal = radius * Math.cos(2 * Math.PI * (yield* Random.next));
93
+
94
+ return Math.max(minimum, Math.min(maximum, median * Math.exp(sigma * normal)));
95
+ });
96
+
97
+ /** A pause around `median`, as the research measured reactions: at most four times it. */
98
+ export const pause = (median: number, sigma: number) =>
99
+ median <= 0 ? Effect.succeed(0) : lognormal(median, sigma, 0, median * 4);
100
+
101
+ // A mouse press about 80 ms long on average, a key about 110 ms, each now and then longer: their
102
+ // medians are the means times exp(-sigma^2 / 2).
103
+ const pressHold = lognormal(75.2, 0.35, 35, 200);
104
+ const keyHold = lognormal(107.8, 0.2, 60, 220);
105
+ const eventOrder = { up: 0, insert: 1, down: 2 };
106
+
107
+ /**
108
+ * Plan the whole text before any key goes, so holds overlap without waiting on the network. A
109
+ * physical key is never held twice, as with A and a, or ! and 1. The first key waits for the hand
110
+ * to reach the keys, each later one follows the last by a gap drawn around `gap`, and a word's
111
+ * first key comes 60 ms later.
112
+ */
113
+ const typing = Effect.fnUntraced(function* (
114
+ text: string,
115
+ gap: number,
116
+ ): Effect.fn.Return<ReadonlyArray<TypingEvent>> {
117
+ const events: Array<TypingEvent> = [];
118
+ const releases = new Map<string, number>();
119
+ let due = text === "" ? 0 : yield* Random.nextBetween(150, 400);
120
+ let previous = "";
121
+
122
+ for (const character of text) {
123
+ if (/\s/u.test(previous) && /[\p{L}\p{N}]/u.test(character)) due += 60;
124
+ const description = Keys.description(character);
125
+
126
+ if (description === undefined)
127
+ events.push({ afterMillis: due, phase: "insert", key: character });
128
+ else {
129
+ due = Math.max(due, (releases.get(description.code) ?? -1) + 1);
130
+ const released = due + (yield* keyHold);
131
+
132
+ events.push(
133
+ { afterMillis: due, phase: "down", key: character },
134
+ { afterMillis: released, phase: "up", key: character },
135
+ );
136
+ releases.set(description.code, released);
137
+ }
138
+ due += yield* Random.nextBetween(gap / 2, (gap * 3) / 2);
139
+ previous = character;
140
+ }
141
+
142
+ return events.toSorted(
143
+ (left, right) =>
144
+ left.afterMillis - right.afterMillis || eventOrder[left.phase] - eventOrder[right.phase],
145
+ );
146
+ });
147
+
148
+ /** A word is five characters; the gap leaves room for each word's later start. */
149
+ export const keyGap = (wordsPerMinute: number) => 12_000 / wordsPerMinute - 10;
150
+
151
+ /**
152
+ * The wheel in 100 px notches, 30 to 140 ms apart, in bursts of at most nine, with 0.6 to 2.6 s
153
+ * between bursts, as people scroll.
154
+ */
155
+ const bursts = Effect.fnUntraced(function* (dx: number, dy: number) {
156
+ const notches = Math.max(1, Math.ceil(Math.hypot(dx, dy) / 100));
157
+ const steps: Array<WheelStep> = [];
158
+ let due = 0;
159
+
160
+ for (let index = 0; index < notches; index++) {
161
+ if (index > 0)
162
+ due += yield* index % 9 === 0 ? Random.nextBetween(600, 2600) : Random.nextBetween(30, 140);
163
+ steps.push({ dx: dx / notches, dy: dy / notches, afterMillis: due });
164
+ }
165
+
166
+ return steps;
167
+ });
168
+
169
+ /** Input performed for viewers, with this pointer and pace. */
170
+ export const performed = (options: {
171
+ readonly pointer: MutableRef.MutableRef<Option.Option<Point>>;
172
+ readonly motion: Motion.Service;
173
+ readonly wordsPerMinute: number;
174
+ }): Style => {
175
+ const gap = keyGap(options.wordsPerMinute);
176
+
177
+ return {
178
+ shown: true,
179
+ pointer: options.pointer,
180
+ glide: (from, to) => options.motion.plan(from, to),
181
+ hold: pressHold,
182
+ typing: (text) => typing(text, gap),
183
+ keyGap: Random.nextBetween(gap / 2, (gap * 3) / 2),
184
+ wheel: bursts,
185
+ };
186
+ };
@@ -0,0 +1,293 @@
1
+ /**
2
+ * In the page: where input lands. It resolves a ref or a point to the control under it, in
3
+ * top-document viewport pixels, with the subject's context, and plans a wheel scroll that brings a
4
+ * ref into view. See `reading/names.inpage.ts` for what a page-side part may use.
5
+ */
6
+ import { Schema } from "effect";
7
+
8
+ import { Box, SubjectContext } from "../../BrowserEvent.ts";
9
+ import type { ContextReader } from "../reading/context.inpage.ts";
10
+ import type { Names } from "../reading/names.inpage.ts";
11
+ import type { Walk } from "../reading/walk.inpage.ts";
12
+
13
+ export type ResolvedPoint = typeof ResolvedPointSchema.Type;
14
+ export type PointResult = typeof PointResultSchema.Type;
15
+
16
+ export const targets = (names: Names, walked: Walk, placing: ContextReader) => {
17
+ const { describe, isDisabled, lookup, nameOf, parentOf, roleOf } = names;
18
+ const { boxOf, controlOf, hitAt } = walked;
19
+ const { contextOf } = placing;
20
+
21
+ /** Whether `node` is `ancestor` or inside it, across shadow roots and same-origin frames. */
22
+ const within = (ancestor: Element, node: Element): boolean => {
23
+ for (let current: Element | null = node; current !== null;) {
24
+ if (current === ancestor) return true;
25
+ current = parentOf(current) ?? current.ownerDocument.defaultView?.frameElement ?? null;
26
+ }
27
+
28
+ return false;
29
+ };
30
+
31
+ // A press at a point reaches the element when the top-document hit is the element, inside it,
32
+ // or one of its containers (whose activation the element's own facts already cover).
33
+ const receives = (element: Element, hit: Element): boolean =>
34
+ within(element, hit) || within(hit, element);
35
+
36
+ // HTML links, SVG links and image-map areas all navigate; SVG keeps lowercase tag names.
37
+ const hrefAttribute = (element: Element): string | null =>
38
+ element.localName === "a" || element.localName === "area"
39
+ ? (element.getAttribute("href") ??
40
+ element.getAttributeNS("http://www.w3.org/1999/xlink", "href"))
41
+ : null;
42
+
43
+ const linkOf = (element: Element): Element | undefined => {
44
+ for (let node: Element | null = element; node !== null; node = parentOf(node))
45
+ if (hrefAttribute(node) !== null) return node;
46
+
47
+ return undefined;
48
+ };
49
+
50
+ const details = (
51
+ element: Element,
52
+ hit: Element,
53
+ x: number,
54
+ y: number,
55
+ ): Omit<ResolvedPoint, "context"> => {
56
+ const link = linkOf(element);
57
+ let href: string | undefined;
58
+
59
+ if (link !== undefined) {
60
+ try {
61
+ href = new URL(hrefAttribute(link) ?? "", link.ownerDocument.baseURI).href;
62
+ } catch {
63
+ href = undefined;
64
+ }
65
+ }
66
+ const role = roleOf(element);
67
+ const view = hit.ownerDocument.defaultView ?? window;
68
+
69
+ return {
70
+ x,
71
+ y,
72
+ element: describe(element),
73
+ tag: element.tagName.toLowerCase(),
74
+ role,
75
+ name: nameOf(element, role),
76
+ cursor: view.getComputedStyle(hit).cursor,
77
+ ...(href === undefined ? {} : { href }),
78
+ };
79
+ };
80
+
81
+ const point = (
82
+ target: string | { readonly x: number; readonly y: number },
83
+ scroll = true,
84
+ ): PointResult => {
85
+ if (typeof target !== "string") {
86
+ const { x, y } = target;
87
+
88
+ if (
89
+ !Number.isFinite(x) ||
90
+ !Number.isFinite(y) ||
91
+ x < 0 ||
92
+ y < 0 ||
93
+ x >= window.innerWidth ||
94
+ y >= window.innerHeight
95
+ )
96
+ return { error: "outside", detail: `(${x}, ${y}) is outside the viewport` };
97
+ const hit = hitAt(document, x, y);
98
+
99
+ if (hit === null) return { error: "offscreen", detail: `nothing is painted at (${x}, ${y})` };
100
+ const control = controlOf(hit);
101
+
102
+ return { ...details(control, hit, x, y), context: contextOf(control), box: boxOf(control) };
103
+ }
104
+ const ref = target;
105
+ const element = lookup(ref);
106
+
107
+ if (element === undefined)
108
+ return { error: "stale", detail: `${ref} is not on the page any more` };
109
+ if (isDisabled(element)) return { error: "disabled", detail: `${ref} is disabled` };
110
+ const rect = element.getBoundingClientRect();
111
+
112
+ if (rect.width === 0 && rect.height === 0)
113
+ return { error: "hidden", detail: `${ref} has no size on the page` };
114
+
115
+ // Input arrives in top-document viewport pixels, so geometry and occlusion are measured
116
+ // there: a frame's own viewport cannot say whether the frame is scrolled away or covered.
117
+ let box = { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom };
118
+ let visible = { ...box };
119
+ let outside = false;
120
+
121
+ for (let inner: Element = element; ;) {
122
+ const view = inner.ownerDocument.defaultView ?? window;
123
+
124
+ outside ||=
125
+ box.left < 0 || box.top < 0 || box.right > view.innerWidth || box.bottom > view.innerHeight;
126
+ visible = {
127
+ left: Math.max(visible.left, 0),
128
+ top: Math.max(visible.top, 0),
129
+ right: Math.min(visible.right, view.innerWidth),
130
+ bottom: Math.min(visible.bottom, view.innerHeight),
131
+ };
132
+
133
+ // A field can be inside the viewport and still clipped by a nested scrolling panel.
134
+ for (let ancestor = parentOf(inner); ancestor !== null; ancestor = parentOf(ancestor)) {
135
+ const style = view.getComputedStyle(ancestor);
136
+ const clip = ancestor.getBoundingClientRect();
137
+
138
+ if (/(auto|scroll|hidden|clip)/.test(style.overflowX)) {
139
+ visible.left = Math.max(visible.left, clip.left + ancestor.clientLeft);
140
+ visible.right = Math.min(
141
+ visible.right,
142
+ clip.left + ancestor.clientLeft + ancestor.clientWidth,
143
+ );
144
+ }
145
+ if (/(auto|scroll|hidden|clip)/.test(style.overflowY)) {
146
+ visible.top = Math.max(visible.top, clip.top + ancestor.clientTop);
147
+ visible.bottom = Math.min(
148
+ visible.bottom,
149
+ clip.top + ancestor.clientTop + ancestor.clientHeight,
150
+ );
151
+ }
152
+ }
153
+
154
+ const frame = view.frameElement;
155
+
156
+ if (frame === null) break;
157
+ const frameBox = frame.getBoundingClientRect();
158
+ const dx = frameBox.left + frame.clientLeft;
159
+ const dy = frameBox.top + frame.clientTop;
160
+
161
+ box = {
162
+ left: box.left + dx,
163
+ top: box.top + dy,
164
+ right: box.right + dx,
165
+ bottom: box.bottom + dy,
166
+ };
167
+ visible = {
168
+ left: Math.max(visible.left + dx, dx),
169
+ top: Math.max(visible.top + dy, dy),
170
+ right: Math.min(visible.right + dx, dx + frame.clientWidth),
171
+ bottom: Math.min(visible.bottom + dy, dy + frame.clientHeight),
172
+ };
173
+ inner = frame;
174
+ }
175
+
176
+ if (outside || visible.right <= visible.left || visible.bottom <= visible.top) {
177
+ if (scroll) {
178
+ element.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
179
+
180
+ return point(target, false);
181
+ }
182
+
183
+ return {
184
+ error: "offscreen",
185
+ detail: outside
186
+ ? ref + " is outside the viewport"
187
+ : `${ref} could not be scrolled into view`,
188
+ };
189
+ }
190
+ const x = (visible.left + visible.right) / 2;
191
+ const y = (visible.top + visible.bottom) / 2;
192
+ const hit = hitAt(document, x, y);
193
+
194
+ if (hit !== null && !receives(element, hit))
195
+ return { error: "covered", detail: `${ref} is covered by ${describe(hit)}` };
196
+
197
+ return {
198
+ ...details(element, hit ?? element, Math.round(x), Math.round(y)),
199
+ context: contextOf(element),
200
+ };
201
+ };
202
+
203
+ // Suggest one visible wheel origin. Unsupported frame geometry and fully clipped panels use
204
+ // the bounded instant fallback; guessing their coordinates could scroll an unrelated control.
205
+ const scrollPlan = (ref: string) => {
206
+ const element = lookup(ref);
207
+
208
+ if (element === undefined || element.ownerDocument !== document) return null;
209
+ const target = element.getBoundingClientRect();
210
+ const centerX = (target.left + target.right) / 2;
211
+ const centerY = (target.top + target.bottom) / 2;
212
+
213
+ for (let ancestor = parentOf(element); ancestor !== null; ancestor = parentOf(ancestor)) {
214
+ const style = window.getComputedStyle(ancestor);
215
+
216
+ const horizontal =
217
+ /^(auto|scroll)$/.test(style.overflowX) && ancestor.scrollWidth > ancestor.clientWidth;
218
+
219
+ const vertical =
220
+ /^(auto|scroll)$/.test(style.overflowY) && ancestor.scrollHeight > ancestor.clientHeight;
221
+
222
+ if (!horizontal && !vertical) continue;
223
+ const box = ancestor.getBoundingClientRect();
224
+ const left = Math.max(0, box.left + ancestor.clientLeft);
225
+ const top = Math.max(0, box.top + ancestor.clientTop);
226
+
227
+ const right = Math.min(
228
+ window.innerWidth,
229
+ box.left + ancestor.clientLeft + ancestor.clientWidth,
230
+ );
231
+
232
+ const bottom = Math.min(
233
+ window.innerHeight,
234
+ box.top + ancestor.clientTop + ancestor.clientHeight,
235
+ );
236
+
237
+ if (right <= left || bottom <= top) continue;
238
+ const x = (left + right) / 2;
239
+ const y = (top + bottom) / 2;
240
+ const dx = horizontal && (target.left < left || target.right > right) ? centerX - x : 0;
241
+ const dy = vertical && (target.top < top || target.bottom > bottom) ? centerY - y : 0;
242
+
243
+ if (dx === 0 && dy === 0) continue;
244
+ const hit = hitAt(document, x, y);
245
+
246
+ if (hit !== null && (hit === ancestor || ancestor.contains(hit))) return { x, y, dx, dy };
247
+ }
248
+
249
+ const dx =
250
+ target.left < 0 || target.right > window.innerWidth ? centerX - window.innerWidth / 2 : 0;
251
+
252
+ const dy =
253
+ target.top < 0 || target.bottom > window.innerHeight ? centerY - window.innerHeight / 2 : 0;
254
+
255
+ return dx === 0 && dy === 0
256
+ ? null
257
+ : { x: window.innerWidth / 2, y: window.innerHeight / 2, dx, dy };
258
+ };
259
+
260
+ return { controlOf, details, hitAt, hrefAttribute, linkOf, point, receives, scrollPlan, within };
261
+ };
262
+
263
+ export type Targets = ReturnType<typeof targets>;
264
+
265
+ const ResolvedPointSchema = Schema.Struct({
266
+ x: Schema.Finite,
267
+ y: Schema.Finite,
268
+ element: Schema.String,
269
+ tag: Schema.String,
270
+ role: Schema.NullOr(Schema.String),
271
+ name: Schema.String,
272
+ context: SubjectContext,
273
+ box: Schema.optional(Box),
274
+ cursor: Schema.String,
275
+ href: Schema.optional(Schema.String),
276
+ });
277
+
278
+ export const PointResultSchema = Schema.Union([
279
+ ResolvedPointSchema,
280
+ Schema.Struct({
281
+ error: Schema.Literals(["stale", "hidden", "offscreen", "disabled", "covered", "outside"]),
282
+ detail: Schema.String,
283
+ }),
284
+ ]);
285
+
286
+ export const ScrollPlanSchema = Schema.NullOr(
287
+ Schema.Struct({
288
+ x: Schema.Finite,
289
+ y: Schema.Finite,
290
+ dx: Schema.Finite,
291
+ dy: Schema.Finite,
292
+ }),
293
+ );
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Where an action's input goes: a ref or a point resolved to a control and a viewport point,
3
+ * scrolled into view first when it is outside the viewport: at once, or with the wheel where
4
+ * viewers watch.
5
+ */
6
+ import { Effect } from "effect";
7
+
8
+ import { InvalidRequest, NotActionable, StaleRef } from "../../BrowserError.ts";
9
+ import { ResolvedTarget, type Target } from "../../Page.ts";
10
+ import { type Bridge, scriptCall } from "../page/bridge.ts";
11
+ import { decodeWith, failWith } from "../page/context.ts";
12
+ import type { Viewport } from "../page/viewport.ts";
13
+ import type { Dispatch } from "./dispatch.ts";
14
+ import type { Approval, Guard } from "./guard.ts";
15
+ import type { InputMarks } from "./perform.ts";
16
+ import type { Pointer } from "./pointer.ts";
17
+ import type { Settle } from "./settle.ts";
18
+ import * as Script from "./targets.inpage.ts";
19
+
20
+ const pointFailure = (
21
+ operation: string,
22
+ target: Target,
23
+ result: Extract<Script.PointResult, { readonly error: string }>,
24
+ ) =>
25
+ failWith(
26
+ operation,
27
+ result.error === "stale" && typeof target === "string"
28
+ ? new StaleRef({ ref: target })
29
+ : result.error === "outside"
30
+ ? new InvalidRequest({ detail: result.detail })
31
+ : new NotActionable({ detail: result.detail }),
32
+ );
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
+ });
46
+
47
+ export const make = (
48
+ bridge: Bridge,
49
+ viewport: Viewport,
50
+ guard: Guard,
51
+ pointer: Pointer,
52
+ dispatch: Dispatch,
53
+ settling: Settle,
54
+ ) => {
55
+ const { evaluate } = bridge;
56
+ const { mutate } = guard;
57
+ const { moveTo, wheel } = pointer;
58
+ const { flush } = dispatch;
59
+
60
+ const readPoint = (
61
+ operation: string,
62
+ target: Target,
63
+ approval: Approval | undefined,
64
+ scroll: boolean,
65
+ ) =>
66
+ (approval === undefined
67
+ ? evaluate(operation, scriptCall("point", target, scroll))
68
+ : mutate(operation, scriptCall("point", target, scroll), approval)
69
+ ).pipe(Effect.flatMap(decodeWith(operation, Script.PointResultSchema)));
70
+
71
+ const resolve = (operation: string, target: Target, approval?: Approval, scroll = true) => {
72
+ if (typeof target === "string" && !/^e\d+$/.test(target))
73
+ return failWith(
74
+ operation,
75
+ new InvalidRequest({ detail: target + " is not a ref; refs look like e12" }),
76
+ );
77
+ if (typeof target !== "string" && (!Number.isFinite(target.x) || !Number.isFinite(target.y)))
78
+ return failWith(
79
+ operation,
80
+ new InvalidRequest({ detail: "point coordinates must be finite" }),
81
+ );
82
+
83
+ return readPoint(operation, target, approval, scroll).pipe(
84
+ Effect.flatMap((result) =>
85
+ "error" in result
86
+ ? pointFailure(operation, target, result)
87
+ : Effect.succeed(resolvedTarget(result)),
88
+ ),
89
+ );
90
+ };
91
+
92
+ const targetFor = Effect.fnUntraced(function* (
93
+ operation: string,
94
+ target: Target,
95
+ approval: Approval | undefined,
96
+ marks: InputMarks,
97
+ ) {
98
+ if (!marks.style.shown || typeof target !== "string")
99
+ return yield* resolve(operation, target, approval);
100
+
101
+ // Inspection and policy approval happen before this point. Every retry is geometry only;
102
+ // scrolling never obtains a fresh approval or silently follows a replacement document.
103
+ for (let attempt = 0; attempt < 3; attempt++) {
104
+ const result = yield* readPoint(operation, target, approval, false);
105
+
106
+ if (!("error" in result)) return resolvedTarget(result);
107
+ if (result.error !== "offscreen") return yield* pointFailure(operation, target, result);
108
+
109
+ const plan = yield* mutate(operation, scriptCall("scrollPlan", target), approval).pipe(
110
+ Effect.flatMap(decodeWith(operation, Script.ScrollPlanSchema)),
111
+ );
112
+
113
+ if (plan === null) break;
114
+ const size = yield* viewport.viewportFor(operation);
115
+ const point = { x: plan.x, y: plan.y };
116
+
117
+ yield* marks.at(point);
118
+ yield* moveTo(operation, marks, point);
119
+ const before = settling.mark();
120
+
121
+ yield* wheel(
122
+ operation,
123
+ marks,
124
+ point,
125
+ Math.max(-size.width * 0.9, Math.min(size.width * 0.9, plan.dx)),
126
+ Math.max(-size.height * 0.9, Math.min(size.height * 0.9, plan.dy)),
127
+ );
128
+ yield* flush(operation, marks.input);
129
+ yield* settling.settle(operation, before);
130
+ if (approval !== undefined) yield* approval.check();
131
+ }
132
+
133
+ // Wheels may be prevented or the target may need unsupported nested/frame geometry. One
134
+ // explicit fallback preserves reachability without a distance-dependent protocol loop.
135
+ yield* marks.touched;
136
+ const before = settling.mark();
137
+
138
+ yield* resolve(operation, target, approval);
139
+ yield* settling.settle(operation, before);
140
+ if (approval !== undefined) yield* approval.check();
141
+
142
+ return yield* resolve(operation, target, approval, false);
143
+ });
144
+
145
+ return { resolve, targetFor };
146
+ };
147
+
148
+ export type Targets = ReturnType<typeof make>;