@hachej/boring-feedback 0.0.0-stage → 0.2.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 (92) hide show
  1. package/INVARIANTS.md +35 -0
  2. package/LICENSE +21 -0
  3. package/README.md +20 -2
  4. package/dist/agent/capability.d.ts +52 -0
  5. package/dist/agent/capability.js +230 -0
  6. package/dist/agent/index.d.ts +4 -0
  7. package/dist/agent/index.js +5 -0
  8. package/dist/agent/preview.d.ts +10 -0
  9. package/dist/agent/preview.js +27 -0
  10. package/dist/format/index.d.ts +7 -0
  11. package/dist/format/index.js +6 -0
  12. package/dist/format/json.d.ts +30 -0
  13. package/dist/format/json.js +193 -0
  14. package/dist/format/preview.d.ts +28 -0
  15. package/dist/format/preview.js +45 -0
  16. package/dist/format/readable.d.ts +10 -0
  17. package/dist/format/readable.js +19 -0
  18. package/dist/format/report.d.ts +187 -0
  19. package/dist/format/report.js +669 -0
  20. package/dist/format/requests.d.ts +53 -0
  21. package/dist/format/requests.js +69 -0
  22. package/dist/page/app-element.d.ts +79 -0
  23. package/dist/page/app-element.js +451 -0
  24. package/dist/page/canaries.d.ts +46 -0
  25. package/dist/page/canaries.js +142 -0
  26. package/dist/page/index.d.ts +6 -0
  27. package/dist/page/index.js +8 -0
  28. package/dist/page/picker/index.d.ts +4 -0
  29. package/dist/page/picker/index.js +4 -0
  30. package/dist/page/picker/label.d.ts +16 -0
  31. package/dist/page/picker/label.js +58 -0
  32. package/dist/page/picker/overlay.d.ts +42 -0
  33. package/dist/page/picker/overlay.js +126 -0
  34. package/dist/page/picker/picker.d.ts +72 -0
  35. package/dist/page/picker/picker.js +386 -0
  36. package/dist/page/privacy/digest.d.ts +6 -0
  37. package/dist/page/privacy/digest.js +24 -0
  38. package/dist/page/privacy/index.d.ts +6 -0
  39. package/dist/page/privacy/index.js +5 -0
  40. package/dist/page/privacy/name.d.ts +8 -0
  41. package/dist/page/privacy/name.js +106 -0
  42. package/dist/page/privacy/policy.d.ts +65 -0
  43. package/dist/page/privacy/policy.js +125 -0
  44. package/dist/page/privacy/route.d.ts +6 -0
  45. package/dist/page/privacy/route.js +20 -0
  46. package/dist/page/privacy/serialize.d.ts +42 -0
  47. package/dist/page/privacy/serialize.js +90 -0
  48. package/dist/page/voice/align.d.ts +36 -0
  49. package/dist/page/voice/align.js +122 -0
  50. package/dist/page/voice/capture.d.ts +43 -0
  51. package/dist/page/voice/capture.js +186 -0
  52. package/dist/page/voice/index.d.ts +3 -0
  53. package/dist/page/voice/index.js +3 -0
  54. package/dist/preview/agent.d.ts +34 -0
  55. package/dist/preview/agent.js +91 -0
  56. package/dist/preview/index.d.ts +5 -0
  57. package/dist/preview/index.js +6 -0
  58. package/dist/preview/page.d.ts +50 -0
  59. package/dist/preview/page.js +293 -0
  60. package/dist/preview/session.d.ts +63 -0
  61. package/dist/preview/session.js +110 -0
  62. package/dist/source/index.d.ts +24 -0
  63. package/dist/source/index.js +59 -0
  64. package/dist/source/jsx-dev-runtime.d.ts +5 -0
  65. package/dist/source/jsx-dev-runtime.js +29 -0
  66. package/dist/store/index.d.ts +5 -0
  67. package/dist/store/index.js +4 -0
  68. package/dist/store/mentions.d.ts +17 -0
  69. package/dist/store/mentions.js +27 -0
  70. package/dist/store/store.d.ts +121 -0
  71. package/dist/store/store.js +353 -0
  72. package/dist/tickets/index.d.ts +5 -0
  73. package/dist/tickets/index.js +5 -0
  74. package/dist/tickets/sinks.d.ts +27 -0
  75. package/dist/tickets/sinks.js +84 -0
  76. package/dist/tickets/ticket.d.ts +93 -0
  77. package/dist/tickets/ticket.js +148 -0
  78. package/dist/ui/annotate.d.ts +103 -0
  79. package/dist/ui/annotate.js +154 -0
  80. package/dist/ui/index.d.ts +7 -0
  81. package/dist/ui/index.js +8 -0
  82. package/dist/ui/report.d.ts +51 -0
  83. package/dist/ui/report.js +58 -0
  84. package/dist/ui/save.d.ts +45 -0
  85. package/dist/ui/save.js +65 -0
  86. package/dist/ui/session.d.ts +188 -0
  87. package/dist/ui/session.js +787 -0
  88. package/dist/ui/show.d.ts +43 -0
  89. package/dist/ui/show.js +72 -0
  90. package/dist/ui/state.d.ts +11 -0
  91. package/dist/ui/state.js +21 -0
  92. package/package.json +98 -4
