@hachej/boring-feedback 0.0.0-stage → 0.2.4

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 +186 -0
  87. package/dist/ui/session.js +762 -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,53 @@
1
+ import type { PublicationRequest, ResourceLocator, ResourceRef, ResourceView } from '@hachej/boring-files';
2
+ import type { FeedbackAuthor, FeedbackDraft, FeedbackReport, FeedbackResolution, FeedbackStatus, Observed } from './report.js';
3
+ /** One listed report, derived from the report file itself (there is no index). */
4
+ export interface FeedbackListItem {
5
+ readonly id: string;
6
+ /** The report's resource path. */
7
+ readonly path: string;
8
+ readonly status: FeedbackStatus;
9
+ readonly subject: string;
10
+ readonly created: string;
11
+ readonly title: string;
12
+ /** The author's principal, as the host derived it when the report was stored. */
13
+ readonly author: string;
14
+ }
15
+ /** The list item of a stored report. */
16
+ export declare function listItemOf(root: FeedbackRoot, report: FeedbackReport): FeedbackListItem;
17
+ /** The index key that groups reports about the same subject: application and route for pages, provider and path for
18
+ * resources (any view), the kind alone for kinds this version does not know. */
19
+ export declare function subjectKeyOf(observed: Observed): string;
20
+ /** Where a store keeps its reports: `<path><id>.md`, one file per report, in one provider view. */
21
+ export interface FeedbackRoot {
22
+ readonly providerId: string;
23
+ readonly view: ResourceView;
24
+ /** Empty or ending with `/`. */
25
+ readonly path: string;
26
+ }
27
+ export declare const reportPath: (root: FeedbackRoot, id: string) => string;
28
+ export declare const reportLocator: (root: FeedbackRoot, id: string) => ResourceLocator;
29
+ export interface FeedbackChange {
30
+ readonly request: PublicationRequest;
31
+ readonly report: FeedbackReport;
32
+ }
33
+ /** Create: the report file with `expected: absent`. `author` is the host-derived author; a draft carrying its own author is refused. */
34
+ export declare function createRequest(input: {
35
+ readonly operationId: string;
36
+ readonly root: FeedbackRoot;
37
+ readonly draft: FeedbackDraft;
38
+ readonly id: string;
39
+ readonly created: string;
40
+ readonly author: FeedbackAuthor;
41
+ }): FeedbackChange;
42
+ /** Resolve: the report, marked addressed with one more resolution entry, replaced at `current.ref`. Unknown kinds and `x-` fields
43
+ * of the current report are carried over unchanged. */
44
+ export declare function resolveRequest(input: {
45
+ readonly operationId: string;
46
+ readonly root: FeedbackRoot;
47
+ readonly current: {
48
+ readonly ref: ResourceRef;
49
+ readonly report: FeedbackReport;
50
+ };
51
+ readonly resolution: FeedbackResolution;
52
+ }): FeedbackChange;
53
+ //# sourceMappingURL=requests.d.ts.map
@@ -0,0 +1,69 @@
1
+ import { draftProblems, FEEDBACK_FORMAT, FEEDBACK_ID, FEEDBACK_MEDIA_TYPE, FeedbackFormatError, checkFeedback, serializeFeedback, reportTitle, draftBody, } from './report.js';
2
+ /** The list item of a stored report. */
3
+ export function listItemOf(root, report) {
4
+ return { id: report.id, path: reportPath(root, report.id), status: report.status, subject: subjectKeyOf(report.observed), created: report.created, title: reportTitle(report), author: report.author?.principalId ?? '' };
5
+ }
6
+ const part = encodeURIComponent;
7
+ /** The index key that groups reports about the same subject: application and route for pages, provider and path for
8
+ * resources (any view), the kind alone for kinds this version does not know. */
9
+ export function subjectKeyOf(observed) {
10
+ if (observed.kind === 'host') {
11
+ const { subject } = observed;
12
+ return `host:${part(subject.type)}:${part(subject.app)}:${part(subject.route)}`;
13
+ }
14
+ if (observed.kind === 'resource') {
15
+ const { locator } = observed;
16
+ return `resource:${part(locator.resource.providerId)}:${part(locator.resource.path)}`;
17
+ }
18
+ return `other:${part(observed.kind)}`;
19
+ }
20
+ const fail = (at, message) => { throw new FeedbackFormatError([{ code: 'schema', at, message }]); };
21
+ function checkRoot(root) {
22
+ if (!root.providerId)
23
+ fail('root.providerId', 'is required');
24
+ if (root.path && (!root.path.endsWith('/') || root.path.startsWith('/') || root.path.split('/').some(segment => segment === '..' || segment === '.')))
25
+ fail('root.path', 'must be empty or a relative folder ending with /');
26
+ }
27
+ const locator = (root, path) => ({ resource: { providerId: root.providerId, path }, view: root.view });
28
+ export const reportPath = (root, id) => `${root.path}${id}.md`;
29
+ export const reportLocator = (root, id) => locator(root, reportPath(root, id));
30
+ const sameView = (a, b) => a.kind === b.kind && (a.kind === 'published' || a.viewId === b.viewId);
31
+ function checkRef(ref, expected, at) {
32
+ if (ref.resource.providerId !== expected.resource.providerId || ref.resource.path !== expected.resource.path || !sameView(ref.view, expected.view) || !ref.revision) {
33
+ fail(at, `must name ${expected.resource.path} in the root's provider and view`);
34
+ }
35
+ }
36
+ function request(operationId, change) {
37
+ if (!operationId)
38
+ fail('operationId', 'is required');
39
+ return { operationId, atomicity: 'all-or-nothing', changes: [change] };
40
+ }
41
+ /** Create: the report file with `expected: absent`. `author` is the host-derived author; a draft carrying its own author is refused. */
42
+ export function createRequest(input) {
43
+ checkRoot(input.root);
44
+ const problems = draftProblems(input.draft);
45
+ if (problems.length)
46
+ throw new FeedbackFormatError(problems);
47
+ const { draft, id, created, author, root } = input;
48
+ if (!FEEDBACK_ID.test(id))
49
+ fail('id', 'invalid id');
50
+ const checked = checkFeedback({ format: FEEDBACK_FORMAT, id, status: 'open', author, created, ...draftBody(draft), resolutions: [] });
51
+ if (!checked.ok)
52
+ throw new FeedbackFormatError(checked.problems);
53
+ const report = checked.report;
54
+ const change = { kind: 'create', target: reportLocator(root, id), expected: { kind: 'absent' }, bytes: serializeFeedback(report), mediaType: FEEDBACK_MEDIA_TYPE };
55
+ return { report, request: request(input.operationId, change) };
56
+ }
57
+ /** Resolve: the report, marked addressed with one more resolution entry, replaced at `current.ref`. Unknown kinds and `x-` fields
58
+ * of the current report are carried over unchanged. */
59
+ export function resolveRequest(input) {
60
+ checkRoot(input.root);
61
+ const { root, current } = input;
62
+ checkRef(current.ref, reportLocator(root, current.report.id), 'current.ref');
63
+ const { at, by, note } = input.resolution;
64
+ const checked = checkFeedback({ ...current.report, status: 'addressed', resolutions: [...current.report.resolutions, { at, by, note }] });
65
+ if (!checked.ok)
66
+ throw new FeedbackFormatError(checked.problems);
67
+ const report = checked.report;
68
+ return { report, request: request(input.operationId, { kind: 'replace', target: current.ref, bytes: serializeFeedback(report), mediaType: FEEDBACK_MEDIA_TYPE }) };
69
+ }
@@ -0,0 +1,79 @@
1
+ import type { AnchorResolution, Placement, ValueSchema } from '@hachej/boring-ui-kit/contracts';
2
+ import { type PrivacyPolicy } from './privacy/policy.js';
3
+ import { type AppDomNode, type AppDomSnapshot } from './privacy/serialize.js';
4
+ export declare const APP_ELEMENT_KIND = "app.element@1";
5
+ /** Identity signals, in the order they are tried. */
6
+ export type AppElementIdentity = 'feedbackId' | 'testId';
7
+ export interface AppElementSignals {
8
+ /** `data-feedback-id`, when the policy kept it. */
9
+ readonly feedbackId?: string;
10
+ /** `data-testid`, when the policy kept it. */
11
+ readonly testId?: string;
12
+ /** Identity signals that occurred exactly once in the complete page snapshot at capture. Only these may place automatically. */
13
+ readonly unique: readonly AppElementIdentity[];
14
+ /** `data-source` (`path:line`), when the policy kept it, and the 0-based occurrence among same-source elements in document order. */
15
+ readonly source?: string;
16
+ readonly sourceIndex?: number;
17
+ /** Explicit kept `role`, otherwise the tag's implicit role. */
18
+ readonly role?: string;
19
+ /** The accessible name, only when the policy allows it (never `masked`). */
20
+ readonly name?: string;
21
+ /** `tag:nth-of-type(n)` chain after a base segment: the nearest `main`, landmark or `feedbackId` ancestor, else the root's tag. */
22
+ readonly path: readonly string[];
23
+ }
24
+ export interface AppElementAnchor {
25
+ readonly kind: typeof APP_ELEMENT_KIND;
26
+ readonly signals: AppElementSignals;
27
+ /** The masked element snapshot (at most 2 KiB). */
28
+ readonly snapshot: AppDomNode;
29
+ /** `[x, y, width, height]` in CSS pixels at capture; display only, never matched. */
30
+ readonly box?: readonly [number, number, number, number];
31
+ readonly fallback: string;
32
+ }
33
+ /** A node path in an `app.dom@1` snapshot: child indices from the snapshot root (`[]` is the root). */
34
+ export type AppElementRange = readonly number[];
35
+ export type AppElementCapture = {
36
+ readonly kind: 'captured';
37
+ readonly anchor: AppElementAnchor;
38
+ } | {
39
+ readonly kind: 'refused';
40
+ readonly reason: string;
41
+ };
42
+ export interface AnchorOfOptions {
43
+ /** The application root. Elements outside it (portals), in shadow roots or in other documents are refused. */
44
+ readonly root: Element;
45
+ /** A page snapshot of `root` already taken with the same policy; otherwise one is serialized. */
46
+ readonly page?: AppDomSnapshot;
47
+ }
48
+ /** The explicit kept `role`, else the tag's implicit role (the picker labels elements with it). */
49
+ export declare function roleOf(node: AppDomNode): string | undefined;
50
+ /** The range of a live element in a snapshot of `root`, or `undefined` when it is not in the snapshot (excluded, skipped, or cut by truncation). */
51
+ export declare function rangeOf(element: Element, root: Element, snapshot: AppDomSnapshot): AppElementRange | undefined;
52
+ /** Captures an `app.element@1` anchor for a picked element. Every signal passes the policy; refusals say why. */
53
+ export declare function anchorOf(element: Element, policy: PrivacyPolicy, { root, page }: AnchorOfOptions): AppElementCapture;
54
+ /** Human wording from allowed parts only: `the «Save» button (SaveBar.tsx:42)` or `a masked button in main › form`. */
55
+ export declare function fallbackOf(signals: AppElementSignals, tag: string): string;
56
+ /**
57
+ * Pure placement over one `app.dom@1` snapshot. Automatic only through an identity that was unique at capture and occurs exactly once
58
+ * in a complete (not truncated) snapshot on an element with the same tag: `exact` when its path is unchanged, else `moved`. Otherwise
59
+ * the elements carrying the identity, or, when none does, the elements matching the remaining signals (tag, role, name, source) are
60
+ * `ambiguous` candidates, ranked by path then source occurrence. Nothing compatible is `missing`.
61
+ */
62
+ export declare function resolveAppElement(anchor: AppElementAnchor, snapshot: AppDomSnapshot, evaluated: string): Placement<AppElementRange>;
63
+ export declare const appElementSchema: ValueSchema<AppElementAnchor>;
64
+ /** The pure half of `app.element@1` (`@hachej/boring-ui-kit/contracts` `AnchorResolution`). `evaluated` is `pageDigest(snapshot)`. */
65
+ export declare const appElementResolution: AnchorResolution<AppElementAnchor, AppDomSnapshot, AppElementRange>;
66
+ export type RevealResult = {
67
+ readonly kind: 'revealed';
68
+ readonly element: Element;
69
+ } | {
70
+ readonly kind: 'stale';
71
+ readonly reason: string;
72
+ };
73
+ /**
74
+ * Finds the live element for a range the person may see (an `exact` or `moved` placement, or a candidate they chose) and scrolls it
75
+ * into view. The element must still match the snapshot `resolve` evaluated (tag, kept attributes and own masked text along the way),
76
+ * else `stale`. It draws nothing: the overlay (WP6) highlights the returned element.
77
+ */
78
+ export declare function revealElement(range: AppElementRange, root: Element, snapshot: AppDomSnapshot, policy: PrivacyPolicy): RevealResult;
79
+ //# sourceMappingURL=app-element.d.ts.map
@@ -0,0 +1,451 @@
1
+ import { accessibleNameOf } from './privacy/name.js';
2
+ import { MASKED_NAME, isExcluded } from './privacy/policy.js';
3
+ import { serializeElement, serializePage } from './privacy/serialize.js';
4
+ export const APP_ELEMENT_KIND = 'app.element@1';
5
+ /** Serialized anchor and element snapshot budgets (FEEDBACK_LIMITS in `./format`). */
6
+ const ANCHOR_BYTES = 4096;
7
+ const SNAPSHOT_BYTES = 2048;
8
+ /** Levels below the picked element kept in its snapshot. `feedback@1` caps front matter nesting at 8 containers (FEEDBACK_LIMITS.depth):
9
+ * front matter, anchors, anchor, snapshot node, its children and a child node with its attrs and children already use 7, so a third
10
+ * level would make the report unparsable. */
11
+ const SNAPSHOT_DEPTH = 1;
12
+ // --- shared, pure over app.dom@1 --------------------------------------------
13
+ const LANDMARK_TAGS = new Set(['main', 'nav', 'header', 'footer', 'aside']);
14
+ const LANDMARK_ROLES = new Set(['main', 'navigation', 'banner', 'contentinfo', 'complementary', 'search', 'region', 'form']);
15
+ const IMPLICIT_ROLES = {
16
+ a: 'link', button: 'button', h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading',
17
+ textarea: 'textbox', select: 'combobox', img: 'img', li: 'listitem', ul: 'list', ol: 'list', nav: 'navigation', main: 'main',
18
+ form: 'form', table: 'table', tr: 'row', td: 'cell', th: 'columnheader', dialog: 'dialog', header: 'banner', footer: 'contentinfo', aside: 'complementary',
19
+ };
20
+ const INPUT_ROLES = { checkbox: 'checkbox', radio: 'radio', submit: 'button', button: 'button', reset: 'button', range: 'slider', search: 'searchbox' };
21
+ /** The explicit kept `role`, else the tag's implicit role (the picker labels elements with it). */
22
+ export function roleOf(node) {
23
+ const explicit = node.attrs['role'];
24
+ if (explicit)
25
+ return explicit;
26
+ if (node.tag === 'input')
27
+ return INPUT_ROLES[(node.attrs['type'] ?? 'text').toLowerCase()] ?? 'textbox';
28
+ return IMPLICIT_ROLES[node.tag];
29
+ }
30
+ const isMasked = (text) => /^\*+$/.test(text);
31
+ /** The node's name as far as the snapshot shows it: a kept `aria-label`, else its subtree text. `undefined` when unknown or masked. */
32
+ function snapshotName(node) {
33
+ const label = node.attrs['aria-label'];
34
+ if (label)
35
+ return label;
36
+ const parts = [];
37
+ let masked = false;
38
+ const walk = (current) => {
39
+ for (const piece of current.text.split(' ')) {
40
+ if (!piece)
41
+ continue;
42
+ if (isMasked(piece))
43
+ masked = true;
44
+ else
45
+ parts.push(piece);
46
+ }
47
+ for (const child of current.children)
48
+ if (child.attrs['aria-hidden'] !== 'true')
49
+ walk(child);
50
+ };
51
+ walk(node);
52
+ return masked || parts.length === 0 ? undefined : parts.join(' ');
53
+ }
54
+ /** Every node in document order, with its range and its ancestor chain (root first, the node last). */
55
+ function locateAll(root) {
56
+ const out = [];
57
+ const walk = (node, range, chain) => {
58
+ const here = [...chain, node];
59
+ out.push({ node, range, chain: here });
60
+ node.children.forEach((child, index) => walk(child, [...range, index], here));
61
+ };
62
+ walk(root, [], []);
63
+ return out;
64
+ }
65
+ const segment = (node) => `${node.tag}:nth-of-type(${node.index + 1})`;
66
+ function baseSegment(node) {
67
+ const feedbackId = node.attrs['data-feedback-id'];
68
+ if (feedbackId)
69
+ return `[data-feedback-id=${JSON.stringify(feedbackId)}]`;
70
+ if (LANDMARK_TAGS.has(node.tag))
71
+ return node.tag;
72
+ const role = node.attrs['role'];
73
+ if (role && LANDMARK_ROLES.has(role))
74
+ return `[role=${JSON.stringify(role)}]`;
75
+ return undefined;
76
+ }
77
+ function pathOf(chain) {
78
+ for (let at = chain.length - 2; at >= 0; at--) {
79
+ const base = baseSegment(chain[at]);
80
+ if (base !== undefined)
81
+ return [base, ...chain.slice(at + 1).map(segment)];
82
+ }
83
+ const [root, ...rest] = chain;
84
+ return root === undefined ? [] : [root.tag, ...rest.map(segment)];
85
+ }
86
+ function sourceIndexOf(all, target) {
87
+ const source = target.node.attrs['data-source'];
88
+ if (!source)
89
+ return undefined;
90
+ let index = 0;
91
+ for (const item of all) {
92
+ if (item === target)
93
+ return index;
94
+ if (item.node.attrs['data-source'] === source)
95
+ index++;
96
+ }
97
+ return undefined;
98
+ }
99
+ const sameStrings = (left, right) => left.length === right.length && left.every((item, index) => item === right[index]);
100
+ // --- capture (browser) -------------------------------------------------------
101
+ function sameTagIndex(element) {
102
+ let index = 0;
103
+ for (let sibling = element.previousElementSibling; sibling; sibling = sibling.previousElementSibling)
104
+ if (sibling.localName === element.localName)
105
+ index++;
106
+ return index;
107
+ }
108
+ /** The range of a live element in a snapshot of `root`, or `undefined` when it is not in the snapshot (excluded, skipped, or cut by truncation). */
109
+ export function rangeOf(element, root, snapshot) {
110
+ const chain = [];
111
+ let current = element;
112
+ for (; current && current !== root; current = current.parentElement)
113
+ chain.unshift(current);
114
+ if (current !== root || !snapshot.root || snapshot.root.tag !== root.localName)
115
+ return undefined;
116
+ let node = snapshot.root;
117
+ const range = [];
118
+ for (const live of chain) {
119
+ const tag = live.localName, index = sameTagIndex(live);
120
+ const at = node.children.findIndex(child => child.tag === tag && child.index === index);
121
+ const child = node.children[at];
122
+ if (child === undefined)
123
+ return undefined;
124
+ range.push(at);
125
+ node = child;
126
+ }
127
+ return range;
128
+ }
129
+ function refusal(element, root) {
130
+ if (element.ownerDocument !== root.ownerDocument)
131
+ return 'the element is in another document (an iframe)';
132
+ if (!element.isConnected)
133
+ return 'the element is not in the page';
134
+ const host = element.getRootNode();
135
+ if (host !== element.ownerDocument && host !== root.getRootNode())
136
+ return 'the element is inside a shadow root';
137
+ if (isExcluded(element))
138
+ return 'the element is inside an ignored subtree or the feedback overlay';
139
+ if (!root.contains(element))
140
+ return 'the element is outside the application root (a portal)';
141
+ return undefined;
142
+ }
143
+ function boxOf(element) {
144
+ try {
145
+ const rect = element.getBoundingClientRect();
146
+ const box = [rect.x, rect.y, rect.width, rect.height].map(Math.round);
147
+ return box.every(Number.isFinite) && (rect.width > 0 || rect.height > 0) ? box : undefined;
148
+ }
149
+ catch {
150
+ return undefined;
151
+ }
152
+ }
153
+ const byteLength = (value) => new TextEncoder().encode(JSON.stringify(value)).length;
154
+ /** Captures an `app.element@1` anchor for a picked element. Every signal passes the policy; refusals say why. */
155
+ export function anchorOf(element, policy, { root, page }) {
156
+ const refused = refusal(element, root);
157
+ if (refused !== undefined)
158
+ return { kind: 'refused', reason: refused };
159
+ const snapshot = page ?? serializePage(root, policy);
160
+ const range = rangeOf(element, root, snapshot);
161
+ if (range === undefined || !snapshot.root) {
162
+ return { kind: 'refused', reason: snapshot.truncated ? 'the page snapshot was truncated before the element' : 'the element is not part of the serialized page' };
163
+ }
164
+ const all = locateAll(snapshot.root);
165
+ const target = all.find(item => sameRange(item.range, range));
166
+ if (target === undefined)
167
+ return { kind: 'refused', reason: 'the element is not part of the serialized page' };
168
+ const { attrs } = target.node;
169
+ const unique = [];
170
+ const feedbackId = attrs['data-feedback-id'], testId = attrs['data-testid'], source = attrs['data-source'];
171
+ if (!snapshot.truncated) {
172
+ if (feedbackId && carriers(all, 'data-feedback-id', feedbackId).length === 1)
173
+ unique.push('feedbackId');
174
+ if (testId && carriers(all, 'data-testid', testId).length === 1)
175
+ unique.push('testId');
176
+ }
177
+ const role = roleOf(target.node);
178
+ const name = accessibleNameOf(element, policy);
179
+ const sourceIndex = sourceIndexOf(all, target);
180
+ const signals = {
181
+ ...(feedbackId ? { feedbackId } : {}),
182
+ ...(testId ? { testId } : {}),
183
+ unique,
184
+ ...(source ? { source } : {}),
185
+ ...(sourceIndex !== undefined ? { sourceIndex } : {}),
186
+ ...(role ? { role } : {}),
187
+ ...(name !== MASKED_NAME ? { name } : {}),
188
+ path: pathOf(target.chain),
189
+ };
190
+ const box = boxOf(element);
191
+ const base = { kind: APP_ELEMENT_KIND, signals, ...(box ? { box } : {}), fallback: fallbackOf(signals, target.node.tag) };
192
+ // The full element snapshot when it fits the anchor budget, else the element alone.
193
+ for (const options of [{}, { subtreeLimit: 1 }]) {
194
+ const own = serializeElement(element, policy, { ...options, maxDepth: SNAPSHOT_DEPTH, maxBytes: SNAPSHOT_BYTES });
195
+ if (!own.root)
196
+ return { kind: 'refused', reason: 'the element is inside an ignored subtree or the feedback overlay' };
197
+ const anchor = { ...base, snapshot: own.root };
198
+ if (byteLength(own.root) <= SNAPSHOT_BYTES && byteLength(anchor) <= ANCHOR_BYTES)
199
+ return { kind: 'captured', anchor };
200
+ }
201
+ return { kind: 'refused', reason: `the anchor would exceed ${ANCHOR_BYTES} bytes` };
202
+ }
203
+ const sameRange = (left, right) => left.length === right.length && left.every((item, index) => item === right[index]);
204
+ const carriers = (all, attribute, value) => all.filter(item => item.node.attrs[attribute] === value);
205
+ // --- fallback ------------------------------------------------------------------
206
+ const ROLE_WORDS = { textbox: 'text field', img: 'image', listitem: 'list item', columnheader: 'column header', combobox: 'combo box', searchbox: 'search field', contentinfo: 'footer', banner: 'header', complementary: 'aside' };
207
+ const QUIET_TAGS = new Set(['div', 'span']);
208
+ function contextOf(path) {
209
+ const [base, ...rest] = path;
210
+ const words = [];
211
+ if (base !== undefined && path.length > 1) {
212
+ const id = /^\[data-feedback-id=(".*")\]$/.exec(base)?.[1];
213
+ const role = /^\[role=(".*")\]$/.exec(base)?.[1];
214
+ const value = id ?? role;
215
+ const parsed = value === undefined ? base : String(JSON.parse(value));
216
+ words.push(id !== undefined ? `«${parsed}»` : parsed);
217
+ }
218
+ for (const item of rest.slice(0, -1)) {
219
+ const tag = item.replace(/:nth-of-type\(\d+\)$/, '');
220
+ if (!QUIET_TAGS.has(tag))
221
+ words.push(tag);
222
+ }
223
+ return words.slice(0, 1).concat(words.slice(1).slice(-3)).join(' › ');
224
+ }
225
+ /** Human wording from allowed parts only: `the «Save» button (SaveBar.tsx:42)` or `a masked button in main › form`. */
226
+ export function fallbackOf(signals, tag) {
227
+ const word = signals.role === undefined ? `${tag} element` : (ROLE_WORDS[signals.role] ?? signals.role);
228
+ const file = signals.source?.split('/').pop();
229
+ const where = file ? ` (${file})` : '';
230
+ if (signals.name !== undefined)
231
+ return `the «${signals.name}» ${word}${where}`;
232
+ const identity = signals.feedbackId ?? signals.testId;
233
+ const context = contextOf(signals.path);
234
+ return `a masked ${word}${identity ? ` «${identity}»` : ''}${context ? ` in ${context}` : ''}${where}`;
235
+ }
236
+ // --- resolution (pure) ---------------------------------------------------------
237
+ /** A candidate that carries a different value for a signal the anchor has contradicts it and is never offered. */
238
+ function contradicts(anchor, node, withName) {
239
+ const differs = (mine, theirs) => mine !== undefined && theirs !== undefined && mine !== theirs;
240
+ if (differs(anchor.feedbackId, node.attrs['data-feedback-id']))
241
+ return true;
242
+ if (differs(anchor.testId, node.attrs['data-testid']))
243
+ return true;
244
+ if (differs(anchor.source, node.attrs['data-source']))
245
+ return true;
246
+ if (differs(anchor.role, roleOf(node)))
247
+ return true;
248
+ return withName && differs(anchor.name, snapshotName(node));
249
+ }
250
+ function score(anchor, all, item) {
251
+ return (sameStrings(pathOf(item.chain), anchor.path) ? 2 : 0) + (anchor.sourceIndex !== undefined && sourceIndexOf(all, item) === anchor.sourceIndex ? 1 : 0);
252
+ }
253
+ /**
254
+ * Pure placement over one `app.dom@1` snapshot. Automatic only through an identity that was unique at capture and occurs exactly once
255
+ * in a complete (not truncated) snapshot on an element with the same tag: `exact` when its path is unchanged, else `moved`. Otherwise
256
+ * the elements carrying the identity, or, when none does, the elements matching the remaining signals (tag, role, name, source) are
257
+ * `ambiguous` candidates, ranked by path then source occurrence. Nothing compatible is `missing`.
258
+ */
259
+ export function resolveAppElement(anchor, snapshot, evaluated) {
260
+ if (snapshot.format !== 'app.dom@1' || !snapshot.root)
261
+ return { kind: 'missing', evaluated };
262
+ const { signals } = anchor;
263
+ const tag = anchor.snapshot.tag;
264
+ const all = locateAll(snapshot.root);
265
+ const sameTag = all.filter(item => item.node.tag === tag);
266
+ const identities = [
267
+ ['feedbackId', 'data-feedback-id', signals.feedbackId],
268
+ ['testId', 'data-testid', signals.testId],
269
+ ];
270
+ for (const [identity, attribute, value] of identities) {
271
+ if (value === undefined)
272
+ continue;
273
+ const onPage = carriers(all, attribute, value);
274
+ // Identity holders never contradict a stronger identity the anchor has; feedbackId is tried first.
275
+ const held = onPage.filter(item => item.node.tag === tag && !(identity === 'testId' && contradictsId(signals, item.node)));
276
+ if (held.length === 0)
277
+ continue;
278
+ const [only] = held;
279
+ if (!snapshot.truncated && onPage.length === 1 && only !== undefined && signals.unique.includes(identity)) {
280
+ return { kind: sameStrings(pathOf(only.chain), signals.path) ? 'exact' : 'moved', range: only.range, evaluated };
281
+ }
282
+ return ambiguous(signals, all, held, evaluated);
283
+ }
284
+ return ambiguous(signals, all, sameTag.filter(item => !contradicts(signals, item.node, true)), evaluated);
285
+ }
286
+ const contradictsId = (signals, node) => {
287
+ const theirs = node.attrs['data-feedback-id'];
288
+ return signals.feedbackId !== undefined && theirs !== undefined && theirs !== signals.feedbackId;
289
+ };
290
+ function ambiguous(signals, all, items, evaluated) {
291
+ if (items.length === 0)
292
+ return { kind: 'missing', evaluated };
293
+ const ranked = items.map((item, order) => ({ item, order, score: score(signals, all, item) })).sort((a, b) => b.score - a.score || a.order - b.order);
294
+ return { kind: 'ambiguous', candidates: ranked.map(entry => entry.item.range), evaluated };
295
+ }
296
+ // --- schema ----------------------------------------------------------------------
297
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
298
+ function fail(message) { throw new TypeError(`app.element@1: ${message}`); }
299
+ const string = (value, at) => typeof value === 'string' ? value : fail(`${at} must be a string`);
300
+ const optionalString = (value, at) => value === undefined ? undefined : string(value, at);
301
+ const count = (value, at) => Number.isInteger(value) && value >= 0 ? value : fail(`${at} must be a non-negative integer`);
302
+ const only = (value, keys, at) => {
303
+ for (const key of Object.keys(value))
304
+ if (!keys.includes(key))
305
+ fail(`${at} has unknown field ${JSON.stringify(key)}`);
306
+ };
307
+ function parseNode(value, at, depth) {
308
+ if (depth > 32)
309
+ fail(`${at} is too deep`);
310
+ if (!isRecord(value))
311
+ fail(`${at} must be an object`);
312
+ only(value, ['tag', 'attrs', 'text', 'children', 'index'], at);
313
+ const attrs = value['attrs'];
314
+ if (!isRecord(attrs))
315
+ fail(`${at}.attrs must be an object`);
316
+ const children = value['children'];
317
+ if (!Array.isArray(children))
318
+ fail(`${at}.children must be an array`);
319
+ return {
320
+ tag: string(value['tag'], `${at}.tag`),
321
+ attrs: Object.fromEntries(Object.entries(attrs).map(([key, item]) => [key, string(item, `${at}.attrs.${key}`)])),
322
+ text: string(value['text'], `${at}.text`),
323
+ children: children.map((child, index) => parseNode(child, `${at}.children[${index}]`, depth + 1)),
324
+ index: count(value['index'], `${at}.index`),
325
+ };
326
+ }
327
+ function parseSignals(value) {
328
+ if (!isRecord(value))
329
+ fail('signals must be an object');
330
+ only(value, ['feedbackId', 'testId', 'unique', 'source', 'sourceIndex', 'role', 'name', 'path'], 'signals');
331
+ const unique = value['unique'], path = value['path'];
332
+ if (!Array.isArray(unique) || !unique.every((item) => item === 'feedbackId' || item === 'testId'))
333
+ fail('signals.unique must list feedbackId or testId');
334
+ if (!Array.isArray(path) || !path.every((item) => typeof item === 'string'))
335
+ fail('signals.path must be an array of strings');
336
+ const feedbackId = optionalString(value['feedbackId'], 'signals.feedbackId'), testId = optionalString(value['testId'], 'signals.testId');
337
+ const source = optionalString(value['source'], 'signals.source'), role = optionalString(value['role'], 'signals.role'), name = optionalString(value['name'], 'signals.name');
338
+ if (name === MASKED_NAME)
339
+ fail('signals.name is never the masked marker');
340
+ for (const identity of unique)
341
+ if ((identity === 'feedbackId' ? feedbackId : testId) === undefined)
342
+ fail(`signals.unique names ${identity}, which is absent`);
343
+ const sourceIndex = value['sourceIndex'] === undefined ? undefined : count(value['sourceIndex'], 'signals.sourceIndex');
344
+ return {
345
+ ...(feedbackId !== undefined ? { feedbackId } : {}),
346
+ ...(testId !== undefined ? { testId } : {}),
347
+ unique: [...unique],
348
+ ...(source !== undefined ? { source } : {}),
349
+ ...(sourceIndex !== undefined ? { sourceIndex } : {}),
350
+ ...(role !== undefined ? { role } : {}),
351
+ ...(name !== undefined ? { name } : {}),
352
+ path: [...path],
353
+ };
354
+ }
355
+ function parseAnchor(value) {
356
+ if (!isRecord(value))
357
+ fail('anchor must be an object');
358
+ only(value, ['kind', 'signals', 'snapshot', 'box', 'fallback'], 'anchor');
359
+ if (value['kind'] !== APP_ELEMENT_KIND)
360
+ fail(`kind must be ${APP_ELEMENT_KIND}`);
361
+ const fallback = string(value['fallback'], 'fallback');
362
+ if (fallback.trim() === '')
363
+ fail('fallback must not be empty');
364
+ const box = value['box'];
365
+ if (box !== undefined && !(Array.isArray(box) && box.length === 4 && box.every((item) => typeof item === 'number' && Number.isFinite(item))))
366
+ fail('box must be four finite numbers');
367
+ return {
368
+ kind: APP_ELEMENT_KIND,
369
+ signals: parseSignals(value['signals']),
370
+ snapshot: parseNode(value['snapshot'], 'snapshot', 0),
371
+ ...(box !== undefined ? { box: [...box] } : {}),
372
+ fallback,
373
+ };
374
+ }
375
+ const NODE_SCHEMA = { type: 'object', required: ['tag', 'attrs', 'text', 'children', 'index'] };
376
+ export const appElementSchema = {
377
+ jsonSchema: {
378
+ type: 'object',
379
+ additionalProperties: false,
380
+ required: ['kind', 'signals', 'snapshot', 'fallback'],
381
+ properties: {
382
+ kind: { const: APP_ELEMENT_KIND },
383
+ signals: {
384
+ type: 'object',
385
+ additionalProperties: false,
386
+ required: ['unique', 'path'],
387
+ properties: {
388
+ feedbackId: { type: 'string' }, testId: { type: 'string' }, source: { type: 'string' }, role: { type: 'string' }, name: { type: 'string' },
389
+ sourceIndex: { type: 'integer', minimum: 0 },
390
+ unique: { type: 'array', items: { enum: ['feedbackId', 'testId'] } },
391
+ path: { type: 'array', items: { type: 'string' } },
392
+ },
393
+ },
394
+ snapshot: NODE_SCHEMA,
395
+ box: { type: 'array', items: { type: 'number' }, minItems: 4, maxItems: 4 },
396
+ fallback: { type: 'string', minLength: 1 },
397
+ },
398
+ },
399
+ parse: parseAnchor,
400
+ };
401
+ /** The pure half of `app.element@1` (`@hachej/boring-ui-kit/contracts` `AnchorResolution`). `evaluated` is `pageDigest(snapshot)`. */
402
+ export const appElementResolution = {
403
+ kind: APP_ELEMENT_KIND,
404
+ schema: appElementSchema,
405
+ resolve: resolveAppElement,
406
+ fallback: anchor => anchor.fallback,
407
+ };
408
+ const sameAttrs = (left, right) => {
409
+ const keys = Object.keys(left);
410
+ return keys.length === Object.keys(right).length && keys.every(key => right[key] === left[key]);
411
+ };
412
+ /**
413
+ * Finds the live element for a range the person may see (an `exact` or `moved` placement, or a candidate they chose) and scrolls it
414
+ * into view. The element must still match the snapshot `resolve` evaluated (tag, kept attributes and own masked text along the way),
415
+ * else `stale`. It draws nothing: the overlay (WP6) highlights the returned element.
416
+ */
417
+ export function revealElement(range, root, snapshot, policy) {
418
+ let node = snapshot.root;
419
+ if (!node || node.tag !== root.localName)
420
+ return { kind: 'stale', reason: 'the application root no longer matches the snapshot' };
421
+ let live = root;
422
+ for (const step of range) {
423
+ const child = node.children[step];
424
+ if (child === undefined)
425
+ return { kind: 'stale', reason: 'the range is not in the snapshot' };
426
+ let seen = 0, found;
427
+ for (const candidate of Array.from(live.children)) {
428
+ if (candidate.localName !== child.tag)
429
+ continue;
430
+ if (seen++ === child.index) {
431
+ found = candidate;
432
+ break;
433
+ }
434
+ }
435
+ if (found === undefined)
436
+ return { kind: 'stale', reason: 'the page changed since it was resolved' };
437
+ live = found;
438
+ node = child;
439
+ }
440
+ if (isExcluded(live))
441
+ return { kind: 'stale', reason: 'the element is now inside an ignored subtree' };
442
+ const own = serializeElement(live, policy, { subtreeLimit: 1 }).root;
443
+ if (!own || own.tag !== node.tag || !sameAttrs(own.attrs, node.attrs) || own.text !== node.text) {
444
+ return { kind: 'stale', reason: 'the element changed since it was resolved' };
445
+ }
446
+ try {
447
+ live.scrollIntoView({ block: 'center', inline: 'nearest' });
448
+ }
449
+ catch { /* not scrollable here; still returned for the overlay */ }
450
+ return { kind: 'revealed', element: live };
451
+ }