@pygmalionjs/pygmalion 0.6.0 → 0.6.2
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/README.md +3 -1
- package/dist-lib/{FrozenRoutePreview-DStbZQD4.js → FrozenRoutePreview-X4XRmf9G.js} +2066 -1733
- package/dist-lib/pygmalion.js +7605 -6877
- package/dist-lib/style.css +1 -1
- package/dist-lib/testing.js +1 -1
- package/dist-lib/types/canvas/ObjectControls.d.ts +8 -5
- package/dist-lib/types/editor/automaticMotion.d.ts +24 -0
- package/dist-lib/types/editor/automaticPseudoStates.d.ts +21 -0
- package/dist-lib/types/editor/previewBootstrap.d.ts +8 -0
- package/dist-lib/types/editor/projectRuntime.d.ts +5 -0
- package/dist-lib/types/editor/shadowPreview.d.ts +9 -0
- package/dist-lib/types/editor/store.d.ts +22 -0
- package/dist-lib/types/lib.d.ts +1 -1
- package/docs/screen-state-contract.md +46 -1
- package/node/automatic-motion-runtime.mjs +279 -0
- package/node/automatic-pseudo-runtime.mjs +383 -0
- package/node/dev-mirror.mjs +79 -8
- package/node/inspect-plugin.mjs +37 -0
- package/node/storyboard-canonical.mjs +1 -1
- package/node/storyboard-capture-runtime.mjs +36 -7
- package/package.json +3 -1
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Annotates visible CSS and Web Animation targets in the running capture page.
|
|
3
|
+
*
|
|
4
|
+
* This function is intentionally self-contained because Playwright serializes
|
|
5
|
+
* it into the browser realm. Keep its metadata contract and discovery behavior
|
|
6
|
+
* aligned with src/editor/automaticMotion.ts.
|
|
7
|
+
*/
|
|
8
|
+
export function annotateStoryboardAutomaticMotionTargets() {
|
|
9
|
+
const metadataAttribute = 'data-pygmalion-auto-motion';
|
|
10
|
+
const ledgerAttribute = 'data-pygmalion-auto-motions';
|
|
11
|
+
const declarationAttribute = 'data-pygmalion-motion';
|
|
12
|
+
const motionPseudoPattern =
|
|
13
|
+
/:(focus-visible|focus-within|hover|focus|active)(?![a-z0-9_-])/gi;
|
|
14
|
+
const normalizeText = (value) =>
|
|
15
|
+
String(value ?? '')
|
|
16
|
+
.replace(/\s+/g, ' ')
|
|
17
|
+
.trim();
|
|
18
|
+
const compactLabel = (value) => {
|
|
19
|
+
const normalized = normalizeText(value);
|
|
20
|
+
return normalized.length <= 52
|
|
21
|
+
? normalized
|
|
22
|
+
: `${normalized.slice(0, 49).trimEnd()}…`;
|
|
23
|
+
};
|
|
24
|
+
const attributeSelector = (name, value) => {
|
|
25
|
+
const escaped = String(value)
|
|
26
|
+
.replaceAll('\\', '\\\\')
|
|
27
|
+
.replaceAll('"', '\\"')
|
|
28
|
+
.replaceAll('\n', '\\a ')
|
|
29
|
+
.replaceAll('\r', '\\d ');
|
|
30
|
+
return `[${name}="${escaped}"]`;
|
|
31
|
+
};
|
|
32
|
+
const splitSelectorList = (selectorText) => {
|
|
33
|
+
const selectors = [];
|
|
34
|
+
let start = 0;
|
|
35
|
+
let round = 0;
|
|
36
|
+
let square = 0;
|
|
37
|
+
let quote = '';
|
|
38
|
+
for (let index = 0; index < selectorText.length; index += 1) {
|
|
39
|
+
const character = selectorText[index];
|
|
40
|
+
if (quote) {
|
|
41
|
+
if (character === '\\') index += 1;
|
|
42
|
+
else if (character === quote) quote = '';
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (character === '"' || character === "'") {
|
|
46
|
+
quote = character;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (character === '(') round += 1;
|
|
50
|
+
else if (character === ')') round = Math.max(0, round - 1);
|
|
51
|
+
else if (character === '[') square += 1;
|
|
52
|
+
else if (character === ']') square = Math.max(0, square - 1);
|
|
53
|
+
else if (character === ',' && round === 0 && square === 0) {
|
|
54
|
+
selectors.push(selectorText.slice(start, index).trim());
|
|
55
|
+
start = index + 1;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
selectors.push(selectorText.slice(start).trim());
|
|
59
|
+
return selectors.filter(Boolean);
|
|
60
|
+
};
|
|
61
|
+
const visibleElement = (element) => {
|
|
62
|
+
let style;
|
|
63
|
+
try {
|
|
64
|
+
style = getComputedStyle(element);
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
if (
|
|
69
|
+
style.display === 'none' ||
|
|
70
|
+
style.visibility === 'hidden' ||
|
|
71
|
+
style.visibility === 'collapse'
|
|
72
|
+
) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
const rect = element.getBoundingClientRect();
|
|
76
|
+
return rect.width > 0 && rect.height > 0;
|
|
77
|
+
};
|
|
78
|
+
const computedStyleCarriesMotion = (style) => {
|
|
79
|
+
const names = String(style.animationName ?? '').trim();
|
|
80
|
+
if (names) {
|
|
81
|
+
return names
|
|
82
|
+
.split(',')
|
|
83
|
+
.some((name) => name.trim().toLowerCase() !== 'none');
|
|
84
|
+
}
|
|
85
|
+
const shorthand = String(style.animation ?? '').trim();
|
|
86
|
+
return Boolean(shorthand && shorthand.toLowerCase() !== 'none');
|
|
87
|
+
};
|
|
88
|
+
const computedMotionCandidate = (element) => {
|
|
89
|
+
try {
|
|
90
|
+
if (computedStyleCarriesMotion(getComputedStyle(element))) return true;
|
|
91
|
+
return ['::before', '::after'].some((pseudo) =>
|
|
92
|
+
computedStyleCarriesMotion(getComputedStyle(element, pseudo)),
|
|
93
|
+
);
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const declarationCarriesMotion = (style) => {
|
|
99
|
+
const names = String(style.getPropertyValue('animation-name') ?? '').trim();
|
|
100
|
+
if (
|
|
101
|
+
names &&
|
|
102
|
+
names.split(',').some((name) => name.trim().toLowerCase() !== 'none')
|
|
103
|
+
) {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
const shorthand = String(style.getPropertyValue('animation') ?? '').trim();
|
|
107
|
+
return Boolean(shorthand && shorthand.toLowerCase() !== 'none');
|
|
108
|
+
};
|
|
109
|
+
const motionSubjectSelector = (selector) => {
|
|
110
|
+
const subject = selector
|
|
111
|
+
.replace(/::[a-z0-9_-]+(?:\([^)]*\))?/gi, '')
|
|
112
|
+
.replace(
|
|
113
|
+
/:not\(\s*:(focus-visible|focus-within|hover|focus|active)\s*\)/gi,
|
|
114
|
+
'',
|
|
115
|
+
)
|
|
116
|
+
.replace(motionPseudoPattern, '')
|
|
117
|
+
.replace(/:(?:is|where|not|has)\(\s*(?:,\s*)*\)/gi, '')
|
|
118
|
+
.trim();
|
|
119
|
+
motionPseudoPattern.lastIndex = 0;
|
|
120
|
+
return subject || null;
|
|
121
|
+
};
|
|
122
|
+
const candidates = new Set();
|
|
123
|
+
const elements = [...document.querySelectorAll('*')];
|
|
124
|
+
for (const element of elements) {
|
|
125
|
+
if (!visibleElement(element)) continue;
|
|
126
|
+
if (
|
|
127
|
+
element.hasAttribute(declarationAttribute) ||
|
|
128
|
+
computedMotionCandidate(element)
|
|
129
|
+
) {
|
|
130
|
+
candidates.add(element);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const inspectRules = (rules) => {
|
|
134
|
+
for (const rule of [...rules]) {
|
|
135
|
+
if (
|
|
136
|
+
typeof rule.selectorText === 'string' &&
|
|
137
|
+
rule.style &&
|
|
138
|
+
declarationCarriesMotion(rule.style)
|
|
139
|
+
) {
|
|
140
|
+
for (const selector of splitSelectorList(rule.selectorText)) {
|
|
141
|
+
const subject = motionSubjectSelector(selector);
|
|
142
|
+
if (!subject) continue;
|
|
143
|
+
try {
|
|
144
|
+
for (const element of document.querySelectorAll(subject)) {
|
|
145
|
+
if (visibleElement(element)) candidates.add(element);
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
// Continue through selectors the current engine can query.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (rule.cssRules) inspectRules(rule.cssRules);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
for (const sheet of [...document.styleSheets]) {
|
|
156
|
+
try {
|
|
157
|
+
inspectRules(sheet.cssRules);
|
|
158
|
+
} catch {
|
|
159
|
+
// Cross-origin stylesheets cannot expose their rules.
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
for (const animation of document.getAnimations?.({ subtree: true }) ?? []) {
|
|
164
|
+
const target = animation.effect?.target;
|
|
165
|
+
if (target?.nodeType === 1 && visibleElement(target)) {
|
|
166
|
+
candidates.add(target);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// CSS discovery remains available without getAnimations.
|
|
171
|
+
}
|
|
172
|
+
const selectorForElement = (element) => {
|
|
173
|
+
if (element === document.documentElement) {
|
|
174
|
+
return { selector: 'html', index: 0 };
|
|
175
|
+
}
|
|
176
|
+
if (element === document.body) return { selector: 'body', index: 0 };
|
|
177
|
+
for (const name of [
|
|
178
|
+
'data-testid',
|
|
179
|
+
'data-pygmalion-own-source',
|
|
180
|
+
'data-pygmalion-source',
|
|
181
|
+
'id',
|
|
182
|
+
]) {
|
|
183
|
+
const value = element.getAttribute(name);
|
|
184
|
+
if (!value) continue;
|
|
185
|
+
const selector = attributeSelector(name, value);
|
|
186
|
+
try {
|
|
187
|
+
const matches = [...document.querySelectorAll(selector)];
|
|
188
|
+
const index = matches.indexOf(element);
|
|
189
|
+
if (index >= 0) return { selector, index };
|
|
190
|
+
} catch {
|
|
191
|
+
// Try the next identity source.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const segments = [];
|
|
195
|
+
let current = element;
|
|
196
|
+
while (current && current !== document.body) {
|
|
197
|
+
const parent = current.parentElement;
|
|
198
|
+
if (!parent) return null;
|
|
199
|
+
const tag = current.tagName.toLowerCase();
|
|
200
|
+
const siblings = [...parent.children].filter(
|
|
201
|
+
(candidate) => candidate.tagName === current.tagName,
|
|
202
|
+
);
|
|
203
|
+
segments.unshift(
|
|
204
|
+
`${tag}:nth-of-type(${siblings.indexOf(current) + 1})`,
|
|
205
|
+
);
|
|
206
|
+
current = parent;
|
|
207
|
+
}
|
|
208
|
+
return current && segments.length > 0
|
|
209
|
+
? { selector: `body > ${segments.join(' > ')}`, index: 0 }
|
|
210
|
+
: null;
|
|
211
|
+
};
|
|
212
|
+
const targetLabel = (element) => {
|
|
213
|
+
const descendantLabel = element
|
|
214
|
+
.querySelector('[aria-label]')
|
|
215
|
+
?.getAttribute('aria-label');
|
|
216
|
+
const ancestorLabel = element.parentElement
|
|
217
|
+
?.closest('[aria-label]')
|
|
218
|
+
?.getAttribute('aria-label');
|
|
219
|
+
const sourceName = element
|
|
220
|
+
.getAttribute('data-pygmalion-source-style')
|
|
221
|
+
?.split('#')
|
|
222
|
+
.at(-1);
|
|
223
|
+
return compactLabel(
|
|
224
|
+
element.getAttribute('aria-label') ||
|
|
225
|
+
element.getAttribute('title') ||
|
|
226
|
+
descendantLabel ||
|
|
227
|
+
ancestorLabel ||
|
|
228
|
+
element.getAttribute('data-testid') ||
|
|
229
|
+
normalizeText(element.textContent) ||
|
|
230
|
+
sourceName ||
|
|
231
|
+
element.getAttribute('role') ||
|
|
232
|
+
element.tagName.toLowerCase(),
|
|
233
|
+
);
|
|
234
|
+
};
|
|
235
|
+
const stableId = (value) => {
|
|
236
|
+
let hash = 0x811c9dc5;
|
|
237
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
238
|
+
hash ^= value.charCodeAt(index);
|
|
239
|
+
hash = Math.imul(hash, 0x01000193);
|
|
240
|
+
}
|
|
241
|
+
return (hash >>> 0).toString(36);
|
|
242
|
+
};
|
|
243
|
+
const order = new Map(elements.map((element, index) => [element, index]));
|
|
244
|
+
const pending = [...candidates]
|
|
245
|
+
.flatMap((element) => {
|
|
246
|
+
const identity = selectorForElement(element);
|
|
247
|
+
return identity
|
|
248
|
+
? [{ element, identity, baseLabel: targetLabel(element) }]
|
|
249
|
+
: [];
|
|
250
|
+
})
|
|
251
|
+
.sort(
|
|
252
|
+
(left, right) =>
|
|
253
|
+
(order.get(left.element) ?? Number.MAX_SAFE_INTEGER) -
|
|
254
|
+
(order.get(right.element) ?? Number.MAX_SAFE_INTEGER),
|
|
255
|
+
);
|
|
256
|
+
const totals = new Map();
|
|
257
|
+
for (const entry of pending) {
|
|
258
|
+
totals.set(entry.baseLabel, (totals.get(entry.baseLabel) ?? 0) + 1);
|
|
259
|
+
}
|
|
260
|
+
const seen = new Map();
|
|
261
|
+
const targets = pending.map(({ element, identity, baseLabel }) => {
|
|
262
|
+
const ordinal = (seen.get(baseLabel) ?? 0) + 1;
|
|
263
|
+
seen.set(baseLabel, ordinal);
|
|
264
|
+
const total = totals.get(baseLabel) ?? 1;
|
|
265
|
+
const label =
|
|
266
|
+
total > 1 ? `${baseLabel} · ${ordinal}/${total}` : baseLabel;
|
|
267
|
+
const key = `${identity.selector}\u0000${identity.index}`;
|
|
268
|
+
const target = {
|
|
269
|
+
id: `auto-motion:${stableId(key)}`,
|
|
270
|
+
label,
|
|
271
|
+
selector: identity.selector,
|
|
272
|
+
index: identity.index,
|
|
273
|
+
};
|
|
274
|
+
element.setAttribute(metadataAttribute, JSON.stringify(target));
|
|
275
|
+
return target;
|
|
276
|
+
});
|
|
277
|
+
document.body?.setAttribute(ledgerAttribute, JSON.stringify(targets));
|
|
278
|
+
return targets;
|
|
279
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Annotates visible pseudo-state targets in the running capture page.
|
|
3
|
+
*
|
|
4
|
+
* The function is intentionally self-contained: Playwright serializes it into
|
|
5
|
+
* the browser realm. Keep its metadata schema and discovery behavior aligned
|
|
6
|
+
* with src/editor/automaticPseudoStates.ts.
|
|
7
|
+
*/
|
|
8
|
+
export function annotateStoryboardAutomaticPseudoStates() {
|
|
9
|
+
const metadataAttribute = 'data-pygmalion-auto-pseudo';
|
|
10
|
+
const ledgerAttribute = 'data-pygmalion-auto-pseudos';
|
|
11
|
+
const eventAttribute = 'data-pygmalion-pseudo-events';
|
|
12
|
+
const stateOrder = ['hover', 'focus-visible', 'active'];
|
|
13
|
+
const pseudoPattern =
|
|
14
|
+
/:(focus-visible|focus-within|hover|focus|active)(?![a-z0-9_-])/gi;
|
|
15
|
+
const focusableSelector = [
|
|
16
|
+
'button:not(:disabled)',
|
|
17
|
+
'a[href]',
|
|
18
|
+
'input:not(:disabled)',
|
|
19
|
+
'textarea:not(:disabled)',
|
|
20
|
+
'select:not(:disabled)',
|
|
21
|
+
'summary',
|
|
22
|
+
'area[href]',
|
|
23
|
+
'audio[controls]',
|
|
24
|
+
'video[controls]',
|
|
25
|
+
'iframe',
|
|
26
|
+
'[contenteditable]:not([contenteditable="false"])',
|
|
27
|
+
'[tabindex]:not(:disabled)',
|
|
28
|
+
].join(',');
|
|
29
|
+
const hoverReactEventProps = [
|
|
30
|
+
'onMouseEnter',
|
|
31
|
+
'onMouseOver',
|
|
32
|
+
'onMouseMove',
|
|
33
|
+
'onPointerEnter',
|
|
34
|
+
'onPointerOver',
|
|
35
|
+
'onPointerMove',
|
|
36
|
+
];
|
|
37
|
+
const focusReactEventProps = ['onFocus', 'onFocusCapture'];
|
|
38
|
+
|
|
39
|
+
const normalizeText = (value) =>
|
|
40
|
+
String(value ?? '')
|
|
41
|
+
.replace(/\s+/g, ' ')
|
|
42
|
+
.trim();
|
|
43
|
+
const compactLabel = (value) => {
|
|
44
|
+
const normalized = normalizeText(value);
|
|
45
|
+
return normalized.length <= 52
|
|
46
|
+
? normalized
|
|
47
|
+
: `${normalized.slice(0, 49).trimEnd()}…`;
|
|
48
|
+
};
|
|
49
|
+
const attributeSelector = (name, value) => {
|
|
50
|
+
const escaped = String(value)
|
|
51
|
+
.replaceAll('\\', '\\\\')
|
|
52
|
+
.replaceAll('"', '\\"')
|
|
53
|
+
.replaceAll('\n', '\\a ')
|
|
54
|
+
.replaceAll('\r', '\\d ');
|
|
55
|
+
return `[${name}="${escaped}"]`;
|
|
56
|
+
};
|
|
57
|
+
const splitSelectorList = (selectorText) => {
|
|
58
|
+
const selectors = [];
|
|
59
|
+
let start = 0;
|
|
60
|
+
let round = 0;
|
|
61
|
+
let square = 0;
|
|
62
|
+
let quote = '';
|
|
63
|
+
for (let index = 0; index < selectorText.length; index += 1) {
|
|
64
|
+
const character = selectorText[index];
|
|
65
|
+
if (quote) {
|
|
66
|
+
if (character === '\\') index += 1;
|
|
67
|
+
else if (character === quote) quote = '';
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (character === '"' || character === "'") {
|
|
71
|
+
quote = character;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (character === '(') round += 1;
|
|
75
|
+
else if (character === ')') round = Math.max(0, round - 1);
|
|
76
|
+
else if (character === '[') square += 1;
|
|
77
|
+
else if (character === ']') square = Math.max(0, square - 1);
|
|
78
|
+
else if (character === ',' && round === 0 && square === 0) {
|
|
79
|
+
selectors.push(selectorText.slice(start, index).trim());
|
|
80
|
+
start = index + 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
selectors.push(selectorText.slice(start).trim());
|
|
84
|
+
return selectors.filter(Boolean);
|
|
85
|
+
};
|
|
86
|
+
const visibleElement = (element) => {
|
|
87
|
+
let style;
|
|
88
|
+
try {
|
|
89
|
+
style = getComputedStyle(element);
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
if (
|
|
94
|
+
style.display === 'none' ||
|
|
95
|
+
style.visibility === 'hidden' ||
|
|
96
|
+
style.visibility === 'collapse'
|
|
97
|
+
) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const rect = element.getBoundingClientRect();
|
|
101
|
+
return rect.width > 0 && rect.height > 0;
|
|
102
|
+
};
|
|
103
|
+
const focusTarget = (element) => {
|
|
104
|
+
try {
|
|
105
|
+
if (element.matches(focusableSelector) && visibleElement(element)) {
|
|
106
|
+
return element;
|
|
107
|
+
}
|
|
108
|
+
return (
|
|
109
|
+
[...element.querySelectorAll(focusableSelector)].find(visibleElement) ??
|
|
110
|
+
null
|
|
111
|
+
);
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const normalizedNegatedPseudo = (selector) =>
|
|
117
|
+
selector.replace(
|
|
118
|
+
/:not\(\s*:(focus-visible|focus-within|hover|focus|active)\s*\)/gi,
|
|
119
|
+
(_match, state) => `:${state}`,
|
|
120
|
+
);
|
|
121
|
+
const compoundEndAfter = (selector, start) => {
|
|
122
|
+
let round = 0;
|
|
123
|
+
let square = 0;
|
|
124
|
+
let quote = '';
|
|
125
|
+
for (let index = 0; index < selector.length; index += 1) {
|
|
126
|
+
const character = selector[index];
|
|
127
|
+
if (quote) {
|
|
128
|
+
if (character === '\\') index += 1;
|
|
129
|
+
else if (character === quote) quote = '';
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (character === '"' || character === "'") {
|
|
133
|
+
quote = character;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (character === '(') round += 1;
|
|
137
|
+
else if (character === ')') round = Math.max(0, round - 1);
|
|
138
|
+
else if (character === '[') square += 1;
|
|
139
|
+
else if (character === ']') square = Math.max(0, square - 1);
|
|
140
|
+
if (
|
|
141
|
+
index >= start &&
|
|
142
|
+
round === 0 &&
|
|
143
|
+
square === 0 &&
|
|
144
|
+
(character === '>' ||
|
|
145
|
+
character === '+' ||
|
|
146
|
+
character === '~' ||
|
|
147
|
+
/\s/.test(character))
|
|
148
|
+
) {
|
|
149
|
+
return index;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return selector.length;
|
|
153
|
+
};
|
|
154
|
+
const pseudoSubjectSelector = (selectorText, pseudo) => {
|
|
155
|
+
const selector = normalizedNegatedPseudo(selectorText);
|
|
156
|
+
pseudoPattern.lastIndex = 0;
|
|
157
|
+
const matches = [...selector.matchAll(pseudoPattern)].filter((match) => {
|
|
158
|
+
const state = match[1].toLowerCase();
|
|
159
|
+
return pseudo === 'focus-visible'
|
|
160
|
+
? state === 'focus' ||
|
|
161
|
+
state === 'focus-visible' ||
|
|
162
|
+
state === 'focus-within'
|
|
163
|
+
: state === pseudo;
|
|
164
|
+
});
|
|
165
|
+
pseudoPattern.lastIndex = 0;
|
|
166
|
+
const match = matches.at(-1);
|
|
167
|
+
if (!match || match.index == null) return null;
|
|
168
|
+
const end = compoundEndAfter(selector, match.index + match[0].length);
|
|
169
|
+
const prefix = selector
|
|
170
|
+
.slice(0, end)
|
|
171
|
+
.replace(pseudoPattern, '')
|
|
172
|
+
.replace(/::[a-z0-9_-]+(?:\([^)]*\))?/gi, '')
|
|
173
|
+
.replace(/:(?:is|where|not)\(\s*(?:,\s*)*\)/gi, '')
|
|
174
|
+
.trim();
|
|
175
|
+
pseudoPattern.lastIndex = 0;
|
|
176
|
+
return prefix || null;
|
|
177
|
+
};
|
|
178
|
+
const activeGroupingRule = (rule) => {
|
|
179
|
+
if (rule.type === CSSRule.MEDIA_RULE) {
|
|
180
|
+
return !rule.conditionText || matchMedia(rule.conditionText).matches;
|
|
181
|
+
}
|
|
182
|
+
if (rule.type === CSSRule.SUPPORTS_RULE) {
|
|
183
|
+
try {
|
|
184
|
+
return !rule.conditionText || CSS.supports(rule.conditionText);
|
|
185
|
+
} catch {
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
};
|
|
191
|
+
const found = new Map();
|
|
192
|
+
const add = (element, state) => {
|
|
193
|
+
if (!visibleElement(element)) return;
|
|
194
|
+
const states = found.get(element) ?? new Set();
|
|
195
|
+
states.add(state);
|
|
196
|
+
found.set(element, states);
|
|
197
|
+
};
|
|
198
|
+
const inspectRules = (rules) => {
|
|
199
|
+
for (const rule of [...rules]) {
|
|
200
|
+
const selectorText = rule.selectorText;
|
|
201
|
+
pseudoPattern.lastIndex = 0;
|
|
202
|
+
const carriesPseudo =
|
|
203
|
+
typeof selectorText === 'string' && pseudoPattern.test(selectorText);
|
|
204
|
+
pseudoPattern.lastIndex = 0;
|
|
205
|
+
if (carriesPseudo) {
|
|
206
|
+
for (const selector of splitSelectorList(selectorText)) {
|
|
207
|
+
const rawStates = new Set(
|
|
208
|
+
[...selector.matchAll(pseudoPattern)].map((match) =>
|
|
209
|
+
match[1].toLowerCase(),
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
pseudoPattern.lastIndex = 0;
|
|
213
|
+
const states = [];
|
|
214
|
+
if (rawStates.has('hover')) states.push('hover');
|
|
215
|
+
if (
|
|
216
|
+
rawStates.has('focus') ||
|
|
217
|
+
rawStates.has('focus-visible') ||
|
|
218
|
+
rawStates.has('focus-within')
|
|
219
|
+
) {
|
|
220
|
+
states.push('focus-visible');
|
|
221
|
+
}
|
|
222
|
+
if (rawStates.has('active')) states.push('active');
|
|
223
|
+
for (const state of states) {
|
|
224
|
+
const subjectSelector = pseudoSubjectSelector(selector, state);
|
|
225
|
+
if (!subjectSelector) continue;
|
|
226
|
+
let subjects;
|
|
227
|
+
try {
|
|
228
|
+
subjects = [...document.querySelectorAll(subjectSelector)];
|
|
229
|
+
} catch {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
for (const subject of subjects) {
|
|
233
|
+
if (state === 'focus-visible') {
|
|
234
|
+
const target = focusTarget(subject);
|
|
235
|
+
if (target) add(target, state);
|
|
236
|
+
} else {
|
|
237
|
+
add(subject, state);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (rule.cssRules && activeGroupingRule(rule)) {
|
|
244
|
+
inspectRules(rule.cssRules);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
for (const sheet of [...document.styleSheets]) {
|
|
249
|
+
try {
|
|
250
|
+
inspectRules(sheet.cssRules);
|
|
251
|
+
} catch {
|
|
252
|
+
// Cross-origin stylesheets cannot be inspected.
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
for (const element of document.querySelectorAll('*')) {
|
|
256
|
+
if (!visibleElement(element)) continue;
|
|
257
|
+
const declared = new Set(
|
|
258
|
+
String(element.getAttribute(eventAttribute) ?? '')
|
|
259
|
+
.split(',')
|
|
260
|
+
.map((value) => value.trim()),
|
|
261
|
+
);
|
|
262
|
+
for (const key of Object.getOwnPropertyNames(element)) {
|
|
263
|
+
if (!key.startsWith('__reactProps$')) continue;
|
|
264
|
+
const props = element[key];
|
|
265
|
+
if (!props || typeof props !== 'object') continue;
|
|
266
|
+
if (hoverReactEventProps.some((name) => typeof props[name] === 'function')) {
|
|
267
|
+
declared.add('hover');
|
|
268
|
+
}
|
|
269
|
+
if (focusReactEventProps.some((name) => typeof props[name] === 'function')) {
|
|
270
|
+
declared.add('focus');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const states = found.get(element) ?? new Set();
|
|
274
|
+
if (declared.has('hover')) states.add('hover');
|
|
275
|
+
if (declared.has('focus') && focusTarget(element) === element) {
|
|
276
|
+
states.add('focus-visible');
|
|
277
|
+
}
|
|
278
|
+
if (states.size > 0) found.set(element, states);
|
|
279
|
+
}
|
|
280
|
+
const selectorForElement = (element) => {
|
|
281
|
+
if (element === document.documentElement) {
|
|
282
|
+
return { selector: 'html', index: 0 };
|
|
283
|
+
}
|
|
284
|
+
if (element === document.body) return { selector: 'body', index: 0 };
|
|
285
|
+
for (const name of [
|
|
286
|
+
'data-testid',
|
|
287
|
+
'data-pygmalion-own-source',
|
|
288
|
+
'data-pygmalion-source',
|
|
289
|
+
'id',
|
|
290
|
+
]) {
|
|
291
|
+
const value = element.getAttribute(name);
|
|
292
|
+
if (!value) continue;
|
|
293
|
+
const selector = attributeSelector(name, value);
|
|
294
|
+
try {
|
|
295
|
+
const matches = [...document.querySelectorAll(selector)];
|
|
296
|
+
const index = matches.indexOf(element);
|
|
297
|
+
if (index >= 0) return { selector, index };
|
|
298
|
+
} catch {
|
|
299
|
+
// Try the next identity source.
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const segments = [];
|
|
303
|
+
let current = element;
|
|
304
|
+
while (current && current !== document.body) {
|
|
305
|
+
const parent = current.parentElement;
|
|
306
|
+
if (!parent) return null;
|
|
307
|
+
const tag = current.tagName.toLowerCase();
|
|
308
|
+
const siblings = [...parent.children].filter(
|
|
309
|
+
(candidate) => candidate.tagName === current.tagName,
|
|
310
|
+
);
|
|
311
|
+
segments.unshift(
|
|
312
|
+
`${tag}:nth-of-type(${siblings.indexOf(current) + 1})`,
|
|
313
|
+
);
|
|
314
|
+
current = parent;
|
|
315
|
+
}
|
|
316
|
+
return current && segments.length > 0
|
|
317
|
+
? { selector: `body > ${segments.join(' > ')}`, index: 0 }
|
|
318
|
+
: null;
|
|
319
|
+
};
|
|
320
|
+
const targetLabel = (element) => {
|
|
321
|
+
const descendantLabel = element
|
|
322
|
+
.querySelector('[aria-label]')
|
|
323
|
+
?.getAttribute('aria-label');
|
|
324
|
+
const sourceName = element
|
|
325
|
+
.getAttribute('data-pygmalion-source-style')
|
|
326
|
+
?.split('#')
|
|
327
|
+
.at(-1);
|
|
328
|
+
return compactLabel(
|
|
329
|
+
element.getAttribute('aria-label') ||
|
|
330
|
+
element.getAttribute('title') ||
|
|
331
|
+
descendantLabel ||
|
|
332
|
+
element.getAttribute('data-testid') ||
|
|
333
|
+
normalizeText(element.textContent) ||
|
|
334
|
+
sourceName ||
|
|
335
|
+
element.getAttribute('role') ||
|
|
336
|
+
element.tagName.toLowerCase(),
|
|
337
|
+
);
|
|
338
|
+
};
|
|
339
|
+
const stableId = (value) => {
|
|
340
|
+
let hash = 0x811c9dc5;
|
|
341
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
342
|
+
hash ^= value.charCodeAt(index);
|
|
343
|
+
hash = Math.imul(hash, 0x01000193);
|
|
344
|
+
}
|
|
345
|
+
return (hash >>> 0).toString(36);
|
|
346
|
+
};
|
|
347
|
+
const pending = [...found].flatMap(([element, states]) => {
|
|
348
|
+
const identity = selectorForElement(element);
|
|
349
|
+
if (!identity) return [];
|
|
350
|
+
return [
|
|
351
|
+
{
|
|
352
|
+
element,
|
|
353
|
+
identity,
|
|
354
|
+
baseLabel: targetLabel(element),
|
|
355
|
+
states: stateOrder.filter((state) => states.has(state)),
|
|
356
|
+
},
|
|
357
|
+
];
|
|
358
|
+
});
|
|
359
|
+
const totals = new Map();
|
|
360
|
+
for (const entry of pending) {
|
|
361
|
+
totals.set(entry.baseLabel, (totals.get(entry.baseLabel) ?? 0) + 1);
|
|
362
|
+
}
|
|
363
|
+
const seen = new Map();
|
|
364
|
+
const targets = pending.map(({ element, identity, baseLabel, states }) => {
|
|
365
|
+
const ordinal = (seen.get(baseLabel) ?? 0) + 1;
|
|
366
|
+
seen.set(baseLabel, ordinal);
|
|
367
|
+
const total = totals.get(baseLabel) ?? 1;
|
|
368
|
+
const label =
|
|
369
|
+
total > 1 ? `${baseLabel} · ${ordinal}/${total}` : baseLabel;
|
|
370
|
+
const key = `${identity.selector}\u0000${identity.index}`;
|
|
371
|
+
const target = {
|
|
372
|
+
id: `auto-pseudo:${stableId(key)}`,
|
|
373
|
+
label,
|
|
374
|
+
selector: identity.selector,
|
|
375
|
+
index: identity.index,
|
|
376
|
+
states,
|
|
377
|
+
};
|
|
378
|
+
element.setAttribute(metadataAttribute, JSON.stringify(target));
|
|
379
|
+
return target;
|
|
380
|
+
});
|
|
381
|
+
document.body?.setAttribute(ledgerAttribute, JSON.stringify(targets));
|
|
382
|
+
return targets;
|
|
383
|
+
}
|