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/cli/affora.mjs ADDED
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { access, copyFile, mkdir, readFile } from "node:fs/promises"
4
+ import { constants } from "node:fs"
5
+ import path from "node:path"
6
+ import process from "node:process"
7
+ import { fileURLToPath } from "node:url"
8
+
9
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
10
+ const manifest = JSON.parse(await readFile(path.join(packageRoot, "registry.json"), "utf8"))
11
+ const items = manifest.items
12
+
13
+ function usage() {
14
+ return `Affora copy-in component CLI
15
+
16
+ Usage:
17
+ affora list
18
+ affora add <item...> [--cwd <directory>] [--path <directory>] [--force] [--dry-run]
19
+ affora add --all [--cwd <directory>] [--path <directory>] [--force] [--dry-run]
20
+
21
+ Examples:
22
+ npx affora add combobox
23
+ npx affora add product-card dialog --path src/ui
24
+ npx affora add --all
25
+ npx affora add themes --path src/styles
26
+
27
+ Components automatically include the Affora theme tokens. Existing files are
28
+ never overwritten unless --force is passed.`
29
+ }
30
+
31
+ function fail(message) {
32
+ console.error(`affora: ${message}`)
33
+ process.exitCode = 1
34
+ }
35
+
36
+ function parse(argv) {
37
+ const [command, ...rest] = argv
38
+ const options = { cwd: process.cwd(), path: "src", force: false, dryRun: false, all: false }
39
+ const names = []
40
+ for (let i = 0; i < rest.length; i += 1) {
41
+ const arg = rest[i]
42
+ if (arg === "--force") options.force = true
43
+ else if (arg === "--dry-run") options.dryRun = true
44
+ else if (arg === "--all") options.all = true
45
+ else if (arg === "--cwd" || arg === "--path") {
46
+ const value = rest[++i]
47
+ if (!value || value.startsWith("--")) throw new Error(`${arg} requires a value`)
48
+ options[arg.slice(2)] = value
49
+ } else if (arg.startsWith("--")) throw new Error(`unknown option ${arg}`)
50
+ else names.push(arg)
51
+ }
52
+ return { command, names, options }
53
+ }
54
+
55
+ function resolveNames(requested) {
56
+ const resolved = []
57
+ const visiting = new Set()
58
+ const add = (name) => {
59
+ if (!items[name]) throw new Error(`unknown item "${name}"; run \`affora list\``)
60
+ if (visiting.has(name)) return
61
+ visiting.add(name)
62
+ for (const dependency of items[name].requires) add(dependency)
63
+ resolved.push(name)
64
+ }
65
+ for (const name of requested) add(name)
66
+ return resolved
67
+ }
68
+
69
+ async function exists(filename) {
70
+ try {
71
+ await access(filename, constants.F_OK)
72
+ return true
73
+ } catch {
74
+ return false
75
+ }
76
+ }
77
+
78
+ async function add(requested, options) {
79
+ if (requested.length === 0) throw new Error("add requires at least one item")
80
+ const names = resolveNames(requested)
81
+ const base = path.resolve(options.cwd, options.path)
82
+ const planned = names.map((name) => ({
83
+ name,
84
+ source: path.join(packageRoot, items[name].source),
85
+ destination: path.join(base, items[name].target),
86
+ }))
87
+
88
+ if (!options.force) {
89
+ const conflicts = []
90
+ for (const file of planned) if (await exists(file.destination)) conflicts.push(file.destination)
91
+ if (conflicts.length > 0) {
92
+ throw new Error(`refusing to overwrite ${conflicts.map((file) => path.relative(options.cwd, file)).join(", ")}; pass --force to replace`)
93
+ }
94
+ }
95
+
96
+ for (const file of planned) {
97
+ const shown = path.relative(options.cwd, file.destination)
98
+ if (options.dryRun) console.log(`would add ${file.name} -> ${shown}`)
99
+ else {
100
+ await mkdir(path.dirname(file.destination), { recursive: true })
101
+ await copyFile(file.source, file.destination)
102
+ console.log(`added ${file.name} -> ${shown}`)
103
+ }
104
+ }
105
+ }
106
+
107
+ async function main() {
108
+ let parsed
109
+ try {
110
+ parsed = parse(process.argv.slice(2))
111
+ if (!parsed.command || parsed.command === "help" || parsed.command === "--help" || parsed.command === "-h") {
112
+ console.log(usage())
113
+ return
114
+ }
115
+ if (parsed.command === "list") {
116
+ for (const [name, item] of Object.entries(items)) console.log(`${name.padEnd(14)} ${item.description}`)
117
+ return
118
+ }
119
+ if (parsed.command === "add") {
120
+ if (parsed.options.all && parsed.names.length) throw new Error("use item names or --all, not both")
121
+ const requested = parsed.options.all ? Object.keys(items).filter((name) => name !== "themes") : parsed.names
122
+ await add(requested, parsed.options)
123
+ return
124
+ }
125
+ throw new Error(`unknown command "${parsed.command}"`)
126
+ } catch (error) {
127
+ fail(error instanceof Error ? error.message : String(error))
128
+ }
129
+ }
130
+
131
+ await main()
package/design.md ADDED
@@ -0,0 +1,113 @@
1
+ # Designing with Affora
2
+
3
+ Affora is a design system for interfaces with two readers: a person who sees
4
+ the page and an agent that enumerates the document. Its central contract is:
5
+
6
+ > What an interface declares is fixed and checked. What it paints is free.
7
+
8
+ Affora calls the declared document the **substrate** and its visual expression
9
+ the **skin**. A skin may change colour, typography, shape, spacing, layout,
10
+ motion, and effects. It must not remove or rename the controls, facts, state,
11
+ or relationships on which either reader depends.
12
+
13
+ ## Three layers
14
+
15
+ Use one vocabulary throughout product and code:
16
+
17
+ 1. **Primitive** — a single native interaction such as a button, input, or
18
+ select. Prefer the native element and give it a persistent accessible name.
19
+ 2. **Composition** — several primitives that express one coherent object, such
20
+ as a product card, data table, or settings form.
21
+ 3. **Flow pattern** — state and actions across time, such as a multi-step form,
22
+ gated action, recoverable deletion, or error-remedy sequence.
23
+
24
+ The current registry contains Affora's native primitive layer, five
25
+ compositions, and five flow patterns. Affora does not rebrand the project's
26
+ shadcn control condition as its primitive layer; each published item uses
27
+ native controls and has no experiment-harness dependency.
28
+
29
+ ## The substrate rules
30
+
31
+ ### State is stated
32
+
33
+ Render task-relevant state as document text or native state, not only as
34
+ colour, position, or styling. Say which item is selected, which page is current,
35
+ what was saved, and which actions have completed. A count alone is not a
36
+ receipt: name the completed items when identity matters.
37
+
38
+ ### Answers are present
39
+
40
+ If content can answer the reader's question, keep it in the document. It may be
41
+ visually collapsed, clipped, or progressively revealed, but interaction must
42
+ not be the only way for a document reader to discover that it exists.
43
+
44
+ ### Affordances are real and named
45
+
46
+ Use native controls. Give every control a stable name, preferably visible text.
47
+ An icon can accompany the name but must not be its only carrier. The visible
48
+ and operable target must occupy the same place.
49
+
50
+ ### Meaning has more than one carrier
51
+
52
+ Colour, geometry, motion, and position may reinforce meaning; none may be the
53
+ only source of it. Status, severity, destructive consequence, and selection
54
+ must also be stated.
55
+
56
+ ### Structure matches hierarchy
57
+
58
+ Use headings for headings, labels for fields, fieldsets for grouped choices,
59
+ tables for tabular relationships, and lists for lists. Visual rank may vary by
60
+ theme; semantic rank must remain truthful.
61
+
62
+ ### Constraints precede failure
63
+
64
+ State required values, formats, ranges, and unlocking conditions before the
65
+ reader attempts an action. A disabled control must name why it is disabled and
66
+ the control or action that resolves the condition.
67
+
68
+ ### Feedback persists
69
+
70
+ Keep outcomes readable after the action completes. Toasts may provide immediate
71
+ feedback, but durable state must also remain in the document. Error messages
72
+ must say what failed, why, and what named action corrects it.
73
+
74
+ ### Vocabulary and shape stay stable
75
+
76
+ Use one name for one concept and one semantic shape for one pattern. This helps
77
+ people learn the product and lets agents reuse a successful action recipe.
78
+
79
+ ## Flow rules
80
+
81
+ - Minimise interaction depth. Each required step adds an observation cycle and
82
+ another place to lose context.
83
+ - Make each screen self-describing. Restate task facts and progress needed to
84
+ continue; do not assume access to the previous screen.
85
+ - Offer a direct path beside guided presentation. Visual staging must not require
86
+ serial interaction when the underlying task can be submitted directly.
87
+ - Prefer cheap reversal to routine confirmation. Mark irreversible actions
88
+ explicitly; use undo where recovery is possible.
89
+ - State completion and leave a durable result the reader can verify.
90
+
91
+ ## Skin contract
92
+
93
+ Components consume semantic custom properties from `themes.css`; they do not
94
+ choose a theme. Apply a style and layout at an ancestor:
95
+
96
+ ```html
97
+ <html data-flagship="swiss" data-layout="saas" data-mode="light">
98
+ ```
99
+
100
+ Changing those attributes may change the skin, never the substrate. When adding
101
+ a theme, define the complete token vocabulary and preserve token meaning across
102
+ light and dark modes. Do not target a component's internal classes from a theme.
103
+
104
+ ## Review gate
105
+
106
+ Before accepting a component or rewrite, verify all three statements:
107
+
108
+ 1. The page still **says** every task-relevant fact and state.
109
+ 2. The page still **has** every required affordance with a stable name.
110
+ 3. Using an affordance still **causes** the same product effect.
111
+
112
+ Then run Affora's executable checks. A passing check suite establishes document
113
+ conformance; it does not guarantee that every agent will complete every task.
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "affora",
3
+ "version": "0.1.0",
4
+ "description": "Components for interfaces with two readers: a person, and an agent that reads the page",
5
+ "keywords": [
6
+ "react",
7
+ "components",
8
+ "design-system",
9
+ "accessibility",
10
+ "agents",
11
+ "copy-in"
12
+ ],
13
+ "license": "MIT",
14
+ "type": "module",
15
+ "homepage": "https://affora.design",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/srcJin/Agent_Friendly_Web_Design.git",
19
+ "directory": "library"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/srcJin/Agent_Friendly_Web_Design/issues"
23
+ },
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "bin": {
31
+ "affora": "cli/affora.mjs"
32
+ },
33
+ "files": [
34
+ "cli/affora.mjs",
35
+ "registry.json",
36
+ "src/primitives/",
37
+ "src/components/",
38
+ "src/patterns/",
39
+ "src/tokens/",
40
+ "checks/",
41
+ "rules/",
42
+ "README.md",
43
+ "design.md",
44
+ "agent.md",
45
+ "CHANGELOG.md",
46
+ "CHECKS.md",
47
+ "LICENSE"
48
+ ],
49
+ "scripts": {
50
+ "demo": "vite demo --port 5280 --strictPort",
51
+ "build:demo": "vite build demo --config demo/vite.config.js",
52
+ "check": "node checks/cli.mjs",
53
+ "typecheck": "tsc -p tsconfig.json",
54
+ "test:cli": "node --test cli/affora.test.mjs",
55
+ "test": "npm run typecheck && npm run test:cli"
56
+ },
57
+ "peerDependencies": {
58
+ "react": ">=18",
59
+ "react-dom": ">=18"
60
+ },
61
+ "devDependencies": {
62
+ "@types/react": "^19.3.0",
63
+ "@types/react-dom": "^19.3.0",
64
+ "@vitejs/plugin-react": "^4.3.3",
65
+ "playwright": "^1.63.0",
66
+ "react": "^18.3.1",
67
+ "react-dom": "^18.3.1",
68
+ "typescript": "^7.0.2",
69
+ "vite": "^5.4.10"
70
+ }
71
+ }
package/registry.json ADDED
@@ -0,0 +1,83 @@
1
+ {
2
+ "version": 1,
3
+ "items": {
4
+ "action-button": {
5
+ "description": "Named native action with stated loading and destructive consequences.",
6
+ "source": "src/primitives/actionbutton.tsx",
7
+ "target": "primitives/affora/action-button.tsx",
8
+ "requires": ["themes"]
9
+ },
10
+ "text-field": {
11
+ "description": "Native text field with persistent label, constraint, and error remedy.",
12
+ "source": "src/primitives/textfield.tsx",
13
+ "target": "primitives/affora/text-field.tsx",
14
+ "requires": ["themes"]
15
+ },
16
+ "combobox": {
17
+ "description": "Searchable framework picker with enumerable native options.",
18
+ "source": "src/components/combobox.tsx",
19
+ "target": "components/affora/combobox.tsx",
20
+ "requires": ["themes"]
21
+ },
22
+ "data-table": {
23
+ "description": "Sortable semantic data table with named row actions.",
24
+ "source": "src/components/datatable.tsx",
25
+ "target": "components/affora/data-table.tsx",
26
+ "requires": ["themes"]
27
+ },
28
+ "dialog": {
29
+ "description": "Modal dialog with stated constraints and focus management.",
30
+ "source": "src/components/dialog.tsx",
31
+ "target": "components/affora/dialog.tsx",
32
+ "requires": ["themes"]
33
+ },
34
+ "product-card": {
35
+ "description": "Product card whose variant and quantity remain explicit state.",
36
+ "source": "src/components/productcard.tsx",
37
+ "target": "components/affora/product-card.tsx",
38
+ "requires": ["themes"]
39
+ },
40
+ "settings-form": {
41
+ "description": "Settings form with persistent labels, help, and saved state.",
42
+ "source": "src/components/settingsform.tsx",
43
+ "target": "components/affora/settings-form.tsx",
44
+ "requires": ["themes"]
45
+ },
46
+ "gated-action": {
47
+ "description": "Disabled action that persistently names its blocker and remedy.",
48
+ "source": "src/patterns/gatedaction.tsx",
49
+ "target": "patterns/affora/gated-action.tsx",
50
+ "requires": ["themes"]
51
+ },
52
+ "error-remedy": {
53
+ "description": "Persistent form error that names the conflict and corrective action.",
54
+ "source": "src/patterns/errremedy.tsx",
55
+ "target": "patterns/affora/error-remedy.tsx",
56
+ "requires": ["themes"]
57
+ },
58
+ "confirm-undo": {
59
+ "description": "Recoverable destructive action with a persistent named undo.",
60
+ "source": "src/patterns/confirmundo.tsx",
61
+ "target": "patterns/affora/confirm-undo.tsx",
62
+ "requires": ["themes"]
63
+ },
64
+ "flow-form": {
65
+ "description": "Guided form whose complete directly submittable substrate stays mounted.",
66
+ "source": "src/patterns/flowform.tsx",
67
+ "target": "patterns/affora/flow-form.tsx",
68
+ "requires": ["themes"]
69
+ },
70
+ "persistent-feedback": {
71
+ "description": "Action result and returned value that persist until superseded.",
72
+ "source": "src/patterns/persistentfeedback.tsx",
73
+ "target": "patterns/affora/persistent-feedback.tsx",
74
+ "requires": ["themes"]
75
+ },
76
+ "themes": {
77
+ "description": "Affora semantic and layout tokens with sixteen visual themes.",
78
+ "source": "src/tokens/themes.css",
79
+ "target": "styles/affora/themes.css",
80
+ "requires": []
81
+ }
82
+ }
83
+ }
package/rules/antd.js ADDED
@@ -0,0 +1,173 @@
1
+ // Ant Design rules — developed and verified against NATIVE antd projects
2
+ // (ant.design's own component demos), never against our benchmark re-creations,
3
+ // which would make the validation circular.
4
+ //
5
+ // Empirically probed idioms, and they differ from every other library here:
6
+ // Select the focusable element is an <input role="combobox"> whose VALUE is
7
+ // not in the input at all — it is rendered in a sibling
8
+ // .ant-select-selection-item, and the options portal into
9
+ // .ant-select-dropdown only once opened.
10
+ // Tabs the tab is a DIV (.ant-tabs-tab-btn[role=tab]), not a button, and
11
+ // inactive panels are unmounted like Radix's.
12
+ // Collapse .ant-collapse-header carries aria-expanded and the content lives in
13
+ // a sibling panel — .ant-collapse-panel in v6, .ant-collapse-content in
14
+ // v5 — which stays mounted once opened.
15
+ ({
16
+ antd_select: {
17
+ find: () => [...document.querySelectorAll('input.ant-select-selection-search-input, input.ant-select-input, .ant-select input[role="combobox"]')],
18
+ extract: (el) => {
19
+ const wrap = el.closest('.ant-select');
20
+ if (!wrap) return null;
21
+ const owned = el.getAttribute('aria-controls');
22
+ let box = owned && document.getElementById(owned);
23
+ if (!box) box = document.querySelector('.ant-select-dropdown:not(.ant-select-dropdown-hidden)');
24
+ const opts = box ? [...box.querySelectorAll('.ant-select-item-option, [role="option"]')]
25
+ .map(o => ({ value: o.getAttribute('data-value') || rsRendered(o), label: rsRendered(o) }))
26
+ .filter(o => o.label) : [];
27
+ if (!opts.length) {
28
+ const tries = +(el.getAttribute('data-rs-probe') || 0);
29
+ if (tries >= 3) return null;
30
+ el.setAttribute('data-rs-probe', String(tries + 1));
31
+ // antd opens on mousedown on the SELECTOR, not on the inner input
32
+ const sel = wrap.querySelector('.ant-select-selector') || wrap;
33
+ sel.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
34
+ sel.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
35
+ return 'retry';
36
+ }
37
+ // The chosen value is rendered beside the input, never inside it, so reading
38
+ // el.value returns empty on a select that plainly shows a selection. In
39
+ // multiple/tags mode there is one item PER selection, and reading only the
40
+ // first silently deleted every other chosen value — 17 lost facts on the
41
+ // demo page.
42
+ const chosen = [...wrap.querySelectorAll('.ant-select-selection-item')]
43
+ .map(n => rsRendered(n)).filter(Boolean);
44
+ const multi = wrap.classList.contains('ant-select-multiple') || chosen.length > 1;
45
+ const shown = chosen.join(', ');
46
+ const placeholder = rsRendered(wrap.querySelector('.ant-select-selection-placeholder'));
47
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
48
+ return { label: rsLabel(wrap), opts, current: shown, chosen, multi, placeholder,
49
+ wrap, id: el.getAttribute('id') || '' };
50
+ },
51
+ anchor: (el, d) => d.wrap,
52
+ render: (d) => {
53
+ const id = d.id || ('rs-' + (++window.__rsSeq));
54
+ const chosen = new Set(d.chosen || []);
55
+ // Options the page shows as chosen but that are not in the harvested list
56
+ // (a tags-mode select accepts free text) must still be stated, or the swap
57
+ // deletes what the user picked.
58
+ const extra = (d.chosen || []).filter(c => !d.opts.some(o => o.label === c));
59
+ return `<div class="rs rs-field">
60
+ ${d.label ? `<label class="rs-label" for="${id}">${rsEsc(d.label)}</label>` : ''}
61
+ ${d.multi && chosen.size ? `<p class="rs-label">Selected: ${rsEsc(d.current)}</p>` : ''}
62
+ <select class="rs-select" id="${id}"${d.multi ? ' multiple' : ''}>
63
+ ${chosen.size ? '' : `<option value="" selected>${rsEsc(d.placeholder || 'Select…')}</option>`}
64
+ ${extra.map(c => `<option value="${rsEsc(c)}" selected>${rsEsc(c)}</option>`).join('')}
65
+ ${d.opts.map(o => `<option value="${rsEsc(o.value)}"${chosen.has(o.label) ? ' selected' : ''}>${rsEsc(o.label)}</option>`).join('')}
66
+ </select>
67
+ </div>`;
68
+ },
69
+ },
70
+
71
+ antd_tabs: {
72
+ // flattening: the tab/section controls exist to reveal content this
73
+ // output reveals structurally, so they are subsumed, not lost
74
+ subsumes: true,
75
+ find: () => {
76
+ // A NAVIGATION switcher is not a content tab strip. Atomic CRM renders its
77
+ // top navigation as MUI Tabs whose tabs are links; probing one navigated
78
+ // the application to its root and the flattening then replaced the page,
79
+ // costing the rewritten arm 25 of 36 tasks it could otherwise do. The
80
+ // generic tabs rule has carried this guard since Magento's mobile nav.
81
+ const contentTabs = (el) => !el.closest('nav,[role="navigation"]')
82
+ && ![...el.querySelectorAll('[role="tab"]')].some(t => t.tagName === 'A' && t.getAttribute('href'));
83
+ return [...document.querySelectorAll('.ant-tabs-nav-list, [role="tablist"]')]
84
+ .filter(t => t.querySelector('[role="tab"]'))
85
+ .filter(contentTabs)
86
+ },
87
+ extract: (el) => {
88
+ const trigs = [...el.querySelectorAll('[role="tab"]')];
89
+ if (trigs.length < 2) return null;
90
+ const st = el.__rsCap || (el.__rsCap = { caps: {} });
91
+ let missing = null;
92
+ trigs.forEach(t => {
93
+ const key = rsRendered(t);
94
+ if (!key) return;
95
+ const id = t.getAttribute('aria-controls');
96
+ const panel = id ? document.getElementById(id) : null;
97
+ if (panel && rsRendered(panel)) st.caps[key] = { title: key, html: panel.innerHTML, panel };
98
+ else if (!(key in st.caps) && !missing) missing = t;
99
+ });
100
+ if (missing) {
101
+ const tries = +(el.getAttribute('data-rs-probe') || 0);
102
+ if (tries < trigs.length * 2 + 2) {
103
+ el.setAttribute('data-rs-probe', String(tries + 1));
104
+ // a DIV tab responds to click, not to form activation
105
+ missing.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
106
+ missing.click();
107
+ return 'retry';
108
+ }
109
+ }
110
+ // Keep EVERY tab's title, not only the ones whose panel we captured. A demo
111
+ // with an overflow menu never mounts the hidden tabs' panels, so filtering
112
+ // to captured ones deleted the names 'Tab-28' and 'Tab-29' from a page that
113
+ // plainly lists them. A title with no body asserts that the tab exists and
114
+ // says nothing about its contents, which is exactly what we know.
115
+ const tabs = trigs.map(t => {
116
+ const key = rsRendered(t);
117
+ return key ? (st.caps[key] || { title: key, html: '', panel: null }) : null;
118
+ }).filter(Boolean);
119
+ return tabs.filter(t => t.html).length >= 1 && tabs.length >= 2 ? { tabs } : null;
120
+ },
121
+ render: (d) => `<div class="rs rs-sections">` + d.tabs.map(t =>
122
+ `<section class="rs-section"><h3 class="rs-section-title">${rsEsc(t.title)}</h3>
123
+ ${t.html ? `<div class="rs-section-body">${t.html}</div>` : ''}</section>`).join('')
124
+ + `</div>`,
125
+ removes: (d) => d.tabs.map(t => t.panel).filter(Boolean),
126
+ after: (d) => d.tabs.forEach(t => t.panel && t.panel.isConnected && t.panel.remove()),
127
+ },
128
+
129
+ antd_collapse: {
130
+ // flattening: the tab/section controls exist to reveal content this
131
+ // output reveals structurally, so they are subsumed, not lost
132
+ subsumes: true,
133
+ find: () => {
134
+ const roots = new Set();
135
+ document.querySelectorAll('.ant-collapse').forEach(c => {
136
+ if (c.querySelectorAll('.ant-collapse-header').length >= 2) roots.add(c);
137
+ });
138
+ return [...roots];
139
+ },
140
+ extract: (el) => {
141
+ const heads = [...el.querySelectorAll('.ant-collapse-header')];
142
+ const st = el.__rsCap || (el.__rsCap = { caps: {} });
143
+ let missing = null;
144
+ heads.forEach(h => {
145
+ const key = rsRendered(h);
146
+ if (!key) return;
147
+ const item = h.closest('.ant-collapse-item') || h.parentElement;
148
+ // antd renamed this between majors — v5 ships .ant-collapse-content, v6
149
+ // ships .ant-collapse-panel — so a single-name selector matched nothing on
150
+ // the current site and the rule reported zero instances on a page full of
151
+ // them. Accept both rather than pinning a version.
152
+ const body = item && item.querySelector('.ant-collapse-panel, .ant-collapse-content');
153
+ if (body && rsRendered(body)) st.caps[key] = { title: key, html: body.innerHTML, panel: body };
154
+ else if (!(key in st.caps) && !missing) missing = h;
155
+ });
156
+ if (missing) {
157
+ const tries = +(el.getAttribute('data-rs-probe') || 0);
158
+ if (tries < heads.length * 2 + 2) {
159
+ el.setAttribute('data-rs-probe', String(tries + 1));
160
+ missing.click();
161
+ return 'retry';
162
+ }
163
+ }
164
+ const secs = heads.map(h => st.caps[rsRendered(h)]).filter(Boolean);
165
+ return secs.length >= 2 ? { secs } : null;
166
+ },
167
+ render: (d) => `<div class="rs rs-sections">` + d.secs.map(t =>
168
+ `<section class="rs-section"><h3 class="rs-section-title">${rsEsc(t.title)}</h3>
169
+ <div class="rs-section-body">${t.html}</div></section>`).join('') + `</div>`,
170
+ removes: (d) => d.secs.map(t => t.panel),
171
+ after: (d) => d.secs.forEach(t => t.panel && t.panel.isConnected && t.panel.remove()),
172
+ },
173
+ })