effect-browser 0.2.0-beta.8 → 0.3.0-beta.10

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 (204) hide show
  1. package/README.md +849 -1143
  2. package/dist/Browser-S4kvpmqa.mjs +605 -0
  3. package/dist/Browser-S4kvpmqa.mjs.map +1 -0
  4. package/dist/Browser.d.mts +127 -3
  5. package/dist/Browser.mjs +2 -22
  6. package/dist/BrowserError-Coy7EW1N.d.mts +140 -0
  7. package/dist/BrowserError.d.mts +2 -0
  8. package/dist/BrowserError.mjs +211 -0
  9. package/dist/BrowserError.mjs.map +1 -0
  10. package/dist/BrowserEvent-BRu2juEz.d.mts +279 -0
  11. package/dist/BrowserEvent.d.mts +2 -0
  12. package/dist/BrowserEvent.mjs +297 -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-iMlcuzVD.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-B5cjaUp5.mjs +618 -0
  39. package/dist/Page-B5cjaUp5.mjs.map +1 -0
  40. package/dist/Page-BlPjfibv.d.mts +595 -0
  41. package/dist/Page.d.mts +2 -0
  42. package/dist/Page.mjs +2 -0
  43. package/dist/Plan-BI8XGvDu.d.mts +209 -0
  44. package/dist/Plan-D1wrJVs8.mjs +508 -0
  45. package/dist/Plan-D1wrJVs8.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 +64 -0
  59. package/dist/Stage.mjs +237 -0
  60. package/dist/Stage.mjs.map +1 -0
  61. package/dist/Supervisor-CYWFK3vM.d.mts +280 -0
  62. package/dist/Supervisor-TNKNOVzu.mjs +599 -0
  63. package/dist/Supervisor-TNKNOVzu.mjs.map +1 -0
  64. package/dist/Supervisor.d.mts +2 -0
  65. package/dist/Supervisor.mjs +2 -0
  66. package/dist/context-B6xr8nVi.mjs +261 -0
  67. package/dist/context-B6xr8nVi.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-B8tUQELg.mjs +5073 -0
  73. package/dist/page-B8tUQELg.mjs.map +1 -0
  74. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  75. package/dist/url-Cd04Okid.mjs +264 -0
  76. package/dist/url-Cd04Okid.mjs.map +1 -0
  77. package/package.json +96 -61
  78. package/src/Browser.ts +666 -0
  79. package/src/BrowserError.ts +234 -0
  80. package/src/BrowserEvent.ts +296 -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 +522 -0
  89. package/src/Plan.ts +663 -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 +467 -0
  94. package/src/Supervisor.ts +599 -0
  95. package/src/index.ts +20 -0
  96. package/src/internal/input/actions.ts +610 -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/gate.ts +224 -0
  101. package/src/internal/input/guard.inpage.ts +605 -0
  102. package/src/internal/input/guard.ts +184 -0
  103. package/src/internal/input/keyboard.ts +112 -0
  104. package/src/internal/input/keys.ts +127 -0
  105. package/src/internal/input/perform.ts +376 -0
  106. package/src/internal/input/pointer.ts +153 -0
  107. package/src/internal/input/replies.ts +195 -0
  108. package/src/internal/input/settle.ts +50 -0
  109. package/src/internal/input/style.ts +186 -0
  110. package/src/internal/input/targets.inpage.ts +293 -0
  111. package/src/internal/input/targets.ts +148 -0
  112. package/src/internal/page/bridge.ts +386 -0
  113. package/src/internal/page/context.ts +312 -0
  114. package/src/internal/page/lane.ts +311 -0
  115. package/src/internal/page/navigation.ts +183 -0
  116. package/src/internal/page/openings.ts +87 -0
  117. package/src/internal/page/page.ts +165 -0
  118. package/src/internal/page/url.ts +100 -0
  119. package/src/internal/page/viewport.ts +43 -0
  120. package/src/internal/pictures/capture.ts +776 -0
  121. package/src/internal/pictures/clock.ts +141 -0
  122. package/src/internal/pictures/counts.ts +92 -0
  123. package/src/internal/pictures/pictures.ts +520 -0
  124. package/src/internal/pictures/transport.ts +242 -0
  125. package/src/internal/reading/choose.ts +100 -0
  126. package/src/internal/reading/context.inpage.ts +201 -0
  127. package/src/internal/reading/controls.inpage.ts +158 -0
  128. package/src/internal/reading/expect.inpage.ts +70 -0
  129. package/src/internal/reading/match.inpage.ts +92 -0
  130. package/src/internal/reading/names.inpage.ts +306 -0
  131. package/src/internal/reading/outline.inpage.ts +260 -0
  132. package/src/internal/reading/reading.ts +223 -0
  133. package/src/internal/reading/ready.inpage.ts +164 -0
  134. package/src/internal/reading/ready.ts +111 -0
  135. package/src/internal/reading/subjects.inpage.ts +188 -0
  136. package/src/internal/reading/text.inpage.ts +135 -0
  137. package/src/internal/reading/walk.inpage.ts +220 -0
  138. package/src/internal/supervisor/lifecycle.ts +416 -0
  139. package/src/internal/timeline/changes.inpage.ts +216 -0
  140. package/src/internal/timeline/changes.ts +100 -0
  141. package/src/internal/timeline/events.ts +94 -0
  142. package/src/internal/timeline/fold.inpage.ts +123 -0
  143. package/src/internal/timeline/history.inpage.ts +229 -0
  144. package/src/internal/timeline/marks.inpage.ts +207 -0
  145. package/src/internal/timeline/record.inpage.ts +133 -0
  146. package/src/internal/timeline/sight.inpage.ts +122 -0
  147. package/src/internal/timeline/window.ts +96 -0
  148. package/src/internal/timeline/words.ts +65 -0
  149. package/dist/Bootstrap-B9UUXsvF.d.mts +0 -183
  150. package/dist/Bootstrap-CeQOWBy6.mjs +0 -189
  151. package/dist/Bootstrap-CeQOWBy6.mjs.map +0 -1
  152. package/dist/Bootstrap.d.mts +0 -3
  153. package/dist/Bootstrap.mjs +0 -2
  154. package/dist/Browser-n-bKgBNN.d.mts +0 -372
  155. package/dist/Browser.mjs.map +0 -1
  156. package/dist/BrowserData-CPIIIHol.d.mts +0 -585
  157. package/dist/BrowserData.d.mts +0 -3
  158. package/dist/BrowserData.mjs +0 -695
  159. package/dist/BrowserData.mjs.map +0 -1
  160. package/dist/BrowserRuntime-CFe9uUTv.d.mts +0 -148
  161. package/dist/BrowserRuntime-HJ1yYlFS.mjs +0 -6741
  162. package/dist/BrowserRuntime-HJ1yYlFS.mjs.map +0 -1
  163. package/dist/BrowserRuntime.d.mts +0 -3
  164. package/dist/BrowserRuntime.mjs +0 -3
  165. package/dist/Capture.d.mts +0 -56
  166. package/dist/Capture.mjs +0 -678
  167. package/dist/Capture.mjs.map +0 -1
  168. package/dist/CaptureData-CYriZHX1.mjs +0 -201
  169. package/dist/CaptureData-CYriZHX1.mjs.map +0 -1
  170. package/dist/CaptureData.d.mts +0 -219
  171. package/dist/CaptureData.mjs +0 -2
  172. package/dist/Errors.d.mts +0 -158
  173. package/dist/Errors.mjs +0 -207
  174. package/dist/Errors.mjs.map +0 -1
  175. package/dist/Images-CaaPNeed.mjs +0 -76
  176. package/dist/Images-CaaPNeed.mjs.map +0 -1
  177. package/dist/OperationOptions-DAeRS6O9.mjs +0 -55
  178. package/dist/OperationOptions-DAeRS6O9.mjs.map +0 -1
  179. package/dist/PageControl.d.mts +0 -15
  180. package/dist/PageControl.mjs +0 -34
  181. package/dist/PageControl.mjs.map +0 -1
  182. package/dist/PageRegistry-B3q9DS3g.mjs +0 -164
  183. package/dist/PageRegistry-B3q9DS3g.mjs.map +0 -1
  184. package/dist/Plan-D8qQ48Ic.mjs +0 -512
  185. package/dist/Plan-D8qQ48Ic.mjs.map +0 -1
  186. package/dist/PlanData-B668RxbQ.d.mts +0 -4831
  187. package/dist/PlanData.d.mts +0 -3
  188. package/dist/PlanData.mjs +0 -268
  189. package/dist/PlanData.mjs.map +0 -1
  190. package/dist/PlanInput-BJcPPncs.mjs +0 -75
  191. package/dist/PlanInput-BJcPPncs.mjs.map +0 -1
  192. package/dist/PublicSession-Bs61Eal8.mjs +0 -5601
  193. package/dist/PublicSession-Bs61Eal8.mjs.map +0 -1
  194. package/dist/Testing.d.mts +0 -479
  195. package/dist/Testing.mjs +0 -2147
  196. package/dist/Testing.mjs.map +0 -1
  197. package/dist/Timeline-fV22Xk-S.d.mts +0 -29
  198. package/dist/Timeline.d.mts +0 -4
  199. package/dist/Timeline.mjs +0 -20
  200. package/dist/Timeline.mjs.map +0 -1
  201. package/dist/TimelineData-BsOn-YGZ.d.mts +0 -3818
  202. package/dist/TimelineData.d.mts +0 -3
  203. package/dist/TimelineData.mjs +0 -482
  204. package/dist/TimelineData.mjs.map +0 -1
