@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.
- package/INVARIANTS.md +35 -0
- package/LICENSE +21 -0
- package/README.md +20 -2
- package/dist/agent/capability.d.ts +52 -0
- package/dist/agent/capability.js +230 -0
- package/dist/agent/index.d.ts +4 -0
- package/dist/agent/index.js +5 -0
- package/dist/agent/preview.d.ts +10 -0
- package/dist/agent/preview.js +27 -0
- package/dist/format/index.d.ts +7 -0
- package/dist/format/index.js +6 -0
- package/dist/format/json.d.ts +30 -0
- package/dist/format/json.js +193 -0
- package/dist/format/preview.d.ts +28 -0
- package/dist/format/preview.js +45 -0
- package/dist/format/readable.d.ts +10 -0
- package/dist/format/readable.js +19 -0
- package/dist/format/report.d.ts +187 -0
- package/dist/format/report.js +669 -0
- package/dist/format/requests.d.ts +53 -0
- package/dist/format/requests.js +69 -0
- package/dist/page/app-element.d.ts +79 -0
- package/dist/page/app-element.js +451 -0
- package/dist/page/canaries.d.ts +46 -0
- package/dist/page/canaries.js +142 -0
- package/dist/page/index.d.ts +6 -0
- package/dist/page/index.js +8 -0
- package/dist/page/picker/index.d.ts +4 -0
- package/dist/page/picker/index.js +4 -0
- package/dist/page/picker/label.d.ts +16 -0
- package/dist/page/picker/label.js +58 -0
- package/dist/page/picker/overlay.d.ts +42 -0
- package/dist/page/picker/overlay.js +126 -0
- package/dist/page/picker/picker.d.ts +72 -0
- package/dist/page/picker/picker.js +386 -0
- package/dist/page/privacy/digest.d.ts +6 -0
- package/dist/page/privacy/digest.js +24 -0
- package/dist/page/privacy/index.d.ts +6 -0
- package/dist/page/privacy/index.js +5 -0
- package/dist/page/privacy/name.d.ts +8 -0
- package/dist/page/privacy/name.js +106 -0
- package/dist/page/privacy/policy.d.ts +65 -0
- package/dist/page/privacy/policy.js +125 -0
- package/dist/page/privacy/route.d.ts +6 -0
- package/dist/page/privacy/route.js +20 -0
- package/dist/page/privacy/serialize.d.ts +42 -0
- package/dist/page/privacy/serialize.js +90 -0
- package/dist/page/voice/align.d.ts +36 -0
- package/dist/page/voice/align.js +122 -0
- package/dist/page/voice/capture.d.ts +43 -0
- package/dist/page/voice/capture.js +186 -0
- package/dist/page/voice/index.d.ts +3 -0
- package/dist/page/voice/index.js +3 -0
- package/dist/preview/agent.d.ts +34 -0
- package/dist/preview/agent.js +91 -0
- package/dist/preview/index.d.ts +5 -0
- package/dist/preview/index.js +6 -0
- package/dist/preview/page.d.ts +50 -0
- package/dist/preview/page.js +293 -0
- package/dist/preview/session.d.ts +63 -0
- package/dist/preview/session.js +110 -0
- package/dist/source/index.d.ts +24 -0
- package/dist/source/index.js +59 -0
- package/dist/source/jsx-dev-runtime.d.ts +5 -0
- package/dist/source/jsx-dev-runtime.js +29 -0
- package/dist/store/index.d.ts +5 -0
- package/dist/store/index.js +4 -0
- package/dist/store/mentions.d.ts +17 -0
- package/dist/store/mentions.js +27 -0
- package/dist/store/store.d.ts +121 -0
- package/dist/store/store.js +353 -0
- package/dist/tickets/index.d.ts +5 -0
- package/dist/tickets/index.js +5 -0
- package/dist/tickets/sinks.d.ts +27 -0
- package/dist/tickets/sinks.js +84 -0
- package/dist/tickets/ticket.d.ts +93 -0
- package/dist/tickets/ticket.js +148 -0
- package/dist/ui/annotate.d.ts +103 -0
- package/dist/ui/annotate.js +154 -0
- package/dist/ui/index.d.ts +7 -0
- package/dist/ui/index.js +8 -0
- package/dist/ui/report.d.ts +51 -0
- package/dist/ui/report.js +58 -0
- package/dist/ui/save.d.ts +45 -0
- package/dist/ui/save.js +65 -0
- package/dist/ui/session.d.ts +186 -0
- package/dist/ui/session.js +762 -0
- package/dist/ui/show.d.ts +43 -0
- package/dist/ui/show.js +72 -0
- package/dist/ui/state.d.ts +11 -0
- package/dist/ui/state.js +21 -0
- package/package.json +98 -4
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { RouteLocation } from './privacy/policy.js';
|
|
2
|
+
/** Every planted channel. `visible-*` channels sit inside a `data-feedback-visible` region and must stay masked there too. */
|
|
3
|
+
export declare const CANARY_CHANNELS: readonly ["text", "text-entity", "text-percent", "labelled-by", "id", "class", "title", "title-entity", "alt", "aria-label", "aria-description", "data-attribute", "kept-digits", "kept-at", "style", "event-handler", "href-path", "href-percent", "href-query", "href-fragment", "src", "form-action", "input-value", "input-live-value", "placeholder", "button-value", "textarea", "textarea-live-value", "select-value", "select-text", "contenteditable", "visible-input-value", "visible-placeholder", "visible-textarea", "visible-select-text", "visible-contenteditable", "document-title", "route-segment", "route-percent", "route-query", "route-fragment"];
|
|
4
|
+
export type CanaryChannel = (typeof CANARY_CHANNELS)[number];
|
|
5
|
+
/** The marker on the planted section, so callers and tests can find it. */
|
|
6
|
+
export declare const CANARY_SECTION_ATTRIBUTE = "data-feedback-canaries";
|
|
7
|
+
/** Default tokens: unique per channel, none a prefix of another, no digit run or `@` (so the digit rule never hides a leak). */
|
|
8
|
+
export declare function defaultCanaries(): Readonly<Record<CanaryChannel, string>>;
|
|
9
|
+
export interface CanaryPage {
|
|
10
|
+
readonly document: Document;
|
|
11
|
+
/** Where the canary section is appended; defaults to `document.body`. Put it where the application's data would live. */
|
|
12
|
+
readonly root?: Element;
|
|
13
|
+
}
|
|
14
|
+
export interface CanaryRun {
|
|
15
|
+
readonly document: Document;
|
|
16
|
+
/** The planted section. */
|
|
17
|
+
readonly root: Element;
|
|
18
|
+
/** A location with canaries in path segments, query and fragment, for the route channel. */
|
|
19
|
+
readonly location: RouteLocation;
|
|
20
|
+
}
|
|
21
|
+
export interface CanaryHit {
|
|
22
|
+
readonly channel: CanaryChannel;
|
|
23
|
+
readonly token: string;
|
|
24
|
+
/** Where in the output it was found, as a JSON-path-like string (`$.report.observed.subject.route`). */
|
|
25
|
+
readonly output: string;
|
|
26
|
+
}
|
|
27
|
+
export interface CanaryResult {
|
|
28
|
+
/** False when any canary was found. */
|
|
29
|
+
readonly ok: boolean;
|
|
30
|
+
readonly hits: readonly CanaryHit[];
|
|
31
|
+
readonly channels: readonly CanaryChannel[];
|
|
32
|
+
readonly scanned: number;
|
|
33
|
+
}
|
|
34
|
+
export interface PrivacyCanaryOptions {
|
|
35
|
+
readonly page: CanaryPage;
|
|
36
|
+
/** Token per channel; defaults to `defaultCanaries()`. Tokens must be fictional. */
|
|
37
|
+
readonly canaries?: Partial<Readonly<Record<CanaryChannel, string>>>;
|
|
38
|
+
/** The caller's real path (serializer, picker label, report, Copy...). Its return value is scanned; so are strings it passes to `emit`. */
|
|
39
|
+
readonly run: (context: CanaryRun, emit: (label: string, output: unknown) => void) => unknown;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Plants canaries under `page.root`, sets the document title to a canary, runs `run` with a canary location, then restores the page and
|
|
43
|
+
* scans the returned value and every emitted output. Each hit names its channel and the output it appeared in.
|
|
44
|
+
*/
|
|
45
|
+
export declare function runPrivacyCanaries({ page, canaries, run }: PrivacyCanaryOptions): Promise<CanaryResult>;
|
|
46
|
+
//# sourceMappingURL=canaries.d.ts.map
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { FEEDBACK_VISIBLE_ATTRIBUTE } from './privacy/policy.js';
|
|
2
|
+
/** Every planted channel. `visible-*` channels sit inside a `data-feedback-visible` region and must stay masked there too. */
|
|
3
|
+
export const CANARY_CHANNELS = Object.freeze([
|
|
4
|
+
'text', 'text-entity', 'text-percent', 'labelled-by',
|
|
5
|
+
'id', 'class', 'title', 'title-entity', 'alt', 'aria-label', 'aria-description', 'data-attribute', 'kept-digits', 'kept-at', 'style', 'event-handler',
|
|
6
|
+
'href-path', 'href-percent', 'href-query', 'href-fragment', 'src', 'form-action',
|
|
7
|
+
'input-value', 'input-live-value', 'placeholder', 'button-value', 'textarea', 'textarea-live-value', 'select-value', 'select-text', 'contenteditable',
|
|
8
|
+
'visible-input-value', 'visible-placeholder', 'visible-textarea', 'visible-select-text', 'visible-contenteditable',
|
|
9
|
+
'document-title', 'route-segment', 'route-percent', 'route-query', 'route-fragment',
|
|
10
|
+
]);
|
|
11
|
+
/** The marker on the planted section, so callers and tests can find it. */
|
|
12
|
+
export const CANARY_SECTION_ATTRIBUTE = 'data-feedback-canaries';
|
|
13
|
+
/** Default tokens: unique per channel, none a prefix of another, no digit run or `@` (so the digit rule never hides a leak). */
|
|
14
|
+
export function defaultCanaries() {
|
|
15
|
+
return Object.freeze(Object.fromEntries(CANARY_CHANNELS.map(channel => [channel, `xq${channel.replace(/-/g, '')}vz`])));
|
|
16
|
+
}
|
|
17
|
+
const percentEncode = (token) => `%${token.charCodeAt(0).toString(16)}${token.slice(1)}`;
|
|
18
|
+
// Hexadecimal entity and two-digit percent forms: a three-digit decimal entity would be hidden by the digit rule, not by the policy.
|
|
19
|
+
const entityEncode = (token) => `&#x${token.charCodeAt(0).toString(16)};${token.slice(1)}`;
|
|
20
|
+
function plant(document, tokens) {
|
|
21
|
+
const make = (tag, attributes = {}, text) => {
|
|
22
|
+
const element = document.createElement(tag);
|
|
23
|
+
for (const [name, value] of Object.entries(attributes))
|
|
24
|
+
element.setAttribute(name, value);
|
|
25
|
+
if (text !== undefined)
|
|
26
|
+
element.append(document.createTextNode(text));
|
|
27
|
+
return element;
|
|
28
|
+
};
|
|
29
|
+
const t = tokens;
|
|
30
|
+
const section = make('section', { [CANARY_SECTION_ATTRIBUTE]: '', role: 'region' });
|
|
31
|
+
const text = make('p', { 'data-testid': 'canary-text' }, `Fictional ${t.text} record`);
|
|
32
|
+
text.setAttribute('id', t.id);
|
|
33
|
+
section.append(text, make('p', {}, entityEncode(t['text-entity'])), make('p', {}, percentEncode(t['text-percent'])), make('span', { id: 'feedback-canary-labelled' }, t['labelled-by']), make('button', { type: 'button', 'aria-labelledby': 'feedback-canary-labelled' }), make('div', {
|
|
34
|
+
class: `card ${t.class}`, title: t.title, 'aria-label': t['aria-label'], 'aria-description': t['aria-description'], 'data-record': t['data-attribute'],
|
|
35
|
+
'data-testid': `${t['kept-digits']}-4711`, 'data-feedback-id': `${t['kept-at']}@fictional.invalid`, style: `background:url(${t.style})`, onclick: `alert('${t['event-handler']}')`,
|
|
36
|
+
}, 'Card'), make('span', { title: entityEncode(t['title-entity']) }, 'Entity title'), make('img', { src: `/images/${t.src}.png`, alt: t.alt }), make('a', { href: `/records/${t['href-path']}?q=${t['href-query']}#${t['href-fragment']}` }, 'Open'), make('a', { href: `/records/${percentEncode(t['href-percent'])}` }, 'Open encoded'));
|
|
37
|
+
const form = make('form', { action: `/submit/${t['form-action']}` });
|
|
38
|
+
const input = make('input', { type: 'text', value: t['input-value'], placeholder: t.placeholder });
|
|
39
|
+
const live = make('input', { type: 'text' });
|
|
40
|
+
live.value = t['input-live-value'];
|
|
41
|
+
const textarea = make('textarea', {}, t.textarea);
|
|
42
|
+
const liveArea = make('textarea', {});
|
|
43
|
+
liveArea.value = t['textarea-live-value'];
|
|
44
|
+
const select = make('select');
|
|
45
|
+
select.append(make('option', { value: t['select-value'], selected: '' }, t['select-text']));
|
|
46
|
+
form.append(input, live, make('input', { type: 'submit', value: t['button-value'] }), textarea, liveArea, select, make('div', { contenteditable: 'true' }, t.contenteditable));
|
|
47
|
+
const visible = make('div', { [FEEDBACK_VISIBLE_ATTRIBUTE]: '' });
|
|
48
|
+
const visibleSelect = make('select', { 'aria-label': 'Fictional choice' });
|
|
49
|
+
visibleSelect.append(make('option', {}, t['visible-select-text']));
|
|
50
|
+
visible.append(make('h3', {}, 'Visible fictional region'), make('label', { for: 'feedback-canary-visible-input' }, 'Visible label'), make('input', { id: 'feedback-canary-visible-input', type: 'text', value: t['visible-input-value'], placeholder: t['visible-placeholder'] }), make('textarea', { 'aria-label': 'Fictional notes' }, t['visible-textarea']), visibleSelect, make('div', { contenteditable: 'plaintext-only' }, t['visible-contenteditable']));
|
|
51
|
+
section.append(form, visible);
|
|
52
|
+
return section;
|
|
53
|
+
}
|
|
54
|
+
const NAMED = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
|
55
|
+
function decodeOnce(value) {
|
|
56
|
+
const entities = value
|
|
57
|
+
.replace(/&#x([0-9a-f]+);?/gi, (_, hex) => String.fromCodePoint(Math.min(parseInt(hex, 16), 0x10ffff)))
|
|
58
|
+
.replace(/&#(\d+);?/g, (_, dec) => String.fromCodePoint(Math.min(parseInt(dec, 10), 0x10ffff)))
|
|
59
|
+
.replace(/&([a-z]+);/gi, (match, name) => NAMED[name.toLowerCase()] ?? match);
|
|
60
|
+
const percent = value.replace(/%([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
61
|
+
return [entities, percent];
|
|
62
|
+
}
|
|
63
|
+
/** The string and its HTML-entity and percent decodings (combined, up to three rounds), lower-cased. */
|
|
64
|
+
function decodings(value) {
|
|
65
|
+
let frontier = new Set([value]);
|
|
66
|
+
const all = new Set([value.toLowerCase()]);
|
|
67
|
+
for (let round = 0; round < 3 && frontier.size; round++) {
|
|
68
|
+
const next = new Set();
|
|
69
|
+
for (const item of frontier)
|
|
70
|
+
for (const decoded of decodeOnce(item))
|
|
71
|
+
if (!all.has(decoded.toLowerCase())) {
|
|
72
|
+
all.add(decoded.toLowerCase());
|
|
73
|
+
next.add(decoded);
|
|
74
|
+
}
|
|
75
|
+
frontier = next;
|
|
76
|
+
}
|
|
77
|
+
return all;
|
|
78
|
+
}
|
|
79
|
+
function strings(value, path, out, seen) {
|
|
80
|
+
if (typeof value === 'string') {
|
|
81
|
+
out.push({ path, value });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (value === null || typeof value !== 'object' || seen.has(value))
|
|
85
|
+
return;
|
|
86
|
+
seen.add(value);
|
|
87
|
+
if (Array.isArray(value)) {
|
|
88
|
+
value.forEach((item, index) => strings(item, `${path}[${index}]`, out, seen));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (value instanceof Map) {
|
|
92
|
+
for (const [key, item] of value) {
|
|
93
|
+
strings(key, `${path}.<key>`, out, seen);
|
|
94
|
+
strings(item, `${path}.${String(key)}`, out, seen);
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
for (const [key, item] of Object.entries(value)) {
|
|
99
|
+
out.push({ path: `${path}.<key ${key}>`, value: key });
|
|
100
|
+
strings(item, `${path}.${key}`, out, seen);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Plants canaries under `page.root`, sets the document title to a canary, runs `run` with a canary location, then restores the page and
|
|
105
|
+
* scans the returned value and every emitted output. Each hit names its channel and the output it appeared in.
|
|
106
|
+
*/
|
|
107
|
+
export async function runPrivacyCanaries({ page, canaries, run }) {
|
|
108
|
+
const tokens = { ...defaultCanaries(), ...canaries };
|
|
109
|
+
const { document } = page;
|
|
110
|
+
const parent = page.root ?? document.body;
|
|
111
|
+
const section = plant(document, tokens);
|
|
112
|
+
const title = document.title;
|
|
113
|
+
const location = Object.freeze({
|
|
114
|
+
pathname: `/records/${tokens['route-segment']}/${percentEncode(tokens['route-percent'])}`,
|
|
115
|
+
search: `?q=${tokens['route-query']}`,
|
|
116
|
+
hash: `#${tokens['route-fragment']}`,
|
|
117
|
+
});
|
|
118
|
+
const outputs = [];
|
|
119
|
+
const emit = (label, output) => strings(output, `$${label ? `.${label}` : ''}`, outputs, new Set());
|
|
120
|
+
parent.append(section);
|
|
121
|
+
document.title = tokens['document-title'];
|
|
122
|
+
try {
|
|
123
|
+
emit('result', await run(Object.freeze({ document, root: section, location }), emit));
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
section.remove();
|
|
127
|
+
document.title = title;
|
|
128
|
+
}
|
|
129
|
+
const hits = [];
|
|
130
|
+
for (const { path, value } of outputs) {
|
|
131
|
+
const decoded = decodings(value);
|
|
132
|
+
for (const channel of CANARY_CHANNELS) {
|
|
133
|
+
const token = tokens[channel].toLowerCase();
|
|
134
|
+
for (const candidate of decoded)
|
|
135
|
+
if (candidate.includes(token)) {
|
|
136
|
+
hits.push(Object.freeze({ channel, token: tokens[channel], output: path }));
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return Object.freeze({ ok: hits.length === 0, hits: Object.freeze(hits), channels: CANARY_CHANNELS, scanned: outputs.length });
|
|
142
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export * from './privacy/index.js';
|
|
2
|
+
export { runPrivacyCanaries, defaultCanaries, CANARY_CHANNELS, CANARY_SECTION_ATTRIBUTE, type CanaryChannel, type CanaryPage, type CanaryRun, type CanaryHit, type CanaryResult, type PrivacyCanaryOptions, } from './canaries.js';
|
|
3
|
+
export { anchorOf, rangeOf, resolveAppElement, revealElement, fallbackOf, appElementResolution, appElementSchema, APP_ELEMENT_KIND, type AppElementAnchor, type AppElementSignals, type AppElementIdentity, type AppElementRange, type AppElementCapture, type AnchorOfOptions, type RevealResult, } from './app-element.js';
|
|
4
|
+
export * from './picker/index.js';
|
|
5
|
+
export * from './voice/index.js';
|
|
6
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @hachej/boring-feedback/page: browser-side privacy policy, the `app.dom@1` serializer and the privacy canary kit (WP3); `app.element@1` pins (WP5);
|
|
2
|
+
// the picker and its overlay (WP6); voice capture and speech-to-pointer alignment.
|
|
3
|
+
export * from './privacy/index.js';
|
|
4
|
+
export { runPrivacyCanaries, defaultCanaries, CANARY_CHANNELS, CANARY_SECTION_ATTRIBUTE, } from './canaries.js';
|
|
5
|
+
export { anchorOf, rangeOf, resolveAppElement, revealElement, fallbackOf, appElementResolution, appElementSchema, APP_ELEMENT_KIND, } from './app-element.js';
|
|
6
|
+
export * from './picker/index.js';
|
|
7
|
+
// Voice capture and speech-to-pointer alignment.
|
|
8
|
+
export * from './voice/index.js';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { createPicker, pickRefusal, type Picker, type PickerOptions, type PickerResult, type PickerState, type PickerMode } from './picker.js';
|
|
2
|
+
export { createOverlay, type PickerOverlay, type OverlayBox, type OverlayOptions, type OverlayToolbar, type OverlayTone } from './overlay.js';
|
|
3
|
+
export { pickerLabel, componentOf, elementAt } from './label.js';
|
|
4
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type AppElementRange } from '../app-element.js';
|
|
2
|
+
import { type PrivacyPolicy } from '../privacy/policy.js';
|
|
3
|
+
import { type AppDomSnapshot } from '../privacy/serialize.js';
|
|
4
|
+
/** `src/settings/SaveBar.tsx:42` → `SaveBar`; an `index` file is named by its folder. */
|
|
5
|
+
export declare function componentOf(source: string): string | undefined;
|
|
6
|
+
/**
|
|
7
|
+
* The overlay label of an element: `SaveBar · button «Save»` with a development source location, `button «Save»` without one, and
|
|
8
|
+
* `button · masked` when the policy masks its name. Excluded elements are only ever `masked`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function pickerLabel(element: Element, policy: PrivacyPolicy): string;
|
|
11
|
+
/**
|
|
12
|
+
* The live element at a range of a snapshot of `root`, following tags and same-tag positions, without scrolling or re-checking it.
|
|
13
|
+
* For drawing numbered candidates only; revealing one goes through `revealElement`, which re-checks it against the snapshot.
|
|
14
|
+
*/
|
|
15
|
+
export declare function elementAt(range: AppElementRange, root: Element, snapshot: AppDomSnapshot): Element | undefined;
|
|
16
|
+
//# sourceMappingURL=label.d.ts.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Picker labels and live lookups. Every word of a label comes through the privacy policy (FEEDBACK-8): the component name from a kept
|
|
2
|
+
// `data-source`, the role from kept structure, and the accessible name only when the policy allows it; otherwise "masked".
|
|
3
|
+
import { roleOf } from '../app-element.js';
|
|
4
|
+
import { accessibleNameOf } from '../privacy/name.js';
|
|
5
|
+
import { MASKED_NAME, isExcluded } from '../privacy/policy.js';
|
|
6
|
+
import { serializeElement } from '../privacy/serialize.js';
|
|
7
|
+
/** `src/settings/SaveBar.tsx:42` → `SaveBar`; an `index` file is named by its folder. */
|
|
8
|
+
export function componentOf(source) {
|
|
9
|
+
const file = source.replace(/:\d+$/, '');
|
|
10
|
+
const parts = file.split('/');
|
|
11
|
+
const base = (parts.at(-1) ?? '').replace(/\.[cm]?[jt]sx?$/, '');
|
|
12
|
+
const name = base === 'index' && parts.length > 1 ? parts.at(-2) : base;
|
|
13
|
+
return name || undefined;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The overlay label of an element: `SaveBar · button «Save»` with a development source location, `button «Save»` without one, and
|
|
17
|
+
* `button · masked` when the policy masks its name. Excluded elements are only ever `masked`.
|
|
18
|
+
*/
|
|
19
|
+
export function pickerLabel(element, policy) {
|
|
20
|
+
if (isExcluded(element))
|
|
21
|
+
return MASKED_NAME;
|
|
22
|
+
const node = serializeElement(element, policy, { subtreeLimit: 1 }).root;
|
|
23
|
+
if (!node)
|
|
24
|
+
return MASKED_NAME;
|
|
25
|
+
const source = node.attrs['data-source'];
|
|
26
|
+
const component = source === undefined ? undefined : componentOf(source);
|
|
27
|
+
const role = roleOf(node) ?? node.tag;
|
|
28
|
+
const name = accessibleNameOf(element, policy);
|
|
29
|
+
const what = name === MASKED_NAME ? `${role} · ${MASKED_NAME}` : `${role} «${name}»`;
|
|
30
|
+
return component ? `${component} · ${what}` : what;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The live element at a range of a snapshot of `root`, following tags and same-tag positions, without scrolling or re-checking it.
|
|
34
|
+
* For drawing numbered candidates only; revealing one goes through `revealElement`, which re-checks it against the snapshot.
|
|
35
|
+
*/
|
|
36
|
+
export function elementAt(range, root, snapshot) {
|
|
37
|
+
let node = snapshot.root;
|
|
38
|
+
if (!node || node.tag !== root.localName)
|
|
39
|
+
return undefined;
|
|
40
|
+
let live = root;
|
|
41
|
+
for (const step of range) {
|
|
42
|
+
const child = node.children[step];
|
|
43
|
+
if (child === undefined)
|
|
44
|
+
return undefined;
|
|
45
|
+
let seen = 0, found;
|
|
46
|
+
for (const candidate of Array.from(live.children)) {
|
|
47
|
+
if (candidate.localName === child.tag && seen++ === child.index) {
|
|
48
|
+
found = candidate;
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (found === undefined)
|
|
53
|
+
return undefined;
|
|
54
|
+
live = found;
|
|
55
|
+
node = child;
|
|
56
|
+
}
|
|
57
|
+
return isExcluded(live) ? undefined : live;
|
|
58
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** How a box is drawn: the element under the pointer, a pinned element, a revealed element, or a numbered candidate to choose from. */
|
|
2
|
+
export type OverlayTone = 'hover' | 'pinned' | 'reveal' | 'candidate';
|
|
3
|
+
export interface OverlayBox {
|
|
4
|
+
readonly element: Element;
|
|
5
|
+
/** Label text. The picker builds it from the privacy policy only (`pickerLabel`). */
|
|
6
|
+
readonly label: string;
|
|
7
|
+
readonly tone: OverlayTone;
|
|
8
|
+
}
|
|
9
|
+
/** The touch toolbar: a tap selects, these buttons replace the keys. Absent actions are not shown. */
|
|
10
|
+
export interface OverlayToolbar {
|
|
11
|
+
readonly parent: () => void;
|
|
12
|
+
readonly child: () => void;
|
|
13
|
+
readonly pin: () => void;
|
|
14
|
+
readonly cancel: () => void;
|
|
15
|
+
/** Shown when several elements are pinned. */
|
|
16
|
+
readonly done?: () => void;
|
|
17
|
+
}
|
|
18
|
+
export interface PickerOverlay {
|
|
19
|
+
/** The host element (light DOM, outside the application root). */
|
|
20
|
+
readonly host: HTMLElement;
|
|
21
|
+
readonly draw: (boxes: readonly OverlayBox[]) => void;
|
|
22
|
+
/** Draws one revealed element with a note (Show). */
|
|
23
|
+
readonly highlight: (element: Element, note?: string) => void;
|
|
24
|
+
readonly toolbar: (actions: OverlayToolbar | undefined) => void;
|
|
25
|
+
readonly clear: () => void;
|
|
26
|
+
/** What is drawn now, for tests and journeys: each box's label and tone. */
|
|
27
|
+
readonly drawn: () => readonly {
|
|
28
|
+
readonly label: string;
|
|
29
|
+
readonly tone: OverlayTone;
|
|
30
|
+
}[];
|
|
31
|
+
readonly dispose: () => void;
|
|
32
|
+
}
|
|
33
|
+
export interface OverlayOptions {
|
|
34
|
+
readonly document: Document;
|
|
35
|
+
/** Extra CSS for the shadow root (the registry's `PickerOverlay` styles). The parts are `box`, `label` and `toolbar`. */
|
|
36
|
+
readonly styles?: string;
|
|
37
|
+
/** Where the host is appended; defaults to `document.body`. Never inside the application root. */
|
|
38
|
+
readonly mount?: Element;
|
|
39
|
+
}
|
|
40
|
+
/** Creates the overlay host and its shadow root. Nothing is drawn until `draw` or `highlight`. */
|
|
41
|
+
export declare function createOverlay({ document, styles, mount }: OverlayOptions): PickerOverlay;
|
|
42
|
+
//# sourceMappingURL=overlay.d.ts.map
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The picker overlay (FEEDBACK.md, "Pointing at elements: the picker"). One host element marked `data-feedback-overlay`, so every
|
|
2
|
+
// snapshot and the picker skip it, with its own shadow root. The layer never takes pointer events and changes no layout, style or event
|
|
3
|
+
// of the page; only the touch toolbar inside it is interactive. Text is set with `textContent` only.
|
|
4
|
+
import { FEEDBACK_OVERLAY_ATTRIBUTE } from '../privacy/policy.js';
|
|
5
|
+
const BASE_STYLES = `
|
|
6
|
+
:host { all: initial; position: fixed; inset: 0; z-index: 2147483647; pointer-events: none; contain: layout style; }
|
|
7
|
+
.layer { position: absolute; inset: 0; pointer-events: none; }
|
|
8
|
+
.box { position: fixed; box-sizing: border-box; border: 2px solid var(--boring-feedback-accent, var(--primary, #2563eb)); border-radius: 3px;
|
|
9
|
+
background: color-mix(in srgb, var(--boring-feedback-accent, var(--primary, #2563eb)) 12%, transparent); pointer-events: none; }
|
|
10
|
+
.box[data-tone="pinned"] { border-style: solid; border-width: 3px; }
|
|
11
|
+
.box[data-tone="reveal"] { border-color: var(--boring-feedback-reveal, var(--ring, #d97706)); background: color-mix(in srgb, var(--boring-feedback-reveal, var(--ring, #d97706)) 14%, transparent); }
|
|
12
|
+
.box[data-tone="candidate"] { border-style: dashed; }
|
|
13
|
+
.label { position: fixed; max-width: min(28rem, 90vw); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 2px 6px;
|
|
14
|
+
border-radius: 4px; background: var(--boring-feedback-label-background, var(--foreground, #111827)); color: var(--boring-feedback-label-foreground, var(--background, #f9fafb));
|
|
15
|
+
font: 500 12px/1.5 ui-sans-serif, system-ui, sans-serif; pointer-events: none; }
|
|
16
|
+
.label[data-tone="candidate"] { min-width: 1.5em; text-align: center; font-weight: 700; }
|
|
17
|
+
.toolbar { position: fixed; left: 50%; bottom: max(16px, env(safe-area-inset-bottom)); transform: translateX(-50%); display: flex; gap: 6px; padding: 6px;
|
|
18
|
+
border-radius: 12px; background: var(--boring-feedback-label-background, var(--foreground, #111827)); pointer-events: auto; }
|
|
19
|
+
.toolbar[hidden] { display: none; }
|
|
20
|
+
.toolbar button { min-width: 44px; min-height: 44px; padding: 0 12px; border: 0; border-radius: 8px; background: transparent;
|
|
21
|
+
color: var(--boring-feedback-label-foreground, var(--background, #f9fafb)); font: 600 14px/1 ui-sans-serif, system-ui, sans-serif; cursor: pointer; }
|
|
22
|
+
.toolbar button:focus-visible { outline: 2px solid var(--boring-feedback-accent, var(--primary, #2563eb)); }
|
|
23
|
+
`;
|
|
24
|
+
const LABEL_HEIGHT = 22;
|
|
25
|
+
/** Creates the overlay host and its shadow root. Nothing is drawn until `draw` or `highlight`. */
|
|
26
|
+
export function createOverlay({ document, styles, mount }) {
|
|
27
|
+
const host = document.createElement('div');
|
|
28
|
+
host.setAttribute(FEEDBACK_OVERLAY_ATTRIBUTE, '');
|
|
29
|
+
host.setAttribute('data-boring', 'feedback');
|
|
30
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
31
|
+
const style = document.createElement('style');
|
|
32
|
+
style.textContent = BASE_STYLES + (styles ?? '');
|
|
33
|
+
const layer = document.createElement('div');
|
|
34
|
+
layer.className = 'layer';
|
|
35
|
+
layer.setAttribute('aria-hidden', 'true');
|
|
36
|
+
layer.setAttribute('part', 'layer');
|
|
37
|
+
const bar = document.createElement('div');
|
|
38
|
+
bar.className = 'toolbar';
|
|
39
|
+
bar.setAttribute('role', 'toolbar');
|
|
40
|
+
bar.setAttribute('aria-label', 'Pick an element');
|
|
41
|
+
bar.setAttribute('part', 'toolbar');
|
|
42
|
+
bar.hidden = true;
|
|
43
|
+
shadow.append(style, layer, bar);
|
|
44
|
+
(mount ?? document.body).append(host);
|
|
45
|
+
const view = document.defaultView;
|
|
46
|
+
let boxes = [];
|
|
47
|
+
let frame;
|
|
48
|
+
let following = false;
|
|
49
|
+
const render = () => {
|
|
50
|
+
frame = undefined;
|
|
51
|
+
layer.replaceChildren();
|
|
52
|
+
const height = view?.innerHeight ?? 0;
|
|
53
|
+
for (const item of boxes) {
|
|
54
|
+
if (!item.element.isConnected)
|
|
55
|
+
continue;
|
|
56
|
+
const rect = item.element.getBoundingClientRect();
|
|
57
|
+
const box = document.createElement('div');
|
|
58
|
+
box.className = 'box';
|
|
59
|
+
box.dataset['tone'] = item.tone;
|
|
60
|
+
box.setAttribute('part', `box box-${item.tone}`);
|
|
61
|
+
box.style.cssText = `top:${rect.top}px;left:${rect.left}px;width:${rect.width}px;height:${rect.height}px`;
|
|
62
|
+
const label = document.createElement('div');
|
|
63
|
+
label.className = 'label';
|
|
64
|
+
label.dataset['tone'] = item.tone;
|
|
65
|
+
label.setAttribute('part', `label label-${item.tone}`);
|
|
66
|
+
label.textContent = item.label;
|
|
67
|
+
const above = rect.top - LABEL_HEIGHT - 2;
|
|
68
|
+
const top = above >= 0 ? above : Math.min(rect.bottom + 2, Math.max(0, height - LABEL_HEIGHT));
|
|
69
|
+
label.style.cssText = `top:${top}px;left:${Math.max(0, rect.left)}px`;
|
|
70
|
+
layer.append(box, label);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const schedule = () => {
|
|
74
|
+
if (frame !== undefined)
|
|
75
|
+
return;
|
|
76
|
+
if (view && typeof view.requestAnimationFrame === 'function')
|
|
77
|
+
frame = view.requestAnimationFrame(render);
|
|
78
|
+
else
|
|
79
|
+
render();
|
|
80
|
+
};
|
|
81
|
+
const follow = (on) => {
|
|
82
|
+
if (!view || on === following)
|
|
83
|
+
return;
|
|
84
|
+
following = on;
|
|
85
|
+
const method = on ? 'addEventListener' : 'removeEventListener';
|
|
86
|
+
view[method]('scroll', schedule, { capture: true });
|
|
87
|
+
view[method]('resize', schedule);
|
|
88
|
+
};
|
|
89
|
+
const draw = (next) => {
|
|
90
|
+
boxes = Object.freeze([...next]);
|
|
91
|
+
follow(boxes.length > 0);
|
|
92
|
+
if (frame !== undefined && view) {
|
|
93
|
+
view.cancelAnimationFrame(frame);
|
|
94
|
+
frame = undefined;
|
|
95
|
+
}
|
|
96
|
+
render();
|
|
97
|
+
};
|
|
98
|
+
const toolbar = (actions) => {
|
|
99
|
+
bar.replaceChildren();
|
|
100
|
+
bar.hidden = actions === undefined;
|
|
101
|
+
if (!actions)
|
|
102
|
+
return;
|
|
103
|
+
const entries = [
|
|
104
|
+
['parent', 'Parent', actions.parent], ['child', 'Child', actions.child], ['pin', 'Pin', actions.pin], ['done', 'Done', actions.done], ['cancel', 'Cancel', actions.cancel],
|
|
105
|
+
];
|
|
106
|
+
for (const [name, text, run] of entries) {
|
|
107
|
+
if (!run)
|
|
108
|
+
continue;
|
|
109
|
+
const button = document.createElement('button');
|
|
110
|
+
button.type = 'button';
|
|
111
|
+
button.dataset['action'] = name;
|
|
112
|
+
button.textContent = text;
|
|
113
|
+
button.addEventListener('click', event => { event.preventDefault(); run(); });
|
|
114
|
+
bar.append(button);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
return {
|
|
118
|
+
host,
|
|
119
|
+
draw,
|
|
120
|
+
highlight: (element, note) => draw([{ element, label: note ?? 'Here', tone: 'reveal' }]),
|
|
121
|
+
toolbar,
|
|
122
|
+
clear: () => draw([]),
|
|
123
|
+
drawn: () => boxes.filter(item => item.element.isConnected).map(({ label, tone }) => ({ label, tone })),
|
|
124
|
+
dispose: () => { draw([]); toolbar(undefined); host.remove(); },
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type PrivacyPolicy } from '../privacy/policy.js';
|
|
2
|
+
import { type PickerOverlay } from './overlay.js';
|
|
3
|
+
export type PickerMode = 'idle' | 'picking' | 'done';
|
|
4
|
+
export interface PickerState {
|
|
5
|
+
readonly mode: PickerMode;
|
|
6
|
+
/** The element the next pin takes. */
|
|
7
|
+
readonly current?: Element;
|
|
8
|
+
/** Its label, from the privacy policy. */
|
|
9
|
+
readonly label?: string;
|
|
10
|
+
readonly pins: readonly Element[];
|
|
11
|
+
/** True once a touch selected: the overlay shows its Parent, Child and Pin buttons. */
|
|
12
|
+
readonly touch: boolean;
|
|
13
|
+
/** Why the element under the pointer cannot be picked (outside the application root). */
|
|
14
|
+
readonly refusal?: string;
|
|
15
|
+
/** Pick mode is on but every event currently reaches the page (`suspend`). */
|
|
16
|
+
readonly suspended: boolean;
|
|
17
|
+
}
|
|
18
|
+
export type PickerResult = {
|
|
19
|
+
readonly kind: 'pinned';
|
|
20
|
+
readonly elements: readonly Element[];
|
|
21
|
+
} | {
|
|
22
|
+
readonly kind: 'cancelled';
|
|
23
|
+
};
|
|
24
|
+
export interface PickerOptions {
|
|
25
|
+
/** The application root: only elements inside it (light DOM, same document) are pickable. */
|
|
26
|
+
readonly root: Element;
|
|
27
|
+
readonly policy: PrivacyPolicy;
|
|
28
|
+
/** A shared overlay; otherwise the picker creates one (with `styles`) and disposes it with itself. */
|
|
29
|
+
readonly overlay?: PickerOverlay;
|
|
30
|
+
readonly styles?: string;
|
|
31
|
+
/** Shift-click and Shift-Enter pin several elements. Default true. */
|
|
32
|
+
readonly multiple?: boolean;
|
|
33
|
+
/** Elements at a viewport point, topmost first. Defaults to `document.elementsFromPoint`. */
|
|
34
|
+
readonly hitTest?: (x: number, y: number) => readonly Element[];
|
|
35
|
+
readonly onChange?: (state: PickerState) => void;
|
|
36
|
+
readonly onFinish?: (result: PickerResult) => void;
|
|
37
|
+
/** Continuous picking: a click, Enter or the touch Pin hands the element here and pick mode stays on. Shift has no effect. */
|
|
38
|
+
readonly onPick?: (element: Element) => void;
|
|
39
|
+
/** The wheel selects the parent and the child (default). False leaves wheel events, and so scrolling, to the page. */
|
|
40
|
+
readonly wheel?: boolean;
|
|
41
|
+
/** `'all'` (default): every key belongs to the picker. `'outside-ignored'`: keys inside ignored subtrees reach them. */
|
|
42
|
+
readonly keys?: 'all' | 'outside-ignored';
|
|
43
|
+
/** `'select'` (default): a tap selects and the touch toolbar pins. `'pick'`, with `onPick`: a tap picks at once and no toolbar shows. */
|
|
44
|
+
readonly tap?: 'select' | 'pick';
|
|
45
|
+
}
|
|
46
|
+
export interface Picker {
|
|
47
|
+
/** Enters pick mode. Resolves when the person pins (Click, Enter, Done) or cancels (Esc, Cancel, `dispose`). */
|
|
48
|
+
readonly start: () => Promise<PickerResult>;
|
|
49
|
+
readonly cancel: () => void;
|
|
50
|
+
/** Ends with the pins so far, or the current element when nothing is pinned yet. */
|
|
51
|
+
readonly finish: () => void;
|
|
52
|
+
readonly parent: () => void;
|
|
53
|
+
readonly child: () => void;
|
|
54
|
+
/** Pins the current element; with `add`, keeps picking for more. */
|
|
55
|
+
readonly pin: (options?: {
|
|
56
|
+
readonly add?: boolean;
|
|
57
|
+
}) => void;
|
|
58
|
+
/** Makes an element current (refused when it is not pickable). */
|
|
59
|
+
readonly select: (element: Element) => boolean;
|
|
60
|
+
readonly state: () => PickerState;
|
|
61
|
+
readonly overlay: PickerOverlay;
|
|
62
|
+
/** Draws one element with a note on the same overlay (Show). */
|
|
63
|
+
readonly highlight: (element: Element, note?: string) => void;
|
|
64
|
+
/** While suspended, pick mode stays on but no event is taken: the page works as usual. Resuming takes them again. */
|
|
65
|
+
readonly suspend: (suspended: boolean) => void;
|
|
66
|
+
/** Cancels pick mode if it is on and removes an overlay the picker created. */
|
|
67
|
+
readonly dispose: () => void;
|
|
68
|
+
}
|
|
69
|
+
/** Why an element cannot be picked, or undefined when it can. */
|
|
70
|
+
export declare function pickRefusal(element: Element, root: Element): string | undefined;
|
|
71
|
+
export declare function createPicker(options: PickerOptions): Picker;
|
|
72
|
+
//# sourceMappingURL=picker.d.ts.map
|