@@ -0,0 +1,386 @@
1
+ // Pick mode (FEEDBACK.md, "Pointing at elements: the picker"): the element under the pointer is boxed and labelled, ↑ or scrolling up
2
+ // selects the parent and ↓ (or scrolling down) the child again, click pins, Shift-click pins several, Esc cancels; Tab and Enter do the
3
+ // same from the keyboard; on touch a tap selects and the overlay's Parent, Child and Pin buttons replace the keys.
4
+ //
5
+ // Picking never activates the page: while pick mode is on, pointer, click, key and submit events are taken in the capture phase on the
6
+ // window and stopped, so pressing a Save button pins it without pressing it. Only the overlay (its touch toolbar) and subtrees marked
7
+ // `data-feedback-ignore` (the application's own chrome, the agent bar) keep their pointer events. Keys always belong to the picker.
8
+ // When pick mode ends, every listener is removed and focus returns to where it was.
9
+ //
10
+ // Only the given application root is pickable: elements outside it (portals), inside shadow roots or in iframes are never selected.
11
+ //
12
+ // Continuous picking (`onPick`, used by the feedback session): a click or Enter hands the element to `onPick` and pick mode stays on,
13
+ // so the person can pin one element after another. `wheel: false` leaves scrolling to the page, `keys: 'outside-ignored'` lets keys
14
+ // typed inside ignored subtrees (a note bubble) reach them, and `suspend(true)` lets every event through to the page until
15
+ // `suspend(false)` (the session's "hold Alt to use the app"). `tap: 'pick'` (point mode on phones) hands a tap straight to `onPick`
16
+ // with no toolbar: one tap, one pick; a finger that moved (a scroll) is not a tap.
17
+ import { isExcluded } from '../privacy/policy.js';
18
+ import { pickerLabel } from './label.js';
19
+ import { createOverlay } from './overlay.js';
20
+ /** Elements Tab moves between: what people can act on or name, plus marked elements. */
21
+ const TAB_STOPS = 'a[href],button,input,select,textarea,summary,[tabindex],[role],[contenteditable],h1,h2,h3,h4,h5,h6,label,img,table,[data-feedback-id],[data-testid]';
22
+ const POINTER_EVENTS = ['pointerdown', 'pointerup', 'pointercancel', 'mousedown', 'mouseup', 'click', 'dblclick', 'auxclick', 'contextmenu', 'touchstart', 'touchend', 'dragstart', 'selectstart'];
23
+ const KEY_EVENTS = ['keydown', 'keyup', 'keypress'];
24
+ /** How far (CSS px) a finger may move and still tap. */
25
+ const TAP_SLOP = 10;
26
+ /** Why an element cannot be picked, or undefined when it can. */
27
+ export function pickRefusal(element, root) {
28
+ if (element.ownerDocument !== root.ownerDocument)
29
+ return 'the element is in another document (an iframe)';
30
+ if (!element.isConnected)
31
+ return 'the element is not in the page';
32
+ if (element.getRootNode() !== element.ownerDocument)
33
+ return 'the element is inside a shadow root';
34
+ if (isExcluded(element))
35
+ return 'the element is inside an ignored subtree or the feedback overlay';
36
+ if (element !== root && !root.contains(element))
37
+ return 'the element is outside the application root';
38
+ return undefined;
39
+ }
40
+ export function createPicker(options) {
41
+ const { root, policy, onPick, wheel = true, keys = 'all' } = options;
42
+ const tapPicks = options.tap === 'pick' && onPick !== undefined;
43
+ const multiple = onPick ? false : options.multiple ?? true;
44
+ const document = root.ownerDocument;
45
+ const view = document.defaultView;
46
+ if (!view)
47
+ throw new TypeError('The application root must be in a document with a window');
48
+ const ownsOverlay = options.overlay === undefined;
49
+ const overlay = options.overlay ?? createOverlay({ document, ...(options.styles !== undefined ? { styles: options.styles } : {}) });
50
+ const hitTest = options.hitTest ?? ((x, y) => document.elementsFromPoint(x, y));
51
+ let mode = 'idle';
52
+ let current;
53
+ let refusal;
54
+ let pins = [];
55
+ let touch = false;
56
+ let suspended = false;
57
+ let lastPointer = 'mouse';
58
+ /** Where the finger went down, and whether this gesture already picked (pointerup and touchend of one tap count once). */
59
+ let fingerDown;
60
+ let tapDone = false;
61
+ /** The finger went further than TAP_SLOP from where it went down at some point of this gesture: a drag, even if it came back. */
62
+ let dragged = false;
63
+ const far = (where) => where !== undefined && fingerDown !== undefined && Math.hypot(where.x - fingerDown.x, where.y - fingerDown.y) > TAP_SLOP;
64
+ /** Children left by ↑, so ↓ returns along the same path. */
65
+ let trail = [];
66
+ let focus = null;
67
+ let settle;
68
+ const removers = [];
69
+ const state = () => Object.freeze({
70
+ mode, pins: Object.freeze([...pins]), touch, suspended,
71
+ ...(current ? { current, label: pickerLabel(current, policy) } : {}),
72
+ ...(refusal !== undefined ? { refusal } : {}),
73
+ });
74
+ const redraw = () => {
75
+ const boxes = pins.map(element => ({ element, label: pickerLabel(element, policy), tone: 'pinned' }));
76
+ if (mode === 'picking' && !suspended && current && !pins.includes(current))
77
+ boxes.push({ element: current, label: pickerLabel(current, policy), tone: 'hover' });
78
+ overlay.draw(boxes);
79
+ overlay.toolbar(mode === 'picking' && !suspended && touch && !tapPicks ? {
80
+ parent: () => move('up'), child: () => move('down'), pin: () => pin(), cancel: () => end({ kind: 'cancelled' }),
81
+ ...(pins.length ? { done: () => finish() } : {}),
82
+ } : undefined);
83
+ };
84
+ const changed = () => { redraw(); options.onChange?.(state()); };
85
+ const pickable = (element) => pickRefusal(element, root) === undefined;
86
+ const choose = (element, keepTrail = false) => {
87
+ if (!keepTrail)
88
+ trail = [];
89
+ current = element;
90
+ refusal = undefined;
91
+ changed();
92
+ };
93
+ const select = (element) => {
94
+ if (mode !== 'picking' || !pickable(element))
95
+ return false;
96
+ choose(element);
97
+ return true;
98
+ };
99
+ /** The topmost pickable element at a point, skipping the overlay and ignored subtrees; anything else on top refuses the point. */
100
+ const at = (x, y) => {
101
+ for (const element of hitTest(x, y)) {
102
+ if (element === overlay.host || isExcluded(element))
103
+ continue;
104
+ const reason = pickRefusal(element, root);
105
+ return reason === undefined ? { element } : { refusal: reason };
106
+ }
107
+ return {};
108
+ };
109
+ const hover = (x, y) => {
110
+ const found = at(x, y);
111
+ if (found.element === current && found.refusal === refusal)
112
+ return;
113
+ trail = [];
114
+ current = found.element;
115
+ refusal = found.refusal;
116
+ changed();
117
+ };
118
+ const move = (direction) => {
119
+ if (mode !== 'picking' || !current)
120
+ return;
121
+ if (direction === 'up') {
122
+ const parent = current.parentElement;
123
+ if (!parent || current === root || !pickable(parent))
124
+ return;
125
+ trail.push(current);
126
+ choose(parent, true);
127
+ return;
128
+ }
129
+ const back = trail.pop();
130
+ const next = back ?? Array.from(current.children).find(pickable);
131
+ if (next)
132
+ choose(next, true);
133
+ };
134
+ const stops = () => Array.from(root.querySelectorAll(TAB_STOPS)).filter(element => pickable(element) && !element.closest('[hidden]'));
135
+ const tab = (backward) => {
136
+ const list = stops();
137
+ if (!list.length)
138
+ return;
139
+ const index = current ? list.indexOf(current) : -1;
140
+ const next = index < 0 ? (backward ? list.length - 1 : 0) : (index + (backward ? -1 : 1) + list.length) % list.length;
141
+ choose(list[next]);
142
+ };
143
+ const pin = ({ add = false } = {}) => {
144
+ if (mode !== 'picking' || suspended || !current)
145
+ return;
146
+ if (onPick) {
147
+ onPick(current);
148
+ return;
149
+ }
150
+ if (add && multiple) {
151
+ pins = pins.includes(current) ? pins.filter(element => element !== current) : [...pins, current];
152
+ changed();
153
+ return;
154
+ }
155
+ if (!pins.includes(current))
156
+ pins = [...pins, current];
157
+ finish();
158
+ };
159
+ const finish = () => {
160
+ if (mode !== 'picking')
161
+ return;
162
+ if (!pins.length && current)
163
+ pins = [current];
164
+ if (!pins.length) {
165
+ end({ kind: 'cancelled' });
166
+ return;
167
+ }
168
+ end({ kind: 'pinned', elements: Object.freeze([...pins]) });
169
+ };
170
+ const end = (result) => {
171
+ if (mode !== 'picking')
172
+ return;
173
+ mode = 'done';
174
+ for (const remove of removers.splice(0))
175
+ remove();
176
+ if (result.kind === 'cancelled')
177
+ pins = [];
178
+ current = undefined;
179
+ refusal = undefined;
180
+ touch = false;
181
+ suspended = false;
182
+ trail = [];
183
+ changed();
184
+ const restore = focus;
185
+ focus = null;
186
+ if (restore && restore.isConnected && typeof restore.focus === 'function') {
187
+ try {
188
+ restore.focus({ preventScroll: true });
189
+ }
190
+ catch { /* not focusable any more */ }
191
+ }
192
+ const done = settle;
193
+ settle = undefined;
194
+ options.onFinish?.(result);
195
+ done?.(result);
196
+ };
197
+ // --- events -------------------------------------------------------------------------------------------------------------------
198
+ const stop = (event) => { if (event.cancelable)
199
+ event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); };
200
+ /** Pointer events on the overlay's toolbar and in ignored subtrees keep working; everything else is the picker's. */
201
+ const passes = (event) => event.composedPath().some(target => target === overlay.host || (isElement(target) && target.hasAttribute('data-feedback-ignore')));
202
+ const point = (event) => {
203
+ const touches = event.changedTouches;
204
+ if (touches && touches.length) {
205
+ const first = touches[0];
206
+ return { x: first.clientX, y: first.clientY };
207
+ }
208
+ const mouse = event;
209
+ return typeof mouse.clientX === 'number' ? { x: mouse.clientX, y: mouse.clientY } : undefined;
210
+ };
211
+ const onPointer = (event) => {
212
+ if (passes(event))
213
+ return;
214
+ const type = event.type;
215
+ const pointerType = event.pointerType;
216
+ if (type === 'pointerdown' && pointerType)
217
+ lastPointer = pointerType;
218
+ if (type === 'touchstart')
219
+ lastPointer = 'touch';
220
+ if ((type === 'pointerdown' && pointerType === 'touch') || type === 'touchstart') {
221
+ fingerDown = point(event);
222
+ tapDone = false;
223
+ dragged = false;
224
+ }
225
+ if (type === 'pointercancel')
226
+ tapDone = true;
227
+ const tapped = type === 'pointerup' && pointerType === 'touch';
228
+ if (tapped || (type === 'touchend' && lastPointer === 'touch')) {
229
+ const where = point(event);
230
+ if (where && tapPicks) {
231
+ if (!tapDone && !dragged && !far(where) && !suspended) {
232
+ tapDone = true;
233
+ touch = true;
234
+ const found = at(where.x, where.y);
235
+ trail = [];
236
+ refusal = found.refusal;
237
+ if (found.element) {
238
+ current = found.element;
239
+ changed();
240
+ pin();
241
+ }
242
+ else
243
+ changed();
244
+ }
245
+ }
246
+ else if (where) {
247
+ touch = true;
248
+ const found = at(where.x, where.y);
249
+ trail = [];
250
+ current = found.element ?? current;
251
+ refusal = found.refusal;
252
+ changed();
253
+ }
254
+ }
255
+ else if (type === 'click' && lastPointer !== 'touch') {
256
+ const where = point(event);
257
+ const mouse = event;
258
+ if (where && (mouse.detail > 0 || where.x !== 0 || where.y !== 0)) {
259
+ const found = at(where.x, where.y);
260
+ if (found.element && found.element !== current)
261
+ choose(found.element);
262
+ }
263
+ pin({ add: mouse.shiftKey });
264
+ }
265
+ // touchstart is not cancelled, so the page still scrolls under a finger; touchend is, so no click is synthesized.
266
+ if (type === 'touchstart') {
267
+ event.stopPropagation();
268
+ event.stopImmediatePropagation();
269
+ return;
270
+ }
271
+ stop(event);
272
+ };
273
+ const inIgnored = (event) => event.composedPath().some(target => isElement(target) && target.hasAttribute('data-feedback-ignore'));
274
+ const onMove = (event) => {
275
+ if (passes(event))
276
+ return;
277
+ if (event.type === 'touchmove' || event.pointerType === 'touch') {
278
+ if (tapPicks && far(point(event)))
279
+ dragged = true;
280
+ return;
281
+ }
282
+ const where = point(event);
283
+ if (where)
284
+ hover(where.x, where.y);
285
+ };
286
+ const onWheel = (event) => {
287
+ if (passes(event))
288
+ return;
289
+ const delta = event.deltaY;
290
+ stop(event);
291
+ if (delta < 0)
292
+ move('up');
293
+ else if (delta > 0)
294
+ move('down');
295
+ };
296
+ const onKey = (event) => {
297
+ const key = event;
298
+ if (keys === 'outside-ignored' && inIgnored(event))
299
+ return;
300
+ stop(event);
301
+ if (event.type !== 'keydown')
302
+ return;
303
+ if (key.key === 'Escape')
304
+ end({ kind: 'cancelled' });
305
+ else if (key.key === 'ArrowUp')
306
+ move('up');
307
+ else if (key.key === 'ArrowDown')
308
+ move('down');
309
+ else if (key.key === 'Tab')
310
+ tab(key.shiftKey);
311
+ else if (key.key === 'Enter')
312
+ pin({ add: key.shiftKey });
313
+ };
314
+ const onSubmit = (event) => { stop(event); };
315
+ const listen = (type, handler, passive = false) => {
316
+ const listenerOptions = { capture: true, passive };
317
+ view.addEventListener(type, handler, listenerOptions);
318
+ removers.push(() => view.removeEventListener(type, handler, listenerOptions));
319
+ };
320
+ const listenAll = () => {
321
+ for (const type of POINTER_EVENTS)
322
+ listen(type, onPointer);
323
+ for (const type of ['pointermove', 'mousemove', 'touchmove'])
324
+ listen(type, onMove, true);
325
+ for (const type of KEY_EVENTS)
326
+ listen(type, onKey);
327
+ if (wheel)
328
+ listen('wheel', onWheel);
329
+ listen('submit', onSubmit);
330
+ };
331
+ const suspend = (on) => {
332
+ if (mode !== 'picking' || on === suspended)
333
+ return;
334
+ suspended = on;
335
+ if (on) {
336
+ for (const remove of removers.splice(0))
337
+ remove();
338
+ current = undefined;
339
+ refusal = undefined;
340
+ trail = [];
341
+ }
342
+ else
343
+ listenAll();
344
+ changed();
345
+ };
346
+ const start = () => {
347
+ if (mode === 'picking')
348
+ return new Promise(resolve => { const previous = settle; settle = result => { previous?.(result); resolve(result); }; });
349
+ mode = 'picking';
350
+ pins = [];
351
+ current = undefined;
352
+ refusal = undefined;
353
+ touch = false;
354
+ suspended = false;
355
+ trail = [];
356
+ focus = document.activeElement;
357
+ listenAll();
358
+ const promise = new Promise(resolve => { settle = resolve; });
359
+ changed();
360
+ return promise;
361
+ };
362
+ return {
363
+ start,
364
+ cancel: () => end({ kind: 'cancelled' }),
365
+ finish,
366
+ parent: () => move('up'),
367
+ child: () => move('down'),
368
+ pin,
369
+ select,
370
+ state,
371
+ overlay,
372
+ highlight: (element, note) => overlay.highlight(element, note),
373
+ suspend,
374
+ dispose: () => {
375
+ end({ kind: 'cancelled' });
376
+ mode = 'idle';
377
+ if (ownsOverlay)
378
+ overlay.dispose();
379
+ else
380
+ overlay.clear();
381
+ },
382
+ };
383
+ }
384
+ function isElement(target) {
385
+ return typeof target.hasAttribute === 'function' && target.nodeType === 1;
386
+ }
@@ -0,0 +1,6 @@
1
+ import type { AppDomSnapshot } from './serialize.js';
2
+ /** JSON with object keys sorted at every level and no whitespace. Only JSON values (no `undefined`, functions or cycles) are accepted. */
3
+ export declare function canonicalJson(value: unknown): string;
4
+ /** Lower-case hex SHA-256 of `canonicalJson(snapshot)`. */
5
+ export declare function pageDigest(snapshot: AppDomSnapshot): Promise<string>;
6
+ //# sourceMappingURL=digest.d.ts.map
@@ -0,0 +1,24 @@
1
+ // The page digest: SHA-256 (hex) of the snapshot's canonical JSON, through the platform module so it works on insecure origins too.
2
+ import { sha256 } from '@hachej/boring-files/platform';
3
+ /** JSON with object keys sorted at every level and no whitespace. Only JSON values (no `undefined`, functions or cycles) are accepted. */
4
+ export function canonicalJson(value) {
5
+ if (value === null || typeof value === 'boolean' || typeof value === 'string')
6
+ return JSON.stringify(value);
7
+ if (typeof value === 'number') {
8
+ if (!Number.isFinite(value))
9
+ throw new TypeError('Non-finite number in canonical JSON');
10
+ return JSON.stringify(value);
11
+ }
12
+ if (Array.isArray(value))
13
+ return `[${value.map(canonicalJson).join(',')}]`;
14
+ if (typeof value === 'object') {
15
+ const record = value;
16
+ return `{${Object.keys(record).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`;
17
+ }
18
+ throw new TypeError(`Not a JSON value: ${typeof value}`);
19
+ }
20
+ /** Lower-case hex SHA-256 of `canonicalJson(snapshot)`. */
21
+ export async function pageDigest(snapshot) {
22
+ const digest = await sha256(new TextEncoder().encode(canonicalJson(snapshot)));
23
+ return Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('');
24
+ }
@@ -0,0 +1,6 @@
1
+ export { createPrivacyPolicy, policyRecord, titleFor, maskText, keptValue, keptAttributeValue, SOURCE_LOCATION, inVisibleRegion, isExcluded, isAlwaysMasked, allowedAttributes, DEFAULT_KEPT_ATTRIBUTES, VISIBLE_REGION_ATTRIBUTES, FEEDBACK_VISIBLE_ATTRIBUTE, FEEDBACK_IGNORE_ATTRIBUTE, FEEDBACK_OVERLAY_ATTRIBUTE, MASKED_NAME, type PrivacyPolicy, type PrivacyPolicyOptions, type RouteLocation, } from './policy.js';
2
+ export { serializeElement, serializePage, PAGE_LIMITS, ELEMENT_LIMITS, type AppDomNode, type AppDomSnapshot, type AppDomLimits, type SerializeElementOptions } from './serialize.js';
3
+ export { accessibleNameOf } from './name.js';
4
+ export { routeFor, maskedPath } from './route.js';
5
+ export { pageDigest, canonicalJson } from './digest.js';
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,5 @@
1
+ export { createPrivacyPolicy, policyRecord, titleFor, maskText, keptValue, keptAttributeValue, SOURCE_LOCATION, inVisibleRegion, isExcluded, isAlwaysMasked, allowedAttributes, DEFAULT_KEPT_ATTRIBUTES, VISIBLE_REGION_ATTRIBUTES, FEEDBACK_VISIBLE_ATTRIBUTE, FEEDBACK_IGNORE_ATTRIBUTE, FEEDBACK_OVERLAY_ATTRIBUTE, MASKED_NAME, } from './policy.js';
2
+ export { serializeElement, serializePage, PAGE_LIMITS, ELEMENT_LIMITS } from './serialize.js';
3
+ export { accessibleNameOf } from './name.js';
4
+ export { routeFor, maskedPath } from './route.js';
5
+ export { pageDigest, canonicalJson } from './digest.js';
@@ -0,0 +1,8 @@
1
+ import { type PrivacyPolicy } from './policy.js';
2
+ /**
3
+ * The element's accessible name, from allowed sources only: `aria-labelledby` and `<label>` targets and the element's own text when
4
+ * that text is visible, and `aria-label` only inside a visible region. Values, placeholders, `title`, `alt` and masked text are never
5
+ * used; the result is then `masked`.
6
+ */
7
+ export declare function accessibleNameOf(element: Element, policy: PrivacyPolicy): string;
8
+ //# sourceMappingURL=name.d.ts.map
@@ -0,0 +1,106 @@
1
+ // Accessible names from allowed sources only. A name is either fully readable or `masked`: one masked piece masks the whole name.
2
+ import { MASKED_NAME, inVisibleRegion, isAlwaysMasked, isExcluded, keptValue, normalizeText } from './policy.js';
3
+ const MAX_NAME = 120;
4
+ const ELEMENT_NODE = 1, TEXT_NODE = 3, CDATA_NODE = 4;
5
+ const SKIPPED = new Set(['head', 'title', 'script', 'style', 'noscript', 'template', 'iframe', 'object', 'embed']);
6
+ const LABELABLE = new Set(['input', 'select', 'textarea', 'button', 'meter', 'output', 'progress']);
7
+ /** Elements whose name comes only from a label, never from their own content or value. */
8
+ const LABEL_ONLY = new Set(['input', 'select', 'textarea', 'output', 'img', 'meter', 'progress']);
9
+ const VALUELESS_INPUTS = new Set(['checkbox', 'radio', 'hidden', 'file', 'image']);
10
+ /** Whether an embedded form control would put its value or content into the enclosing name. */
11
+ function contributesValue(control) {
12
+ if (control.localName === 'input')
13
+ return !VALUELESS_INPUTS.has((control.getAttribute('type') ?? 'text').toLowerCase());
14
+ return control.localName === 'select' || control.localName === 'textarea' || normalizeText(control.textContent ?? '') !== '';
15
+ }
16
+ /** Visible text of a subtree, or `undefined` when any piece of it would be masked. Hidden and excluded subtrees contribute nothing. */
17
+ function readableText(element, policy) {
18
+ if (SKIPPED.has(element.localName) || isExcluded(element) || element.hasAttribute('hidden') || element.getAttribute('aria-hidden') === 'true')
19
+ return '';
20
+ const parts = [];
21
+ for (const child of Array.from(element.childNodes)) {
22
+ if (child.nodeType === TEXT_NODE || child.nodeType === CDATA_NODE) {
23
+ const text = normalizeText(child.nodeValue ?? '');
24
+ if (!text)
25
+ continue;
26
+ if (isAlwaysMasked(element) || !inVisibleRegion(element, policy))
27
+ return undefined;
28
+ parts.push(text);
29
+ }
30
+ else if (child.nodeType === ELEMENT_NODE) {
31
+ const inner = child;
32
+ // A form control inside a label or button contributes its value in the accessibility tree; values are never read.
33
+ if (isAlwaysMasked(inner)) {
34
+ if (contributesValue(inner))
35
+ return undefined;
36
+ continue;
37
+ }
38
+ const text = readableText(inner, policy);
39
+ if (text === undefined)
40
+ return undefined;
41
+ if (text)
42
+ parts.push(text);
43
+ }
44
+ }
45
+ return normalizeText(parts.join(' '));
46
+ }
47
+ function labelsOf(element) {
48
+ const labels = element.labels;
49
+ if (labels)
50
+ return Array.from(labels);
51
+ const found = [];
52
+ const wrapping = element.parentElement?.closest('label');
53
+ if (wrapping)
54
+ found.push(wrapping);
55
+ const id = element.getAttribute('id');
56
+ if (id)
57
+ for (const label of Array.from(element.ownerDocument.querySelectorAll('label[for]')))
58
+ if (label.getAttribute('for') === id && !found.includes(label))
59
+ found.push(label);
60
+ return found;
61
+ }
62
+ function joined(sources, policy) {
63
+ const parts = [];
64
+ for (const source of sources) {
65
+ const text = readableText(source, policy);
66
+ if (text === undefined)
67
+ return undefined;
68
+ if (text)
69
+ parts.push(text);
70
+ }
71
+ return normalizeText(parts.join(' '));
72
+ }
73
+ function bounded(name) {
74
+ if (!name)
75
+ return MASKED_NAME;
76
+ const points = Array.from(name);
77
+ return points.length > MAX_NAME ? `${points.slice(0, MAX_NAME - 1).join('')}…` : name;
78
+ }
79
+ /**
80
+ * The element's accessible name, from allowed sources only: `aria-labelledby` and `<label>` targets and the element's own text when
81
+ * that text is visible, and `aria-label` only inside a visible region. Values, placeholders, `title`, `alt` and masked text are never
82
+ * used; the result is then `masked`.
83
+ */
84
+ export function accessibleNameOf(element, policy) {
85
+ if (isExcluded(element))
86
+ return MASKED_NAME;
87
+ const labelledBy = normalizeText(element.getAttribute('aria-labelledby') ?? '');
88
+ if (labelledBy) {
89
+ const sources = labelledBy.split(' ').map(id => element.ownerDocument.getElementById(id));
90
+ if (sources.some(source => source === null || isExcluded(source)))
91
+ return MASKED_NAME;
92
+ return bounded(joined(sources, policy));
93
+ }
94
+ const label = element.getAttribute('aria-label');
95
+ if (label !== null && normalizeText(label))
96
+ return inVisibleRegion(element, policy) && keptValue(label) ? bounded(normalizeText(label)) : MASKED_NAME;
97
+ const tag = element.localName;
98
+ if (LABELABLE.has(tag) && !(tag === 'input' && element.getAttribute('type')?.toLowerCase() === 'hidden')) {
99
+ const labels = labelsOf(element);
100
+ if (labels.length)
101
+ return bounded(joined(labels, policy));
102
+ }
103
+ if (LABEL_ONLY.has(tag) || isAlwaysMasked(element))
104
+ return MASKED_NAME;
105
+ return bounded(readableText(element, policy));
106
+ }
@@ -0,0 +1,65 @@
1
+ /** Marks a region whose text (and `aria-label`/`aria-description`) may leave the page. Presence counts, whatever its value. */
2
+ export declare const FEEDBACK_VISIBLE_ATTRIBUTE = "data-feedback-visible";
3
+ /** Marks a subtree that is never serialized, named or picked (the application's own chrome, the agent bar). */
4
+ export declare const FEEDBACK_IGNORE_ATTRIBUTE = "data-feedback-ignore";
5
+ /** Marks the picker overlay's host element; it and its subtree are excluded from every snapshot. */
6
+ export declare const FEEDBACK_OVERLAY_ATTRIBUTE = "data-feedback-overlay";
7
+ /** The name returned whenever the accessible name would come from masked or disallowed sources. */
8
+ export declare const MASKED_NAME = "masked";
9
+ /** Attributes kept by default. Values still pass `keptValue`. */
10
+ export declare const DEFAULT_KEPT_ATTRIBUTES: readonly string[];
11
+ /** Kept only inside a visible region. */
12
+ export declare const VISIBLE_REGION_ATTRIBUTES: readonly string[];
13
+ /** What `routeOf` receives: the parts of a `Location` the route is derived from. */
14
+ export interface RouteLocation {
15
+ readonly pathname: string;
16
+ readonly search?: string;
17
+ readonly hash?: string;
18
+ }
19
+ export interface PrivacyPolicyOptions {
20
+ /** Extra attribute names to keep (lower case). Their values still pass the digit/`@` rule. */
21
+ readonly allowAttributes?: readonly string[];
22
+ /** A CSS selector whose matches (and their descendants) count as visible regions, besides `data-feedback-visible`. */
23
+ readonly visibleSelector?: string;
24
+ /** The application's route template for a location (`/orders/:id`). Its query and fragment are cut. */
25
+ readonly routeOf?: (location: RouteLocation) => string;
26
+ /** A document label supplied by the application; the document title is never read. */
27
+ readonly label?: string;
28
+ }
29
+ export interface PrivacyPolicy {
30
+ readonly version: 1;
31
+ /** Every loosening of the default, in a stable order: `allowAttributes:<name>`, `visibleSelector:<selector>`, `routeOf`, `label`. */
32
+ readonly widened: readonly string[];
33
+ readonly allowAttributes: readonly string[];
34
+ readonly visibleSelector?: string;
35
+ readonly routeOf?: (location: RouteLocation) => string;
36
+ readonly label?: string;
37
+ }
38
+ /** Builds the frozen version 1 policy. Throws on attribute names that name an always-masked channel or are malformed. */
39
+ export declare function createPrivacyPolicy(options?: PrivacyPolicyOptions): PrivacyPolicy;
40
+ /** The policy as a report records it (`observed.policy` in `feedback@1`). */
41
+ export declare function policyRecord(policy: PrivacyPolicy): {
42
+ readonly version: number;
43
+ readonly widened: readonly string[];
44
+ };
45
+ /** The document title as it may leave the page: only the application's label, never `document.title`. */
46
+ export declare function titleFor(policy: PrivacyPolicy): string | undefined;
47
+ /** `*` of equal length, per code point. */
48
+ export declare function maskText(text: string): string;
49
+ /** Whitespace runs collapse to one space; ends are trimmed. */
50
+ export declare function normalizeText(text: string): string;
51
+ /** A kept attribute value with three or more digits in a row, or an `@`, is dropped. */
52
+ export declare function keptValue(value: string): boolean;
53
+ /** `data-source` is code, not page data: a project-relative `path:line` (WP8's development stamp)
54
+ * is kept whatever its line number; anything else (absolute, parent, URL, `@`, odd characters) is dropped. */
55
+ export declare const SOURCE_LOCATION: RegExp;
56
+ export declare function keptAttributeValue(name: string, value: string): boolean;
57
+ /** True when the element or an ancestor marks a visible region. */
58
+ export declare function inVisibleRegion(element: Element, policy: PrivacyPolicy): boolean;
59
+ /** True when the element is, or is inside, an ignored subtree or the picker overlay. */
60
+ export declare function isExcluded(element: Element): boolean;
61
+ /** Form values, `textarea` and `contenteditable` content: masked even inside a visible region. */
62
+ export declare function isAlwaysMasked(element: Element): boolean;
63
+ /** The allowed attributes of one element, keys sorted. */
64
+ export declare function allowedAttributes(element: Element, policy: PrivacyPolicy): Readonly<Record<string, string>>;
65
+ //# sourceMappingURL=policy.d.ts.map