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,325 @@
1
+ // Magento (Luma) rules — tuned to a COMPILED storefront, i.e. the WebArena
2
+ // shopping app. Class names here are stable theme output, not app source, which
3
+ // is why they live in a site file: generic.js carries what needs no such
4
+ // knowledge, and this layers the store-specific vocabulary on top.
5
+ ({
6
+ // A product tile in a listing. The theme buries the price in nested spans, the
7
+ // rating in a CSS-width bar, and the action behind an icon — facts the page HAS
8
+ // but does not state. The tile states them.
9
+ product_item: {
10
+ find: () => [...document.querySelectorAll('li.item.product, .product-item')],
11
+ extract: (el) => {
12
+ const nameEl = el.querySelector('.product-item-link, .product-item-name a, a.product-item-link');
13
+ const name = nameEl ? rsRendered(nameEl) : '';
14
+ const href = nameEl ? nameEl.getAttribute('href') : '';
15
+ if (!name || !href) return null;
16
+ // The RENDERED price text is what the page asserts and what an evaluator
17
+ // string-matches, so it is the displayed value verbatim. data-price-amount
18
+ // is the machine form and belongs in <data value> only — reformatting it
19
+ // once dropped the cents digit ($12.80 -> $12.8).
20
+ const pw = el.querySelector('[data-price-amount]');
21
+ const priceText = rsRendered(el.querySelector('.price'));
22
+ const priceValue = pw ? pw.getAttribute('data-price-amount')
23
+ : priceText.replace(/[^\d.]/g, '');
24
+ // Rating: encoded as a percentage WIDTH on an inner span — a fact expressed
25
+ // only as geometry. Read it and say it.
26
+ // Take whichever node CARRIES the width. querySelector returns document
27
+ // order, so a selector list matching both the wrapper and the inner span
28
+ // hands back the wrapper — which has no width — and the rating is silently
29
+ // dropped from every tile: the geometry-to-text conversion this rule exists
30
+ // for, quietly not happening.
31
+ const bar = [...el.querySelectorAll('.rating-result, .rating-result > span')]
32
+ .find(n => /width:\s*[\d.]+%/.test(n.getAttribute('style') || ''))
33
+ || el.querySelector('.rating-result');
34
+ let rating = '', ratingRaw = '';
35
+ if (bar) {
36
+ const m = (bar.getAttribute('style') || '').match(/width:\s*([\d.]+)%/);
37
+ if (m) {
38
+ rating = (Math.round(parseFloat(m[1]) / 20 * 10) / 10) + ' of 5';
39
+ // Keep the page's OWN wording alongside the legible form. This theme
40
+ // prints "Rating: 73%", and replacing it with "3.7 of 5" deleted the
41
+ // assertion the page actually made — which a benchmark evaluator may
42
+ // string-match. Converting a unit is not licence to drop the original.
43
+ ratingRaw = m[1] + '%';
44
+ } else { const t = rsRendered(bar); if (/\d/.test(t)) rating = t; }
45
+ }
46
+ const reviews = rsRendered(el.querySelector('.reviews-actions a, .action.view'));
47
+ const addBtn = el.querySelector('button.tocart, .action.tocart');
48
+ return { name, href, priceText, priceValue, rating, ratingRaw, reviews,
49
+ addLabel: addBtn ? (rsRendered(addBtn) || 'Add to Cart') : '' };
50
+ },
51
+ render: (d) => `<article class="rs rs-tile">
52
+ <h3 class="rs-tile-name"><a href="${rsEsc(d.href)}">${rsEsc(d.name)}</a></h3>
53
+ ${d.priceText ? `<p class="rs-tile-price"><data value="${rsEsc(d.priceValue)}">${rsEsc(d.priceText)}</data></p>` : ''}
54
+ ${d.rating ? `<p class="rs-tile-rating">Rating: ${rsEsc(d.rating)}${d.ratingRaw ? ` (${rsEsc(d.ratingRaw)})` : ''}${d.reviews ? ' · ' + rsEsc(d.reviews) : ''}</p>` : ''}
55
+ ${d.addLabel ? `<p class="rs-tile-action">${rsEsc(d.addLabel)}</p>` : ''}
56
+ </article>`,
57
+ },
58
+
59
+ // Pagination: the current page is a fact the theme encodes only as a class.
60
+ // State the position in prose; keep every item's own wording VERBATIM —
61
+ // "You're currently reading page 1" is the page's screen-reader prose and
62
+ // therefore content; rewriting it to "Page 1" was itself a measured loss.
63
+ pagination: {
64
+ find: () => [...document.querySelectorAll('.pages')],
65
+ extract: (el) => {
66
+ const items = [...el.querySelectorAll('li')].map(li => {
67
+ const raw = rsRendered(li);
68
+ const m = raw.match(/(\d+)\s*$/);
69
+ return { label: raw, num: m ? m[1] : '',
70
+ href: (li.querySelector('a') || {}).href || '',
71
+ current: li.classList.contains('current') };
72
+ }).filter(i => i.label);
73
+ if (!items.length) return null;
74
+ const cur = items.find(i => i.current);
75
+ const nums = items.map(i => parseInt(i.num, 10)).filter(n => !isNaN(n));
76
+ return { items, current: cur ? cur.num : '',
77
+ last: nums.length ? Math.max(...nums) : '' };
78
+ },
79
+ render: (d) => `<nav class="rs rs-pages" aria-label="Pagination">
80
+ ${d.current ? `<p class="rs-pages-state">Page ${rsEsc(d.current)}${d.last ? ' of ' + rsEsc(String(d.last)) : ''}</p>` : ''}
81
+ <ul class="rs-pages-list">${d.items.map(i =>
82
+ `<li>${i.href ? `<a href="${rsEsc(i.href)}"${i.current ? ' aria-current="page"' : ''}>${rsEsc(i.label)}</a>`
83
+ : `<span${i.current ? ' aria-current="page"' : ''}>${rsEsc(i.label)}</span>`}</li>`).join('')}</ul>
84
+ </nav>`,
85
+ },
86
+
87
+ // A quantity field. RE-HOMED under a real label rather than rebuilt: the
88
+ // rebuild dropped the original's own title, which parity caught as a lost
89
+ // affordance, and Magento binds validation and the add-to-cart submit to the
90
+ // original node — a replica with the same name would look right and fail
91
+ // validation silently. Stating the constraints the theme leaves to its
92
+ // validation config is the actual repair; the control itself was never the
93
+ // problem.
94
+ qty: {
95
+ find: () => [...document.querySelectorAll('input[name*="qty" i]')],
96
+ extract: (el) => ({
97
+ label: rsLabel(el) || el.getAttribute('title') || 'Quantity',
98
+ min: el.getAttribute('min') || '',
99
+ max: el.getAttribute('max') || '',
100
+ orig: el,
101
+ }),
102
+ render: (d) => {
103
+ const id = 'rs-' + (++window.__rsSeq);
104
+ const limits = [d.min && `at least ${rsEsc(d.min)}`, d.max && `at most ${rsEsc(d.max)}`]
105
+ .filter(Boolean).join(', ');
106
+ return `<div class="rs rs-field" data-rs-qty>
107
+ <label class="rs-label" for="${id}">${rsEsc(d.label)}${limits ? ` (${limits})` : ''}</label>
108
+ </div>`;
109
+ },
110
+ after: (d, holder) => {
111
+ const field = holder.querySelector('.rs-field');
112
+ const label = holder.querySelector('label');
113
+ if (!field || !label) return;
114
+ if (!d.orig.id) d.orig.id = label.getAttribute('for');
115
+ else label.setAttribute('for', d.orig.id);
116
+ d.orig.setAttribute('data-rs-done', '');
117
+ d.orig.classList.add('rs-input');
118
+ rsUnhide(d.orig);
119
+ field.appendChild(d.orig);
120
+ },
121
+ },
122
+
123
+ // The sort-direction toggle. Its accessible name is the ACTION it performs —
124
+ // "Set Descending Direction" — and nothing states the direction currently in
125
+ // effect, so an agent asked to put cheap items first cannot tell whether the
126
+ // listing is already ascending, and clicking the plausibly-named control
127
+ // reverses what it wanted. Measured across the 3x3 matrix, this single control
128
+ // caused nearly every L2 failure, on BOTH arms and both models (#43).
129
+ //
130
+ // The current direction is not a guess: the theme writes it into the class
131
+ // (`sort-asc` / `sort-desc`). Stating it adds no fact the page does not hold.
132
+ //
133
+ // The original's href is '#' — the behaviour is JavaScript — so the control is
134
+ // shadow-driven rather than rebuilt, and its exact wording is preserved so the
135
+ // page still says everything it said.
136
+ sort_direction: {
137
+ find: () => [...document.querySelectorAll('a.sorter-action, a[title*="Direction" i]')],
138
+ extract: (el) => {
139
+ const cls = String(el.className);
140
+ const current = /sort-asc/.test(cls) ? 'ascending'
141
+ : /sort-desc/.test(cls) ? 'descending' : '';
142
+ if (!current) return null; // direction unknowable: skip, never guess
143
+ return { current, label: rsRendered(el) || el.getAttribute('title') || '', orig: el };
144
+ },
145
+ render: (d) => `<div class="rs rs-field" data-rs-sortdir>
146
+ <p class="rs-label">Sort direction: ${rsEsc(d.current)}</p>
147
+ <button type="button" class="rs-iconbtn">${rsEsc(d.label)}</button>
148
+ </div>`,
149
+ after: (d, holder) => {
150
+ d.orig.style.display = 'none';
151
+ d.orig.setAttribute('data-rs-done', '');
152
+ holder.appendChild(d.orig);
153
+ const btn = holder.querySelector('button');
154
+ if (btn) btn.addEventListener('click', () => d.orig.click());
155
+ },
156
+ },
157
+
158
+ // A cart line. The row spreads one item's facts across table cells whose meaning
159
+ // comes from a header far above, and the quantity field's name is an opaque
160
+ // `cart[854][qty]` — so an agent asked to change the quantity of a NAMED product
161
+ // has to work out which numbered field belongs to it. The line states its own
162
+ // facts and labels its own field.
163
+ //
164
+ // The quantity input is SHADOW-DRIVEN rather than rebuilt: Magento's cart wires
165
+ // validation and the Update button to the original, and a rebuilt field with the
166
+ // same name would look right and update nothing.
167
+ cart_line: {
168
+ find: () => [...document.querySelectorAll('tbody.cart.item')],
169
+ extract: (el) => {
170
+ const name = rsRendered(el.querySelector('.product-item-name'));
171
+ if (!name) return null;
172
+ const link = el.querySelector('.product-item-name a');
173
+ const qty = el.querySelector('input[name*="qty" i]');
174
+ return {
175
+ name,
176
+ href: link ? link.getAttribute('href') : '',
177
+ price: rsRendered(el.querySelector('.col.price .price, .price')),
178
+ subtotal: rsRendered(el.querySelector('.col.subtotal .price, .subtotal')),
179
+ options: rsRendered(el.querySelector('.item-options')),
180
+ qty,
181
+ qtyValue: qty ? (qty.value || qty.getAttribute('value') || '') : '',
182
+ };
183
+ },
184
+ render: (d) => {
185
+ const id = 'rs-' + (++window.__rsSeq);
186
+ return `<tbody class="rs"><tr><td colspan="5">
187
+ <article class="rs rs-tile">
188
+ <h3 class="rs-tile-name">${d.href ? `<a href="${rsEsc(d.href)}">${rsEsc(d.name)}</a>` : rsEsc(d.name)}</h3>
189
+ ${d.options ? `<p class="rs-tile-rating">${rsEsc(d.options)}</p>` : ''}
190
+ ${d.price ? `<p class="rs-tile-price">Price: ${rsEsc(d.price)}</p>` : ''}
191
+ ${d.subtotal ? `<p class="rs-tile-price">Subtotal: ${rsEsc(d.subtotal)}</p>` : ''}
192
+ ${d.qty ? `<div class="rs-field"><label class="rs-label" for="${id}">Quantity of ${rsEsc(d.name)}</label></div>` : ''}
193
+ </article></td></tr></tbody>`;
194
+ },
195
+ after: (d, holder) => {
196
+ if (!d.qty) return;
197
+ // re-home the real field under its new label, keeping its name and handlers
198
+ const field = holder.querySelector('.rs-field');
199
+ const label = holder.querySelector('label');
200
+ if (!field || !label) return;
201
+ if (!d.qty.id) d.qty.id = label.getAttribute('for');
202
+ else label.setAttribute('for', d.qty.id);
203
+ d.qty.setAttribute('data-rs-done', '');
204
+ d.qty.classList.add('rs-input');
205
+ field.appendChild(d.qty);
206
+ },
207
+ },
208
+
209
+ // The order summary. Every figure is a table row whose label and value sit in
210
+ // separate cells, so the association is positional; stated as pairs it survives
211
+ // any reading order.
212
+ cart_totals: {
213
+ find: () => [...document.querySelectorAll('.cart-totals table, table.totals')],
214
+ extract: (el) => {
215
+ const rows = [...el.querySelectorAll('tr')].map(tr => ({
216
+ label: rsRendered(tr.querySelector('th')),
217
+ value: rsRendered(tr.querySelector('td')),
218
+ })).filter(r => r.label && r.value);
219
+ return rows.length ? { rows } : null;
220
+ },
221
+ render: (d) => `<div class="rs rs-sections"><section class="rs-section">
222
+ <h3 class="rs-section-title">Order summary</h3>
223
+ <ul class="rs-pages-list" style="flex-direction:column;align-items:flex-start">
224
+ ${d.rows.map(r => `<li>${rsEsc(r.label)}: ${rsEsc(r.value)}</li>`).join('')}
225
+ </ul></section></div>`,
226
+ },
227
+
228
+ // Product custom options — the reason a configurable product cannot be added
229
+ // from a listing at all, and nothing on either page says so. Magento marks the
230
+ // group required with a CSS CLASS (.field.required) while the inputs themselves
231
+ // report required=false, and names them opaquely (options[2255]) with the human
232
+ // label in a separate element. So the page holds three facts it never states in
233
+ // machine-readable form: what this choice is called, that it must be made, and
234
+ // which values are available.
235
+ //
236
+ // The radios are RE-HOMED rather than rebuilt: Magento's validation binds to the
237
+ // originals, and a rebuilt input with the same name would look right and fail
238
+ // validation silently.
239
+ product_options: {
240
+ find: () => [...document.querySelectorAll('.product-options-wrapper')],
241
+ extract: (el) => {
242
+ const groups = new Map();
243
+ el.querySelectorAll('input[name^="options["], select[name^="options["]').forEach(inp => {
244
+ const name = inp.getAttribute('name');
245
+ if (!groups.has(name)) groups.set(name, []);
246
+ groups.get(name).push(inp);
247
+ });
248
+ if (!groups.size) return null;
249
+ const out = [];
250
+ for (const [name, inputs] of groups) {
251
+ // The label and the requirement live on an ancestor block, not on the
252
+ // control, so both are read from where the theme actually puts them.
253
+ const block = inputs[0].closest('.product-options-wrapper > .field, .field');
254
+ let labelEl = null, req = false;
255
+ for (let n = inputs[0]; n && n !== el; n = n.parentElement) {
256
+ if (!labelEl) labelEl = n.querySelector(':scope > label, :scope > span.label');
257
+ if (n.classList && n.classList.contains('required')) req = true;
258
+ }
259
+ // fall back to the wrapper's own labelled block
260
+ const groupLabel = rsRendered(labelEl) ||
261
+ rsRendered(el.querySelector('.field.required > label, .field.required > .label')) || '';
262
+ const choices = inputs.map(i => ({
263
+ input: i,
264
+ label: rsRendered(i.closest('.field') || i.parentElement) ||
265
+ i.getAttribute('aria-label') || i.value || '',
266
+ })).filter(c => c.label);
267
+ out.push({ name, req, label: groupLabel, choices, block });
268
+ }
269
+ return out.length ? { groups: out } : null;
270
+ },
271
+ render: (d) => `<div class="rs rs-sections" data-rs-options>` + d.groups.map((g, gi) =>
272
+ `<fieldset class="rs-section" data-rs-group="${gi}">
273
+ <legend class="rs-section-title">${rsEsc(g.label || 'Option')}${g.req ? ' (required)' : ''}</legend>
274
+ <p class="rs-label">Choose one of: ${rsEsc(g.choices.map(c => c.label).join(', '))}</p>
275
+ <div class="rs-pages-list" data-rs-choices="${gi}"></div>
276
+ </fieldset>`).join('') + `</div>`,
277
+ after: (d, holder) => {
278
+ d.groups.forEach((g, gi) => {
279
+ const slot = holder.querySelector(`[data-rs-choices="${gi}"]`);
280
+ if (!slot) return;
281
+ g.choices.forEach((c, ci) => {
282
+ const id = `rs-opt-${gi}-${ci}`;
283
+ const wrap = document.createElement('label');
284
+ wrap.className = 'rs-iconbtn';
285
+ wrap.setAttribute('for', c.input.id || id);
286
+ if (!c.input.id) c.input.id = id;
287
+ c.input.setAttribute('data-rs-done', '');
288
+ rsUnhide(c.input);
289
+ wrap.appendChild(c.input); // the ORIGINAL control
290
+ const text = document.createElement('span');
291
+ text.className = 'rs-iconbtn-name';
292
+ text.textContent = c.label;
293
+ wrap.appendChild(text);
294
+ slot.appendChild(wrap);
295
+ });
296
+ });
297
+ },
298
+ },
299
+
300
+ // The product information tabs. Magento hides the description and the reviews
301
+ // behind a tab strip whose panels are in the DOM but collapsed, so a question
302
+ // about either is answerable only after an interaction the page never advertises.
303
+ // Flattened into sections, with each panel's content moved rather than copied.
304
+ product_info_tabs: {
305
+ // flattening: the tab/section controls exist to reveal content this
306
+ // output reveals structurally, so they are subsumed, not lost
307
+ subsumes: true,
308
+ find: () => [...document.querySelectorAll('.product.data.items')],
309
+ extract: (el) => {
310
+ const secs = [...el.querySelectorAll('.data.item.title')].map(t => {
311
+ const link = t.querySelector('a');
312
+ const id = link && (link.getAttribute('href') || '').replace(/^#/, '');
313
+ const panel = (id && document.getElementById(id)) || t.nextElementSibling;
314
+ return panel && rsRendered(panel)
315
+ ? { title: rsRendered(t), html: panel.innerHTML, panel } : null;
316
+ }).filter(Boolean);
317
+ return secs.length >= 1 ? { secs } : null;
318
+ },
319
+ render: (d) => `<div class="rs rs-sections">` + d.secs.map(t =>
320
+ `<section class="rs-section"><h3 class="rs-section-title">${rsEsc(t.title)}</h3>
321
+ <div class="rs-section-body">${t.html}</div></section>`).join('') + `</div>`,
322
+ removes: (d) => d.secs.map(t => t.panel),
323
+ after: (d) => d.secs.forEach(t => t.panel && t.panel.isConnected && t.panel.remove()),
324
+ },
325
+ })
package/rules/mui.js ADDED
@@ -0,0 +1,230 @@
1
+ // Material UI rules — developed and verified against NATIVE MUI projects
2
+ // (mui.com component demos; an official template as the app-scale case), never
3
+ // against our own benchmark re-creations, which would make validation circular.
4
+ //
5
+ // Empirical idioms (probed on mui.com):
6
+ // Select <div role="combobox" aria-haspopup="listbox"> — a DIV, unlike both
7
+ // shadcn eras — beside a hidden .MuiSelect-nativeInput that carries
8
+ // the machine value; options portal-mount on open.
9
+ // Tabs [role=tab] buttons; inactive panels exist but their content is
10
+ // unmounted (hidden + empty) -> probe-click to harvest.
11
+ // Menu trigger [aria-haspopup="menu"]; items portal-mount.
12
+ // Accordion content STAYS mounted (collapsed by height), so extraction is
13
+ // passive — the one family where MUI is kinder than Radix.
14
+ // Switch wraps a REAL <input type=checkbox> already — nothing to replace;
15
+ // deliberately no rule.
16
+ ({
17
+ mui_select: {
18
+ // Library rules must fire on their own library. Ant Design's page-size
19
+ // select carries the same ARIA as MUI's, and this rule matched it, probed
20
+ // it, and then removed a listbox it had found by a page-wide fallback,
21
+ // taking Ant Design's own inner input with it: an affordance present in the
22
+ // shipped page and absent after the rewrite.
23
+ find: () => [...document.querySelectorAll('[role="combobox"][aria-haspopup="listbox"]')]
24
+ .filter(e => e.tagName !== 'SELECT')
25
+ .filter(e => e.closest('.MuiInputBase-root,.MuiSelect-root,.MuiAutocomplete-root')
26
+ || /\bMui[A-Z]/.test(String(e.className || ''))),
27
+ extract: (el) => {
28
+ const owned = el.getAttribute('aria-controls');
29
+ let box = owned && document.getElementById(owned);
30
+ // The fallback is scoped to MUI's own popover: a page-wide query for any
31
+ // listbox reaches whatever else happens to be open.
32
+ if (!box) box = document.querySelector('.MuiPopover-root [role="listbox"], .MuiMenu-root [role="listbox"]');
33
+ const opts = box ? [...box.querySelectorAll('[role="option"],li')]
34
+ .map(o => ({ value: o.getAttribute('data-value') || rsRendered(o),
35
+ label: rsRendered(o) })).filter(o => o.label) : [];
36
+ if (!opts.length) {
37
+ const tries = +(el.getAttribute('data-rs-probe') || 0);
38
+ if (tries >= 3) return null;
39
+ el.setAttribute('data-rs-probe', String(tries + 1));
40
+ el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
41
+ el.click();
42
+ return 'retry';
43
+ }
44
+ // the hidden native input is MUI's own machine-value carrier
45
+ const hidden = el.parentElement &&
46
+ el.parentElement.querySelector('.MuiSelect-nativeInput,input[aria-hidden="true"]');
47
+ const current = (hidden && hidden.value) || rsRendered(el);
48
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
49
+ return { label: rsLabel(el), opts, current, box,
50
+ name: (hidden && hidden.getAttribute('name')) || '',
51
+ id: el.getAttribute('id') || '' };
52
+ },
53
+ render: (d) => {
54
+ const id = d.id || ('rs-' + (++window.__rsSeq));
55
+ const sel = d.opts.find(o => o.value === d.current || o.label === d.current);
56
+ return `<div class="rs rs-field">
57
+ ${d.label ? `<label class="rs-label" for="${id}">${rsEsc(d.label)}</label>` : ''}
58
+ <select class="rs-select" id="${id}"${d.name ? ` name="${rsEsc(d.name)}"` : ''}>
59
+ ${sel ? '' : `<option value="" selected>${rsEsc(d.current || 'Select…')}</option>`}
60
+ ${d.opts.map(o => `<option value="${rsEsc(o.value)}"${sel && o.value === sel.value ? ' selected' : ''}>${rsEsc(o.label)}</option>`).join('')}
61
+ </select>
62
+ </div>`;
63
+ },
64
+ removes: (d) => d.box ? [d.box] : [],
65
+ after: (d) => {
66
+ // Remove ONLY the listbox we read. Reaching for a positioning wrapper —
67
+ // [role="presentation"] especially — takes whatever else it contains: on
68
+ // mui.com that was the documentation sidebar, and every /material-ui/...
69
+ // destination vanished while faithfulness stayed green.
70
+ if (d.box && d.box.isConnected) d.box.remove();
71
+ },
72
+ },
73
+
74
+ mui_menu: {
75
+ find: () => [...document.querySelectorAll('button[aria-haspopup="menu"],[aria-haspopup="true"]')]
76
+ .filter(e => e.tagName === 'BUTTON')
77
+ // Never probe something that NAVIGATES or opens a dialog. Probing clicks,
78
+ // and on a documentation site the nav and the search both look like menu
79
+ // triggers: clicking them replaced the page and parity reported 130 severed
80
+ // navigation paths for a site that worked perfectly.
81
+ .filter(e => e.tagName !== 'A' && !e.getAttribute('href')
82
+ && e.getAttribute('aria-haspopup') !== 'dialog'
83
+ && !e.closest('[role="search"],form[role="search"],nav')),
84
+ extract: (el) => {
85
+ const owned = el.getAttribute('aria-controls');
86
+ let box = owned && document.getElementById(owned);
87
+ if (!box) box = document.querySelector('[role="menu"]');
88
+ // Walk in DOM order and keep GROUP HEADERS, not only the items: MUI menus
89
+ // use ListSubheader ("Category 1") to group options, and emitting items
90
+ // alone silently drops the grouping — a real fact the faithfulness check
91
+ // caught on mui.com's own menu demo.
92
+ const items = box ? [...box.querySelectorAll(
93
+ '[role^="menuitem"],.MuiMenuItem-root,.MuiListSubheader-root,li')]
94
+ .map(o => ({
95
+ label: rsRendered(o),
96
+ href: o.getAttribute('href') || '',
97
+ heading: o.classList.contains('MuiListSubheader-root')
98
+ || (!o.getAttribute('role') && !o.classList.contains('MuiMenuItem-root')),
99
+ }))
100
+ .filter(o => o.label) : [];
101
+ if (!items.length) {
102
+ const tries = +(el.getAttribute('data-rs-probe') || 0);
103
+ if (tries >= 3) return null;
104
+ el.setAttribute('data-rs-probe', String(tries + 1));
105
+ el.click();
106
+ return 'retry';
107
+ }
108
+ const label = rsRendered(el) || rsLabel(el);
109
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
110
+ return { label, items, box, orig: el };
111
+ },
112
+ render: (d) => `<div class="rs rs-field" data-rs-menu>
113
+ <span class="rs-label">${rsEsc(d.label)}</span>
114
+ <ul class="rs-pages-list">${d.items.map((i, n) =>
115
+ i.heading ? `<li class="rs-label" role="presentation">${rsEsc(i.label)}</li>`
116
+ : i.href ? `<li><a href="${rsEsc(i.href)}">${rsEsc(i.label)}</a></li>`
117
+ : `<li><button type="button" class="rs-iconbtn" data-rs-item="${n}">${rsEsc(i.label)}</button></li>`).join('')}</ul>
118
+ </div>`,
119
+ removes: (d) => d.box ? [d.box] : [],
120
+ after: (d, holder) => {
121
+ // only the menu we harvested; a positioning wrapper can hold the page's nav
122
+ if (d.box && d.box.isConnected) d.box.remove();
123
+ d.orig.style.display = 'none';
124
+ d.orig.removeAttribute('data-rs-done');
125
+ // its portal is gone; leaving the pointer behind dangles the reference
126
+ d.orig.removeAttribute('aria-controls');
127
+ d.orig.removeAttribute('aria-owns');
128
+ holder.appendChild(d.orig);
129
+ holder.querySelectorAll('[data-rs-item]').forEach(btn => {
130
+ btn.addEventListener('click', () => {
131
+ const want = btn.textContent.trim();
132
+ d.orig.click();
133
+ setTimeout(() => {
134
+ const it = [...document.querySelectorAll('[role^="menuitem"],.MuiMenuItem-root')]
135
+ .find(m => rsRendered(m) === want);
136
+ if (it) it.click();
137
+ else document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
138
+ }, 120);
139
+ });
140
+ });
141
+ },
142
+ },
143
+
144
+ mui_tabs: {
145
+ // flattening: the tab/section controls exist to reveal content this
146
+ // output reveals structurally, so they are subsumed, not lost
147
+ subsumes: true,
148
+ find: () => {
149
+ // A NAVIGATION switcher is not a content tab strip. Atomic CRM renders its
150
+ // top navigation as MUI Tabs whose tabs are links; probing one navigated
151
+ // the application to its root and the flattening then replaced the page,
152
+ // costing the rewritten arm 25 of 36 tasks it could otherwise do. The
153
+ // generic tabs rule has carried this guard since Magento's mobile nav.
154
+ const contentTabs = (el) => !el.closest('nav,[role="navigation"]')
155
+ && ![...el.querySelectorAll('[role="tab"]')].some(t => t.tagName === 'A' && t.getAttribute('href'));
156
+ return ([...document.querySelectorAll('[role="tablist"]')]).filter(contentTabs)
157
+ },
158
+ extract: (el) => {
159
+ const trigs = [...el.querySelectorAll('[role="tab"]')];
160
+ if (trigs.length < 2) return null;
161
+ // panels: aria-controls when wired, else index-match the page's tabpanels
162
+ // that belong to this tablist's container
163
+ const scope = el.closest('.MuiBox-root,section,div') || document;
164
+ const panels = [...scope.parentElement
165
+ ? scope.parentElement.querySelectorAll('[role="tabpanel"]')
166
+ : document.querySelectorAll('[role="tabpanel"]')];
167
+ const panelOf = (t, i) => {
168
+ const id = t.getAttribute('aria-controls');
169
+ return (id && document.getElementById(id)) || panels[i] || null;
170
+ };
171
+ const st = el.__rsCap || (el.__rsCap = { caps: {} });
172
+ let missing = null, missIdx = -1;
173
+ trigs.forEach((t, i) => {
174
+ const key = rsRendered(t);
175
+ const panel = panelOf(t, i);
176
+ if (panel && rsRendered(panel)) st.caps[key] = { title: key, html: panel.innerHTML, panel };
177
+ else if (!(key in st.caps) && !missing) { missing = t; missIdx = i; }
178
+ });
179
+ if (missing) {
180
+ const tries = +(el.getAttribute('data-rs-probe') || 0);
181
+ if (tries < trigs.length * 2 + 2) {
182
+ el.setAttribute('data-rs-probe', String(tries + 1));
183
+ missing.click();
184
+ return 'retry';
185
+ }
186
+ }
187
+ const tabs = trigs.map(t => st.caps[rsRendered(t)]).filter(Boolean);
188
+ return tabs.length >= 2 ? { tabs } : null;
189
+ },
190
+ render: (d) => `<div class="rs rs-sections">` + d.tabs.map(t =>
191
+ `<section class="rs-section"><h3 class="rs-section-title">${rsEsc(t.title)}</h3>
192
+ <div class="rs-section-body">${t.html}</div></section>`).join('') + `</div>`,
193
+ removes: (d) => d.tabs.map(t => t.panel),
194
+ after: (d) => d.tabs.forEach(t => t.panel && t.panel.isConnected && t.panel.remove()),
195
+ },
196
+
197
+ mui_accordion: {
198
+ // flattening: the tab/section controls exist to reveal content this
199
+ // output reveals structurally, so they are subsumed, not lost
200
+ subsumes: true,
201
+ // content stays mounted, so this is passive: flatten every panel that is
202
+ // already in the document
203
+ find: () => {
204
+ const roots = new Set();
205
+ document.querySelectorAll('.MuiAccordion-root').forEach(a => {
206
+ const g = a.parentElement;
207
+ if (g && [...g.children].filter(c => c.classList.contains('MuiAccordion-root')).length >= 2)
208
+ roots.add(g);
209
+ });
210
+ return [...roots];
211
+ },
212
+ extract: (el) => {
213
+ const secs = [...el.querySelectorAll('.MuiAccordion-root')].map(a => {
214
+ const btn = a.querySelector('[aria-expanded]');
215
+ const region = a.querySelector('[role="region"],.MuiCollapse-root');
216
+ // The panel content references the header by id (aria-labelledby).
217
+ // Carry that id onto the section title, or flattening orphans every
218
+ // one of those references — a regression in the very property this
219
+ // rewrite claims to improve.
220
+ return btn && region && rsRendered(region)
221
+ ? { title: rsRendered(btn), html: region.innerHTML,
222
+ headerId: btn.getAttribute('id') || '' } : null;
223
+ }).filter(Boolean);
224
+ return secs.length >= 2 ? { secs } : null;
225
+ },
226
+ render: (d) => `<div class="rs rs-sections">` + d.secs.map(t =>
227
+ `<section class="rs-section"><h3 class="rs-section-title"${t.headerId ? ` id="${rsEsc(t.headerId)}"` : ''}>${rsEsc(t.title)}</h3>
228
+ <div class="rs-section-body">${t.html}</div></section>`).join('') + `</div>`,
229
+ },
230
+ })