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.
- package/CHANGELOG.md +16 -0
- package/CHECKS.md +40 -0
- package/LICENSE +21 -0
- package/README.md +116 -0
- package/agent.md +70 -0
- package/checks/cli.mjs +44 -0
- package/checks/substrate.mjs +269 -0
- package/cli/affora.mjs +131 -0
- package/design.md +113 -0
- package/package.json +71 -0
- package/registry.json +83 -0
- package/rules/antd.js +173 -0
- package/rules/generic.js +299 -0
- package/rules/hidden-radio.js +94 -0
- package/rules/magento.js +325 -0
- package/rules/mui.js +230 -0
- package/rules/shadcn-baseui.js +169 -0
- package/rules/shadcn-radix.js +289 -0
- package/src/components/combobox.tsx +279 -0
- package/src/components/datatable.tsx +454 -0
- package/src/components/dialog.tsx +548 -0
- package/src/components/productcard.tsx +551 -0
- package/src/components/settingsform.tsx +583 -0
- package/src/patterns/confirmundo.tsx +134 -0
- package/src/patterns/errremedy.tsx +155 -0
- package/src/patterns/flowform.tsx +179 -0
- package/src/patterns/gatedaction.tsx +157 -0
- package/src/patterns/persistentfeedback.tsx +106 -0
- package/src/primitives/actionbutton.tsx +64 -0
- package/src/primitives/textfield.tsx +57 -0
- package/src/tokens/themes.css +1068 -0
package/rules/magento.js
ADDED
|
@@ -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
|
+
})
|