@@ -0,0 +1,207 @@
1
+ /**
2
+ * In the page: what one batch of mutations changed, marked for the record as cheaply as it can be.
3
+ *
4
+ * - **An element whose own text changed.** One that holds only text, as most prices and labels do,
5
+ * is read whole at once, since that text is all it shows, and the batch is undone to say what it
6
+ * said before; any other is read as it renders. Once followed, a text-only element's changes are
7
+ * taken as its text's, with no look at the nodes that came and went.
8
+ * - **An element added** is read as it renders; one that moved in the same batch did not arrive.
9
+ * What came with text its parent gained is part of the parent's change, and what takes a removed
10
+ * element's place, as a re-rendered price does, takes its history. **An element removed** is
11
+ * noted only if the record follows it, so a removal it never saw is not told.
12
+ * - **`hidden`, `open`, `aria-hidden`, `class` or `style`** may show or hide an element. The old
13
+ * value says whether it was hidden before, except for a class, where only the element's state as
14
+ * the record last saw it can: what it follows, what alerts, status lines, dialogs and popovers
15
+ * showed when it started, and what an earlier class change showed. A style that neither hid it
16
+ * nor hides it, such as a transform, shows nothing new.
17
+ * - **The document's title.**
18
+ *
19
+ * See `names.inpage.ts` for what a page-side part may use.
20
+ */
21
+ import type { Names } from "../reading/names.inpage.ts";
22
+ import type { Shown } from "./fold.inpage.ts";
23
+ import type { History } from "./history.inpage.ts";
24
+ import type { Sight } from "./sight.inpage.ts";
25
+
26
+ interface Batch {
27
+ readonly at: number;
28
+ readonly owners: Set<Element>;
29
+ readonly arrived: Set<Node>;
30
+ readonly left: Set<Node>;
31
+ }
32
+
33
+ export const marks = (names: Names, kept: History, seeing: Sight) => {
34
+ const { clean, isElement, isHtml } = names;
35
+ const unwritten = new Set("SCRIPT STYLE NOSCRIPT TEMPLATE TITLE".split(" "));
36
+ const hiding = /display\s*:\s*none|visibility\s*:\s*hidden|opacity\s*:\s*0(?![.\d])/i;
37
+ // Where removed elements were, which gives their context, and the title as last seen.
38
+ let places = new WeakMap<object, Element>();
39
+ let title = "";
40
+
41
+ const words = (text: string | null | undefined): Shown => clean(text, 200) || null;
42
+
43
+ const content = (element: Element, at: number, shown: Shown, initial: () => Shown) => {
44
+ const sample = kept.note(element, "content", at, shown, initial);
45
+
46
+ if (sample !== undefined) seeing.look(element, sample);
47
+ };
48
+
49
+ /** What a text-only element said before this batch: its text with the batch undone. */
50
+ const before = (leaf: Element, records: ReadonlyArray<MutationRecord>): Shown => {
51
+ let nodes: Array<Node> = Array.from(leaf.childNodes);
52
+ const old = new Map<Node, string>();
53
+
54
+ for (const record of records.toReversed())
55
+ if (record.type === "characterData") old.set(record.target, record.oldValue ?? "");
56
+ else {
57
+ const added = new Set(Array.from(record.addedNodes));
58
+
59
+ nodes = nodes.filter((node) => !added.has(node));
60
+ const next = record.nextSibling === null ? -1 : nodes.indexOf(record.nextSibling);
61
+
62
+ nodes.splice(next === -1 ? nodes.length : next, 0, ...Array.from(record.removedNodes));
63
+ }
64
+
65
+ return words(nodes.map((node) => old.get(node) ?? node.textContent).join(""));
66
+ };
67
+
68
+ const attribute = (
69
+ element: Element,
70
+ { attributeName: name, oldValue: old }: MutationRecord,
71
+ at: number,
72
+ ) => {
73
+ // Whether the old value hid it; a class can say only through the page's style.
74
+ const was =
75
+ name === "hidden"
76
+ ? old !== null
77
+ : name === "aria-hidden"
78
+ ? old === "true"
79
+ : name === "open"
80
+ ? element.tagName === "DIALOG" && old === null
81
+ : name === "style" && hiding.test(old ?? "");
82
+
83
+ if (name === "style" && !was && !hiding.test(element.getAttribute("style") ?? "")) return;
84
+ const state = seeing.state(element);
85
+
86
+ if (!kept.has(element) && !was && state === undefined && name !== "open")
87
+ return seeing.glance(element);
88
+ content(element, at, undefined, () =>
89
+ was || state === false
90
+ ? null
91
+ : name === "open" && old === null
92
+ ? words(element.querySelector(":scope > summary")?.textContent)
93
+ : words(element.textContent),
94
+ );
95
+ };
96
+
97
+ /** What one mutation added to and removed from `target`. */
98
+ const nodes = (change: MutationRecord, target: Element, batch: Batch) => {
99
+ const added = Array.from(change.addedNodes).slice(0, 64);
100
+ const removed = Array.from(change.removedNodes).slice(0, 64);
101
+
102
+ const text = [...added, ...removed].some(
103
+ (node) => !isElement(node) && /\S/.test(node.textContent ?? ""),
104
+ );
105
+
106
+ kept.lose(batch.at, Math.max(change.addedNodes.length, change.removedNodes.length) - 64);
107
+ if (text) batch.owners.add(target);
108
+ removed.forEach((gone, index) => {
109
+ const node = added[index];
110
+
111
+ if (!isElement(gone)) return;
112
+ batch.left.add(gone);
113
+ if (node !== undefined && isElement(node) && kept.rekey(gone, node)) seeing.unwatch(gone);
114
+ else if (kept.has(gone)) {
115
+ places.set(gone, target);
116
+ const sample = kept.note(gone, "content", batch.at, null, () => undefined);
117
+
118
+ if (sample !== undefined) sample.seen = seeing.viewed(gone);
119
+ seeing.unwatch(gone);
120
+ }
121
+ });
122
+ for (const node of added)
123
+ if (
124
+ isElement(node) &&
125
+ isHtml(node) &&
126
+ !unwritten.has(node.tagName) &&
127
+ !node.isContentEditable
128
+ )
129
+ if (kept.has(node) || (!text && !batch.left.has(node))) {
130
+ batch.arrived.add(node);
131
+ content(node, batch.at, undefined, () => null);
132
+ }
133
+ };
134
+
135
+ /** Whether `element` is inside something this batch added, which tells it with its own words. */
136
+ const inside = (element: Element, arrived: ReadonlySet<Node>): boolean => {
137
+ for (let up = element.parentElement; arrived.size > 0 && up !== null; up = up.parentElement)
138
+ if (arrived.has(up)) return true;
139
+
140
+ return false;
141
+ };
142
+
143
+ const ownerOf = (change: MutationRecord): Node | null =>
144
+ change.type === "characterData" ? change.target.parentElement : change.target;
145
+
146
+ /** Mark what one batch of mutations changed, at `at`. */
147
+ const mark = (records: ReadonlyArray<MutationRecord>, at: number) => {
148
+ const batch: Batch = { at, owners: new Set(), arrived: new Set(), left: new Set() };
149
+ let byOwner: Map<Node | null, Array<MutationRecord>> | undefined;
150
+
151
+ for (const change of records) {
152
+ const target = ownerOf(change);
153
+
154
+ if (target === null || !isElement(target) || inside(target, batch.arrived)) continue;
155
+ // A text-only element the record follows, or has let go, changes only its text, as a
156
+ // ticking price does, so its nodes need no look: each would cost this world a wrapper.
157
+ const known = kept.has(target) || kept.refuses(target);
158
+
159
+ if (change.type !== "attributes")
160
+ if (change.type === "characterData" || (target.childElementCount === 0 && known))
161
+ batch.owners.add(target);
162
+ else nodes(change, target, batch);
163
+ else if (isHtml(target) && target !== document.body && target !== document.documentElement)
164
+ attribute(target, change, at);
165
+ }
166
+ for (const owner of batch.owners) {
167
+ const leaf = owner.childElementCount === 0;
168
+
169
+ // What the record let go is counted, with no word of it read.
170
+ if (kept.refuses(owner)) kept.lose(at, 1);
171
+ if (kept.refuses(owner) || !isHtml(owner) || unwritten.has(owner.tagName)) continue;
172
+ // What is typed into an editable region is a field's value, told masked as one.
173
+ if (owner === document.body || owner.isContentEditable) continue;
174
+ if (batch.arrived.has(owner) || inside(owner, batch.arrived)) continue;
175
+ content(owner, at, leaf ? words(owner.textContent) : undefined, () => {
176
+ byOwner ??= Map.groupBy(records, ownerOf);
177
+
178
+ return leaf ? before(owner, byOwner.get(owner) ?? []) : undefined;
179
+ });
180
+ }
181
+ if (document.title === title) return;
182
+ const previous = words(title);
183
+
184
+ const sample = kept.note(
185
+ document,
186
+ "title",
187
+ at,
188
+ words((title = document.title)),
189
+ () => previous,
190
+ );
191
+
192
+ if (sample !== undefined) sample.seen = true;
193
+ };
194
+
195
+ return {
196
+ mark,
197
+ /** Where a removed element was, which gives its context. */
198
+ placeOf: (key: object): Element | undefined => places.get(key),
199
+ /** Start afresh, from the document's title now. */
200
+ reset: () => {
201
+ places = new WeakMap();
202
+ title = document.title;
203
+ },
204
+ };
205
+ };
206
+
207
+ export type Marks = ReturnType<typeof marks>;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * In the page: the change recorder. It starts at a read of the page's changes, and from then on at
3
+ * the start of each document the page loads, once that document is parsed; it stops in a document
4
+ * nobody has read for two minutes. While it runs, a MutationObserver hands each batch to
5
+ * `marks.inpage.ts`, and an IntersectionObserver judges as the page renders whether what was marked
6
+ * was in view (`sight.inpage.ts`). A field reports its value as it is edited, a secret one only as
7
+ * `••••`, and trusted pointer and key input is kept, for a read to name causes by. Nothing unmarked
8
+ * is read: no walk, no words, no layout. See `names.inpage.ts` for what a page-side part may use.
9
+ */
10
+ import type { Names } from "../reading/names.inpage.ts";
11
+ import type { Texts } from "../reading/text.inpage.ts";
12
+ import type { History } from "./history.inpage.ts";
13
+ import type { Marks } from "./marks.inpage.ts";
14
+ import type { Sight } from "./sight.inpage.ts";
15
+
16
+ export const record = (
17
+ names: Names,
18
+ texts: Texts,
19
+ kept: History,
20
+ seeing: Sight,
21
+ marking: Marks,
22
+ ) => {
23
+ const { isElement, isInput } = names;
24
+ const epoch = () => performance.timeOrigin + performance.now();
25
+
26
+ const declared =
27
+ "[role=alert],[role=status],[role=log],[role=dialog],[role=alertdialog],[aria-live],dialog,[popover]";
28
+
29
+ let running = false;
30
+ let lastRead = 0;
31
+ // What fields held as they took focus, and the latest trusted input, newest last.
32
+ let focused = new WeakMap<Element, string>();
33
+ const inputs: Array<{ readonly at: number; readonly target: Element }> = [];
34
+
35
+ const targetOf = (event: Event): Element | undefined => {
36
+ const target = event.composedPath()[0];
37
+
38
+ return target instanceof Node && isElement(target) ? target : undefined;
39
+ };
40
+
41
+ /** A field's value as the record keeps it: a secret one's as `••••`, a box's checked or not. */
42
+ const valueOf = (field: Element): string | undefined =>
43
+ isInput(field) && (field.type === "checkbox" || field.type === "radio")
44
+ ? field.checked
45
+ ? "checked"
46
+ : "not checked"
47
+ : texts.shown(field, true);
48
+
49
+ const onFocus = (event: Event) => {
50
+ const field = targetOf(event);
51
+ const value = field === undefined ? undefined : valueOf(field);
52
+
53
+ if (field !== undefined && value !== undefined) focused.set(field, value);
54
+ };
55
+
56
+ const onValue = (event: Event) => {
57
+ const field = targetOf(event);
58
+ const value = field === undefined ? undefined : valueOf(field);
59
+
60
+ if (field === undefined || value === undefined) return;
61
+ const sample = kept.note(field, "value", epoch(), value, () => focused.get(field) ?? null);
62
+
63
+ if (sample !== undefined) seeing.look(field, sample);
64
+ };
65
+
66
+ const onInput = (event: Event) => {
67
+ const target = targetOf(event);
68
+
69
+ if (!event.isTrusted || target === undefined) return;
70
+ inputs.push({ at: performance.timeOrigin + event.timeStamp, target });
71
+ if (inputs.length > 8) inputs.shift();
72
+ };
73
+
74
+ const listeners = [
75
+ ["focusin", onFocus],
76
+ ["input", onValue],
77
+ ["change", onValue],
78
+ ["pointerdown", onInput],
79
+ ["keydown", onInput],
80
+ ] as const;
81
+
82
+ const options = { capture: true, passive: true };
83
+
84
+ const stop = () => {
85
+ running = false;
86
+ observer.disconnect();
87
+ for (const [type, listener] of listeners) document.removeEventListener(type, listener, options);
88
+ seeing.clear();
89
+ kept.clear();
90
+ kept.forgotten();
91
+ inputs.length = 0;
92
+ focused = new WeakMap();
93
+ };
94
+
95
+ const observer = new MutationObserver((records) => {
96
+ const at = epoch();
97
+
98
+ if (at - lastRead > 120_000) stop();
99
+ else marking.mark(records, at);
100
+ });
101
+
102
+ const begin = () => {
103
+ if (!running || kept.begun()) return;
104
+ observer.observe(document, {
105
+ subtree: true,
106
+ childList: true,
107
+ characterData: true,
108
+ characterDataOldValue: true,
109
+ attributeFilter: ["hidden", "open", "aria-hidden", "class", "style"],
110
+ attributeOldValue: true,
111
+ });
112
+ for (const [type, listener] of listeners) document.addEventListener(type, listener, options);
113
+ marking.reset();
114
+ for (const element of Array.from(document.querySelectorAll(declared)).slice(0, 64))
115
+ seeing.glance(element, true);
116
+ kept.begin(epoch());
117
+ };
118
+
119
+ return {
120
+ /** Record from now, once the document is parsed; each read keeps the record going. */
121
+ start: () => {
122
+ lastRead = epoch();
123
+ if (running) return;
124
+ running = true;
125
+ if (document.readyState === "loading")
126
+ document.addEventListener("DOMContentLoaded", begin, { once: true });
127
+ else begin();
128
+ },
129
+ inputs: (): ReadonlyArray<{ readonly at: number; readonly target: Element }> => inputs,
130
+ };
131
+ };
132
+
133
+ export type Recorder = ReturnType<typeof record>;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * In the page: whether what the recorder marked was in view, and what it showed, judged as the page
3
+ * renders it. An IntersectionObserver watches each element the record follows: it reports as the
4
+ * page renders, forces no layout, and leaves a removed element the state it last reported. What
5
+ * changed counts as seen when it was in the viewport and its style showed it; what went counts as
6
+ * seen when it was in view before it went. Words that need the page's layout are read on render
7
+ * too, as `text` reads an element, by the walk's one rule for what shows and with fields masked.
8
+ * An element whose state alone the record wants, as for a class change on something it does not
9
+ * follow, is looked at once. See `names.inpage.ts` for what a page-side part may use.
10
+ */
11
+ import type { Names } from "../reading/names.inpage.ts";
12
+ import type { Texts } from "../reading/text.inpage.ts";
13
+ import type { Walk } from "../reading/walk.inpage.ts";
14
+ import type { Sample, Shown } from "./fold.inpage.ts";
15
+ import type { History } from "./history.inpage.ts";
16
+
17
+ export const sight = (names: Names, walked: Walk, texts: Texts, kept: History) => {
18
+ const { clean } = names;
19
+ const { visible } = walked;
20
+ // Whether each watched element was in view as the page last rendered it, and whether the style
21
+ // of one looked at once showed it.
22
+ let viewed = new WeakMap<Element, boolean>();
23
+ let watching = new WeakSet<Element>();
24
+ let states = new WeakMap<Element, boolean>();
25
+ const waiting = new Map<Element, Array<Sample>>();
26
+
27
+ /** What an element shows: its words, if its style shows it. */
28
+ const shownBy = (element: Element): Shown =>
29
+ element.isConnected && visible(element)
30
+ ? clean(texts.lines(element, false), 200) || null
31
+ : null;
32
+
33
+ /** Settle what waited on an element; its words are read once, if one of them needs them. */
34
+ const judge = (element: Element, pending: ReadonlyArray<Sample>, seen: boolean, was: boolean) => {
35
+ const shown = pending.some((sample) => sample.shown === undefined)
36
+ ? shownBy(element)
37
+ : undefined;
38
+
39
+ for (const sample of pending) {
40
+ const now = sample.shown === undefined ? shown : sample.shown;
41
+
42
+ kept.settle(element, sample, now, now === null ? was : seen);
43
+ }
44
+ };
45
+
46
+ const observer = new IntersectionObserver((entries) => {
47
+ for (const { target, isIntersecting } of entries) {
48
+ const was = viewed.get(target) ?? false;
49
+ const seen = isIntersecting && visible(target);
50
+ const pending = waiting.get(target);
51
+
52
+ viewed.set(target, seen);
53
+ waiting.delete(target);
54
+ if (pending !== undefined) judge(target, pending, seen, was);
55
+ if (!kept.has(target)) {
56
+ states.set(target, visible(target));
57
+ unwatch(target);
58
+ }
59
+ }
60
+ });
61
+
62
+ /** Watch an element, asking for a fresh report even if it is watched already. */
63
+ const watch = (element: Element) => {
64
+ watching.add(element);
65
+ observer.unobserve(element);
66
+ observer.observe(element);
67
+ };
68
+
69
+ const unwatch = (element: Element) => {
70
+ watching.delete(element);
71
+ observer.unobserve(element);
72
+ };
73
+
74
+ return {
75
+ /** Judge a sample at once if what it shows and whether it is in view are known, else on render. */
76
+ look: (element: Element, sample: Sample) => {
77
+ const view = viewed.get(element);
78
+
79
+ if (sample.shown !== undefined && view !== undefined && watching.has(element)) {
80
+ sample.seen = view;
81
+
82
+ return;
83
+ }
84
+ waiting.set(element, [...(waiting.get(element) ?? []), sample]);
85
+ watch(element);
86
+ },
87
+ /**
88
+ * Look at an element once, for its state when it next changes: as it renders, or `now`, by its
89
+ * style, as for the few the record looks at as it starts.
90
+ */
91
+ glance: (element: Element, now = false) => {
92
+ if (now) states.set(element, visible(element));
93
+ if (!watching.has(element)) watch(element);
94
+ },
95
+ /** Whether the style of an element looked at once showed it, if it was. */
96
+ state: (element: Element): boolean | undefined => states.get(element),
97
+ /** Whether a watched element was in view as the page last rendered it. */
98
+ viewed: (element: Element): boolean => viewed.get(element) ?? false,
99
+ unwatch,
100
+ /** Judge now what the page has not rendered since it changed, as a hidden tab never does. */
101
+ settle: () => {
102
+ for (const [element, pending] of waiting) {
103
+ const seen =
104
+ element.isConnected && walked.inView(element.getBoundingClientRect()) && visible(element);
105
+
106
+ judge(element, pending, seen, viewed.get(element) ?? false);
107
+ viewed.set(element, seen);
108
+ }
109
+ waiting.clear();
110
+ for (const key of kept.forgotten()) if (key instanceof Element) unwatch(key);
111
+ },
112
+ clear: () => {
113
+ observer.disconnect();
114
+ waiting.clear();
115
+ viewed = new WeakMap();
116
+ watching = new WeakSet();
117
+ states = new WeakMap();
118
+ },
119
+ };
120
+ };
121
+
122
+ export type Sight = ReturnType<typeof sight>;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * A page's window over its three tracks on the browser's host clock: the events and frames it
3
+ * keeps, at no call, and one read of its changes, the first starting its record. A read that fails
4
+ * is missing, with why, and the window is still made.
5
+ */
6
+ import { Duration, Effect, Option, Result, Schema } from "effect";
7
+
8
+ import { type BrowserError, InvalidRequest } from "../../BrowserError.ts";
9
+ import { type BrowserEvent, TrackEvent } from "../../BrowserEvent.ts";
10
+ import type { Changes } from "../../Change.ts";
11
+ import { Frame } from "../../Frame.ts";
12
+ import { Window } from "../../Moment.ts";
13
+ import type { ChangesOptions, WindowOptions } from "../../Page.ts";
14
+ import { failWith, type PageContext } from "../page/context.ts";
15
+
16
+ const isWindow = Schema.is(Window);
17
+ const isFrame = Schema.is(Frame);
18
+ const isTrackEvent = Schema.is(TrackEvent);
19
+
20
+ /**
21
+ * Where a window asked at `now` starts and ends: never after now, nor starting after it ends.
22
+ * Nothing for a bound that is not finite, or a negative reach back.
23
+ */
24
+ export const boundsOf = ({ since, until }: Pick<WindowOptions, "since" | "until">, now: number) => {
25
+ const end = until === undefined ? now : isFrame(until) ? until.hostTime : Math.min(until, now);
26
+
27
+ const start = isWindow(since)
28
+ ? since.until
29
+ : isFrame(since)
30
+ ? since.hostTime
31
+ : !Duration.isDuration(since)
32
+ ? since
33
+ : Duration.isNegative(since)
34
+ ? Number.NaN
35
+ : end - Duration.toMillis(since);
36
+
37
+ return Number.isFinite(start) && Number.isFinite(end)
38
+ ? Option.some({ since: Math.min(start, end), until: end })
39
+ : Option.none();
40
+ };
41
+
42
+ /**
43
+ * A window's share of the kept tracks: events after its start, frames painted from it, both up to
44
+ * its end, so consecutive windows hold each event once and the next begins with the frame the last
45
+ * ended on. Pointer, key and wheel input is the presentation track, not an event here.
46
+ */
47
+ export const within = (
48
+ { since, until }: { readonly since: number; readonly until: number },
49
+ events: ReadonlyArray<BrowserEvent>,
50
+ frames: ReadonlyArray<Frame>,
51
+ ) => ({
52
+ events: events.filter((event) => event.at > since && event.at <= until && !isTrackEvent(event)),
53
+ frames: frames.filter((frame) => frame.hostTime >= since && frame.hostTime <= until),
54
+ });
55
+
56
+ export const make =
57
+ (
58
+ page: PageContext,
59
+ read: (options: ChangesOptions) => Effect.Effect<Changes, BrowserError>,
60
+ frames: Effect.Effect<ReadonlyArray<Frame>>,
61
+ ) =>
62
+ ({ since, until, unmask }: WindowOptions) =>
63
+ Effect.gen(function* () {
64
+ const asked = boundsOf({ since, until }, page.now());
65
+
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
+ );
73
+ const bounds = asked.value;
74
+
75
+ // A previous window's changes go on exactly where they ended, on the page's own clock, and a
76
+ // frame bounds them at its paint, so that they hold nothing it does not show.
77
+ const changes = yield* Effect.result(
78
+ read({
79
+ since: isWindow(since)
80
+ ? (since.changes ?? bounds.since)
81
+ : isFrame(since)
82
+ ? since
83
+ : bounds.since,
84
+ until: isFrame(until) ? until : bounds.until,
85
+ unmask,
86
+ }),
87
+ );
88
+
89
+ return new Window({
90
+ page: page.id,
91
+ ...bounds,
92
+ ...within(bounds, yield* page.recentEvents, yield* frames),
93
+ changes: Result.getOrUndefined(changes),
94
+ missing: Option.toArray(Result.getFailure(changes)),
95
+ });
96
+ }).pipe(page.span("Page.window"), page.owned);
@@ -0,0 +1,65 @@
1
+ /**
2
+ * What changed on a page, in words for a model: a change with where it is, and what it is. A moment
3
+ * tells its changes this way, and a tool's receipt the changes its action caused.
4
+ */
5
+ import type { Subject, SubjectContext } from "../../BrowserEvent.ts";
6
+ import type { Change } from "../../Change.ts";
7
+
8
+ export const quoted = (value: string | undefined) => JSON.stringify(value ?? "");
9
+
10
+ /** A subject in words, such as `button "Play"`; one without a name says where it was. */
11
+ export const named = (subject: Subject, point: string | undefined) => {
12
+ const kind = subject.role ?? subject.tag;
13
+
14
+ if (subject.name !== "") return `${kind} ${JSON.stringify(subject.name)}`;
15
+
16
+ return point === undefined ? kind : `${kind} at ${point}`;
17
+ };
18
+
19
+ /** A change's context in words: `row "Ether", column "1h"` or `beside "Price", under "Bitcoin"`. */
20
+ export const where = ({ row, column, label, heading }: SubjectContext, grouped = false) => {
21
+ const parts = [
22
+ row === undefined ? "" : `row ${quoted(row)}`,
23
+ column === undefined || grouped ? "" : `column ${quoted(column)}`,
24
+ label === undefined ? "" : `beside ${quoted(label)}`,
25
+ heading === undefined || grouped ? "" : `under ${quoted(heading)}`,
26
+ ].filter((part) => part !== "");
27
+
28
+ return parts.length === 0 ? "" : ` (${parts.join(", ")})`;
29
+ };
30
+
31
+ /** A change in words, such as `"$61,240" became "$62,010" (row "Bitcoin", column "Price")`. */
32
+ export const describe = (change: Change, grouped = false): string => {
33
+ const { subject, before, after, count, lowest, highest } = change;
34
+ const context = where(subject.context, grouped);
35
+
36
+ const range =
37
+ lowest === undefined || highest === undefined ? "" : `, from ${lowest} to ${highest}`;
38
+
39
+ const times = count > 1 ? ` (it changed ${count} times${range})` : "";
40
+
41
+ switch (change.kind) {
42
+ case "title":
43
+ return `the title ${before === undefined ? "now reads" : `${quoted(before)} became`} ${quoted(after)}`;
44
+ case "value": {
45
+ const field = named(subject, undefined);
46
+
47
+ if (after === "checked" || after === "not checked") return `${field} is now ${after}`;
48
+ if (after === undefined || after === "") return `${field} was cleared`;
49
+
50
+ return after === "••••" || before === undefined || before === ""
51
+ ? `${field} was ${after === "••••" ? "edited" : `set to ${quoted(after)}`}`
52
+ : `${field} changed from ${quoted(before)} to ${quoted(after)}`;
53
+ }
54
+ case "appeared":
55
+ return `${quoted(after)} appeared${context}`;
56
+ case "disappeared":
57
+ return `${quoted(before)} disappeared${context}`;
58
+ case "brief":
59
+ return `${quoted(after)} appeared and went away again${context}`;
60
+ case "text":
61
+ return before === undefined
62
+ ? `now reads ${quoted(after)}${context}${times}`
63
+ : `${quoted(before)} became ${quoted(after)}${context}${times}`;
64
+ }
65
+ };