affora 0.1.0

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.
@@ -0,0 +1,299 @@
1
+ // Generic rules — behavioural detection only, no site-specific class names.
2
+ // These fire on any page whose widgets follow common structure or ARIA, which is
3
+ // what makes them safe defaults for a compiled site whose class names are build
4
+ // output. Site files (magento.js, mui.js, …) layer on top and may override.
5
+ ({
6
+ // A choice control: native <select>, or a custom widget claiming the role.
7
+ select: {
8
+ // Native selects always; custom choice controls only when probing them is
9
+ // SAFE. Probing means clicking, and a documentation site's search opens a
10
+ // full-screen modal that unmounts the page — on mui.com that deleted all 85
11
+ // sidebar links from the DOM, and parity then reported 130 severed navigation
12
+ // paths for a site that was working perfectly. A probe must not be able to
13
+ // destroy the page it is reading.
14
+ find: () => {
15
+ const safe = (e) => {
16
+ // A portalled listbox BELONGS to a trigger; it is not a control of its own.
17
+ // Replacing it separately minted a second select from the popup and left a
18
+ // label pointing at the popup's id after the popup closed.
19
+ if (e.getAttribute('role') === 'listbox') {
20
+ const id = e.getAttribute('id');
21
+ if (id && document.querySelector(
22
+ '[aria-controls~="' + id + '"],[aria-owns~="' + id + '"]')) return false;
23
+ }
24
+ // A LINK is not a choice control, whatever ARIA it carries, and probing
25
+ // means clicking: a fixture link marked aria-haspopup=listbox took the
26
+ // probe straight off the page.
27
+ if (e.tagName === 'A' || e.hasAttribute('href')) return false;
28
+ if (e.getAttribute('aria-haspopup') === 'dialog') return false;
29
+ if (e.matches('input[type="search"]')) return false;
30
+ if (e.closest('[role="search"],form[role="search"]')) return false;
31
+ const hint = ((e.getAttribute('aria-label') || '') + ' ' +
32
+ (e.getAttribute('placeholder') || '') + ' ' +
33
+ (e.getAttribute('name') || '')).toLowerCase();
34
+ return !/search|find/.test(hint);
35
+ };
36
+ // aria-haspopup="listbox" is the ARIA signature React Aria and Base UI emit;
37
+ // neither puts role="combobox" on the trigger, so detecting it here is the
38
+ // difference between covering two more libraries and writing two more files.
39
+ return [
40
+ ...document.querySelectorAll('select'),
41
+ ...[...document.querySelectorAll(
42
+ '[role="combobox"],[role="listbox"],[aria-haspopup="listbox"]')].filter(safe),
43
+ ];
44
+ },
45
+ extract: (el) => {
46
+ let opts = [];
47
+ if (el.tagName === 'SELECT') {
48
+ // Carry the GROUP each option belongs to. Flattening an <optgroup> away
49
+ // deletes the label that says what the group is — 'Movies', 'Europe' —
50
+ // which is a fact the page states and, on a grouped select, often the only
51
+ // thing distinguishing two identically-named options.
52
+ opts = [...el.options].map(o => ({
53
+ value: o.value, label: rsRendered(o),
54
+ group: o.parentElement && o.parentElement.tagName === 'OPTGROUP'
55
+ ? (o.parentElement.getAttribute('label') || '') : '',
56
+ }));
57
+ } else {
58
+ const owned = el.getAttribute('aria-controls') || el.getAttribute('aria-owns');
59
+ // A combobox that names its listbox is the easy case. Many do not — Mantine
60
+ // ships none — so fall back to the listbox that BELONGS to this control:
61
+ // walk up a few levels and take the first one found in that subtree. Bounded
62
+ // deliberately, because searching the document would attach a stray
63
+ // listbox's options to whichever control happened to be probed first.
64
+ let box = owned ? document.getElementById(owned) : null;
65
+ if (!box) {
66
+ for (let n = el, i = 0; n && i < 4; n = n.parentElement, i++) {
67
+ const found = n.querySelector && n.querySelector('[role="listbox"]');
68
+ if (found) { box = found; break; }
69
+ }
70
+ }
71
+ if (!box) box = el;
72
+ // A listbox groups its options with [role=group], and the group's name is
73
+ // a fact the page states — 'Movies', 'Europe' — often the only thing
74
+ // telling two identically-named options apart. Read it from wherever ARIA
75
+ // puts it: an explicit label, a referenced element, or the group's own
76
+ // heading text (the child that is not an option).
77
+ const groupName = (o) => {
78
+ const g = o.closest('[role="group"],optgroup');
79
+ if (!g) return '';
80
+ const byRef = g.getAttribute('aria-labelledby');
81
+ return (g.getAttribute('label') || g.getAttribute('aria-label')
82
+ || (byRef ? rsRendered(document.getElementById(byRef)) : '')
83
+ || rsRendered([...g.children].find(c => !c.matches('[role="option"],option,li')))
84
+ || '').trim();
85
+ };
86
+ opts = [...(box ? box.querySelectorAll('[role="option"],li,option') : [])]
87
+ .map(o => ({ value: o.getAttribute('data-value') || rsRendered(o),
88
+ label: rsRendered(o), group: groupName(o) }))
89
+ .filter(o => o.label);
90
+ }
91
+ if (!opts.length) {
92
+ // PROBE. A popup's options do not exist until it opens, so a passive read
93
+ // can only ever skip — which is why every library that portals its options
94
+ // needed its own file. Open it with the page's own interaction, let the
95
+ // options mount, and read what the page itself rendered. Bounded at three
96
+ // attempts, after which the instance is skipped honestly; native selects
97
+ // never reach here, and a control that yields nothing is left alone.
98
+ if (el.tagName !== 'SELECT') {
99
+ const tries = +(el.getAttribute('data-rs-probe') || 0);
100
+ if (tries < 3) {
101
+ el.setAttribute('data-rs-probe', String(tries + 1));
102
+ el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
103
+ el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }));
104
+ el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
105
+ try { el.click(); } catch (e) {}
106
+ return 'retry';
107
+ }
108
+ }
109
+ return null; // options unreachable: skip
110
+ }
111
+ // If the page ALREADY labels this control, do not add a second one. The
112
+ // replacement keeps the original id, so the existing label[for] still
113
+ // resolves to it — rendering our own produced a visible "Sort By Sort By"
114
+ // and put a duplicate of every rewritten control's label into the page.
115
+ // Close whatever the probe opened: leaving a popup up would let the next
116
+ // control's probe harvest THIS one's options.
117
+ if (el.getAttribute('data-rs-probe'))
118
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
119
+ const id0 = el.getAttribute('id') || '';
120
+ const external = id0 && document.querySelector('label[for="' + CSS.escape(id0) + '"]');
121
+ // What the CLOSED control displays is a fact too — a placeholder ("Select
122
+ // movie") or the current choice. It is not one of the options, so listing
123
+ // only the options deleted it.
124
+ return { label: external ? '' : rsLabel(el), opts,
125
+ shown: el.tagName === 'SELECT' ? '' : rsRendered(el),
126
+ value: el.tagName === 'SELECT' ? el.value : '',
127
+ name: el.getAttribute('name') || '',
128
+ id: el.getAttribute('id') || '', // keep the id: label[for] must survive
129
+ orig: el.tagName === 'SELECT' ? el : null,
130
+ trigger: el.tagName === 'SELECT' ? null : el };
131
+ },
132
+ render: (d) => {
133
+ const id = d.id || ('rs-' + (++window.__rsSeq));
134
+ const one = (o) =>
135
+ `<option value="${rsEsc(o.value)}"${o.value === d.value ? ' selected' : ''}>${rsEsc(o.label)}</option>`;
136
+ // Rebuild the groups rather than listing their members loose.
137
+ let opts = '', open = null;
138
+ d.opts.forEach(o => {
139
+ const g = o.group || '';
140
+ if (g !== open) {
141
+ if (open) opts += '</optgroup>';
142
+ if (g) opts += `<optgroup label="${rsEsc(g)}">`;
143
+ open = g;
144
+ }
145
+ opts += one(o);
146
+ });
147
+ if (open) opts += '</optgroup>';
148
+ // Lead with whatever the control was showing when closed, unless that text
149
+ // is already one of the options.
150
+ if (d.shown && !d.opts.some(o => o.label === d.shown))
151
+ opts = `<option value="" selected>${rsEsc(d.shown)}</option>` + opts;
152
+ return `<div class="rs rs-field">
153
+ ${d.label ? `<label class="rs-label" for="${id}">${rsEsc(d.label)}</label>` : ''}
154
+ <select class="rs-select" id="${id}"${d.name ? ` name="${rsEsc(d.name)}"` : ''}>${opts}</select>
155
+ </div>`;
156
+ },
157
+ // SHADOW-DRIVE a native select. Rebuilding one produces an element with the
158
+ // same options and NO BEHAVIOUR: the host's change handler stayed with the
159
+ // original, so choosing an option did nothing. Parity could not see it — it
160
+ // compares which affordances exist, not what using them does — and the sort
161
+ // task failed on the rewritten arm while passing on the baseline.
162
+ // The original rides along hidden and receives the real event.
163
+ after: (d, holder) => {
164
+ // A CUSTOM trigger gets the same treatment as a native select, for the same
165
+ // reason: the application holds the state and a rebuilt control has no
166
+ // connection to it, so the replacement would display a choice nothing
167
+ // receives. The original rides along hidden and is driven the way a person
168
+ // drives it — opened, then the matching option clicked.
169
+ if (d.trigger) {
170
+ d.trigger.style.display = 'none';
171
+ d.trigger.setAttribute('data-rs-done', '');
172
+ holder.appendChild(d.trigger);
173
+ const shownSel = holder.querySelector('select.rs-select');
174
+ if (shownSel) shownSel.addEventListener('change', () => {
175
+ const want = shownSel.selectedOptions[0] && shownSel.selectedOptions[0].textContent.trim();
176
+ if (!want) return;
177
+ d.trigger.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
178
+ d.trigger.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }));
179
+ try { d.trigger.click(); } catch (e) {}
180
+ setTimeout(() => {
181
+ const opt = [...document.querySelectorAll('[role="option"]')]
182
+ .find(o => rsRendered(o) === want);
183
+ if (opt) opt.click();
184
+ else document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
185
+ }, 140);
186
+ });
187
+ return;
188
+ }
189
+ if (!d.orig) return;
190
+ d.orig.style.display = 'none';
191
+ d.orig.setAttribute('data-rs-done', '');
192
+ holder.appendChild(d.orig);
193
+ const shown = holder.querySelector('select.rs-select');
194
+ if (!shown) return;
195
+ shown.addEventListener('change', () => {
196
+ d.orig.value = shown.value;
197
+ d.orig.dispatchEvent(new Event('input', { bubbles: true }));
198
+ d.orig.dispatchEvent(new Event('change', { bubbles: true }));
199
+ });
200
+ },
201
+ },
202
+
203
+ // A tab strip: ARIA tablists, plus the commoner un-ARIA'd form — a cluster of
204
+ // sibling buttons whose handlers name sibling panels. Replaced by plain
205
+ // sections, every panel's content present; the panels MOVE (removes/after), so
206
+ // nothing is duplicated and nothing is lost.
207
+ tabs: {
208
+ // flattening: the tab/section controls exist to reveal content this
209
+ // output reveals structurally, so they are subsumed, not lost
210
+ subsumes: true,
211
+ find: () => {
212
+ // A NAVIGATION switcher is not a content tab strip. Magento's mobile nav is
213
+ // a two-tab switch between menu and account links; flattening it revealed a
214
+ // mobile-only region on a desktop page and, with it, five pre-existing
215
+ // colour-contrast failures that were previously not rendered. Revealing
216
+ // content is the point of this rule; revealing a different VIEWPORT's
217
+ // chrome is not.
218
+ const contentTabs = (el) => !el.closest('nav,[role="navigation"]')
219
+ && !/nav-sections|switcher|viewport/i.test(String(el.className || ''));
220
+ const out = new Set([...document.querySelectorAll('[role="tablist"]')].filter(contentTabs));
221
+ document.querySelectorAll('button,[role="button"],a').forEach(b => {
222
+ const h = (b.getAttribute('onclick') || '') + (b.className || '');
223
+ if (!/tab|panel|switch/i.test(h)) return;
224
+ const g = b.parentElement;
225
+ if (!g) return;
226
+ const peers = [...g.children].filter(c => /^(BUTTON|A)$/.test(c.tagName) &&
227
+ /tab|panel|switch/i.test((c.getAttribute('onclick') || '') + (c.className || '')));
228
+ if (peers.length >= 2 && contentTabs(g)) out.add(g);
229
+ });
230
+ return [...out];
231
+ },
232
+ extract: (el) => {
233
+ const tabs = [], controls = [];
234
+ [...el.querySelectorAll('[role="tab"],button,[role="button"],a')].forEach(t => {
235
+ const title = rsRendered(t);
236
+ if (!title) return;
237
+ let panel = null;
238
+ const owned = t.getAttribute('aria-controls');
239
+ if (owned) panel = document.getElementById(owned);
240
+ if (!panel) {
241
+ const m = (t.getAttribute('onclick') || '').match(/['"]([\w -]+)['"]/);
242
+ if (m) panel = document.getElementById('tab-' + m[1]) || document.getElementById(m[1]);
243
+ }
244
+ if (panel) { tabs.push({ title, html: panel.innerHTML, panel }); controls.push(t); }
245
+ });
246
+ return tabs.length >= 2 ? { tabs, controls } : null;
247
+ },
248
+ anchor: (el, d) => d.controls[0],
249
+ render: (d) => `<div class="rs rs-sections">` + d.tabs.map(t =>
250
+ `<section class="rs-section"><h3 class="rs-section-title">${rsEsc(t.title)}</h3>
251
+ <div class="rs-section-body">${t.html}</div></section>`).join('') + `</div>`,
252
+ removes: (d) => [...d.controls.slice(1), ...d.tabs.map(t => t.panel)],
253
+ after: (d) => {
254
+ d.controls.slice(1).forEach(c => c.remove());
255
+ d.tabs.forEach(t => t.panel && t.panel.remove());
256
+ },
257
+ },
258
+
259
+ // An icon-only control — the commonest unnamed target anywhere. Its name exists
260
+ // in title/alt/aria-label; the swap puts that name into the DOM as visible text.
261
+ // No name anywhere means naming it would invent a fact: skip.
262
+ icon_control: {
263
+ find: () => [...document.querySelectorAll('button,[role="button"],a')]
264
+ .filter(b => !(b.textContent || '').trim() && !b.closest('.rs')),
265
+ extract: (el) => {
266
+ const name = el.getAttribute('aria-label') || el.getAttribute('title')
267
+ || (el.querySelector('img') || {}).alt
268
+ || rsRendered(el.querySelector('svg title')) || '';
269
+ if (!name.trim()) return null;
270
+ return { name: name.trim(), href: el.getAttribute('href') || '',
271
+ tag: el.tagName === 'A' ? 'a' : 'button', inner: el.innerHTML, src: el };
272
+ },
273
+ render: (d) => d.tag === 'a'
274
+ ? `<a class="rs rs-iconbtn" href="${rsEsc(d.href)}">${d.inner}<span class="rs-iconbtn-name">${rsEsc(d.name)}</span></a>`
275
+ : `<button type="button" class="rs rs-iconbtn">${d.inner}<span class="rs-iconbtn-name">${rsEsc(d.name)}</span></button>`,
276
+ // SHADOW-DRIVE the original. A rebuilt button carries the name and none of
277
+ // the behaviour: where the page's handler is a framework listener rather
278
+ // than an href, the replacement is inert, and the arm that was supposed to
279
+ // gain a name silently loses a control. Found on a data grid whose next-page
280
+ // button stopped paging; effect parity could not see it, because it only
281
+ // exercises controls BOTH arms name and a replaced trigger is not one.
282
+ after: (d, holder) => {
283
+ const src = d.src;
284
+ if (!src) return;
285
+ src.style.display = 'none';
286
+ src.setAttribute('data-rs-shadow', '');
287
+ holder.appendChild(src);
288
+ const shown = holder.querySelector('.rs-iconbtn');
289
+ if (!shown || shown === src) return;
290
+ shown.addEventListener('click', (e) => {
291
+ if (d.tag === 'a' && d.href) return; // a link needs no forwarding
292
+ e.preventDefault();
293
+ src.style.display = '';
294
+ src.click();
295
+ src.style.display = 'none';
296
+ });
297
+ },
298
+ },
299
+ })
@@ -0,0 +1,94 @@
1
+ // A radio group whose inputs are hidden by CSS and whose labels are styled as
2
+ // buttons — the commonest way a designer turns a choice into a row of chips.
3
+ //
4
+ // The pattern is behavioural, not site-specific: an <input type="radio"> made
5
+ // imperceptible (zero size, opacity 0, clipped off-screen) beside a visible
6
+ // <label for>. A person sees and clicks the label; a reader that enumerates the
7
+ // action space sees neither, because the label is not a control and the input is
8
+ // not perceivable. The option names sit in the page text with nothing to act on.
9
+ //
10
+ // Written for the idiom rather than for any one site, and first measured on
11
+ // WebShop, whose own stylesheet does exactly this to every product option.
12
+ // It is NOT part of the rule set Section 6 ran; it is the extension arm.
13
+ ({
14
+ hidden_radio_group: {
15
+ find: () => {
16
+ const imperceptible = (el) => {
17
+ const s = getComputedStyle(el);
18
+ if (s.display === 'none' || s.visibility === 'hidden') return true;
19
+ if (+s.opacity === 0) return true;
20
+ const r = el.getBoundingClientRect();
21
+ return r.width < 2 || r.height < 2;
22
+ };
23
+ const visible = (el) => {
24
+ if (!el) return false;
25
+ const s = getComputedStyle(el);
26
+ if (s.display === 'none' || s.visibility === 'hidden' || +s.opacity === 0) return false;
27
+ const r = el.getBoundingClientRect();
28
+ return r.width > 2 && r.height > 2;
29
+ };
30
+ const heads = new Map();
31
+ for (const r of document.querySelectorAll('input[type="radio"][name]')) {
32
+ if (r.closest('.rs')) continue;
33
+ if (!imperceptible(r)) continue; // a visible radio is fine as it is
34
+ const lab = r.id && document.querySelector('label[for="' + CSS.escape(r.id) + '"]');
35
+ if (!visible(lab)) continue; // hidden group, hidden label: not this pattern
36
+ if (!heads.has(r.name)) heads.set(r.name, lab);
37
+ }
38
+ return [...heads.values()];
39
+ },
40
+ extract: (el) => {
41
+ const first = document.getElementById(el.getAttribute('for'));
42
+ if (!first) return null;
43
+ const opts = [];
44
+ for (const r of document.querySelectorAll('input[type="radio"][name="' + CSS.escape(first.name) + '"]')) {
45
+ const lab = r.id && document.querySelector('label[for="' + CSS.escape(r.id) + '"]');
46
+ const text = lab ? rsRendered(lab) : (r.value || '');
47
+ if (text) opts.push({ value: text, label: text, radio: r, el: lab });
48
+ }
49
+ if (opts.length < 2) return null;
50
+ const chosen = opts.find((o) => o.radio.checked);
51
+ // The group's name is what the page already calls it: the radios' name
52
+ // attribute is the page's own word, and it is the only one there is. If a
53
+ // heading beside the group already says it, the replacement does not say
54
+ // it twice — a person would read the same word stacked on itself.
55
+ let head = el.parentElement;
56
+ let titled = false;
57
+ for (let i = 0; i < 3 && head; i++, head = head.parentElement) {
58
+ const h = head.querySelector('h1,h2,h3,h4,h5,legend,label:not([for])');
59
+ if (h && rsRendered(h).trim().toLowerCase() === String(first.name).toLowerCase()) {
60
+ titled = true;
61
+ break;
62
+ }
63
+ }
64
+ return { name: first.name, opts, current: chosen ? chosen.label : '', titled };
65
+ },
66
+ render: (d) => {
67
+ const id = 'hr-' + (++window.__rsSeq);
68
+ return `<div class="rs rs-field">
69
+ ${d.titled ? '' : `<label class="rs-label" for="${id}">${rsEsc(d.name)}</label>`}
70
+ <select class="rs-select" id="${id}">
71
+ ${d.current ? '' : `<option value="" selected>Choose ${rsEsc(d.name)}</option>`}
72
+ ${d.opts.map((o) => `<option value="${rsEsc(o.value)}"${o.label === d.current ? ' selected' : ''}>${rsEsc(o.label)}</option>`).join('')}
73
+ </select>
74
+ </div>`;
75
+ },
76
+ // The labels are the widget as a person sees it; they are consumed by the
77
+ // swap so the page does not show the same choice twice.
78
+ removes: (d) => d.opts.map((o) => o.el).filter(Boolean),
79
+ after: (d, holder) => {
80
+ const shown = holder.querySelector('select.rs-select');
81
+ if (!shown) return;
82
+ for (const o of d.opts) if (o.el && o.el.isConnected) o.el.remove();
83
+ // Drive the page's own control rather than replacing its behaviour: the
84
+ // hidden radio still carries the handler the application installed.
85
+ shown.addEventListener('change', () => {
86
+ const want = shown.value;
87
+ const hit = d.opts.find((o) => o.value === want);
88
+ if (!hit) return;
89
+ hit.radio.style.display = '';
90
+ hit.radio.click();
91
+ });
92
+ },
93
+ },
94
+ })