@wootsup/yt-builder-mcp 1.11.0 → 2.0.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.
Files changed (853) hide show
  1. package/README.md +131 -33
  2. package/bin/node-floor.d.ts +28 -0
  3. package/bin/node-floor.js +123 -0
  4. package/bin/yt-builder-mcp.js +52 -11
  5. package/dist/auth.d.ts +3 -121
  6. package/dist/auth.d.ts.map +1 -1
  7. package/dist/auth.js +40 -170
  8. package/dist/auth.js.map +1 -1
  9. package/dist/catalog/build-catalog.d.ts +4 -49
  10. package/dist/catalog/build-catalog.d.ts.map +1 -1
  11. package/dist/catalog/build-catalog.js +15 -126
  12. package/dist/catalog/build-catalog.js.map +1 -1
  13. package/dist/catalog/json-schema.d.ts +3 -0
  14. package/dist/catalog/json-schema.d.ts.map +1 -0
  15. package/dist/catalog/json-schema.js +68 -0
  16. package/dist/catalog/json-schema.js.map +1 -0
  17. package/dist/catalog/output-shapes.d.ts +6 -0
  18. package/dist/catalog/output-shapes.d.ts.map +1 -0
  19. package/dist/catalog/output-shapes.js +89 -0
  20. package/dist/catalog/output-shapes.js.map +1 -0
  21. package/dist/catalog/tool-catalog-markdown.d.ts +1 -37
  22. package/dist/catalog/tool-catalog-markdown.d.ts.map +1 -1
  23. package/dist/catalog/tool-catalog-markdown.js +9 -41
  24. package/dist/catalog/tool-catalog-markdown.js.map +1 -1
  25. package/dist/cli/doctor-command.d.ts +2 -7
  26. package/dist/cli/doctor-command.d.ts.map +1 -1
  27. package/dist/cli/doctor-command.js +33 -54
  28. package/dist/cli/doctor-command.js.map +1 -1
  29. package/dist/cli/invocation-decision.d.ts +4 -52
  30. package/dist/cli/invocation-decision.d.ts.map +1 -1
  31. package/dist/cli/invocation-decision.js +45 -54
  32. package/dist/cli/invocation-decision.js.map +1 -1
  33. package/dist/client.d.ts +3 -107
  34. package/dist/client.d.ts.map +1 -1
  35. package/dist/client.js +34 -115
  36. package/dist/client.js.map +1 -1
  37. package/dist/clients/claude-code.d.ts +0 -15
  38. package/dist/clients/claude-code.d.ts.map +1 -1
  39. package/dist/clients/claude-code.js +0 -22
  40. package/dist/clients/claude-code.js.map +1 -1
  41. package/dist/clients/claude-desktop.d.ts +0 -16
  42. package/dist/clients/claude-desktop.d.ts.map +1 -1
  43. package/dist/clients/claude-desktop.js +0 -18
  44. package/dist/clients/claude-desktop.js.map +1 -1
  45. package/dist/clients/cline.d.ts +0 -23
  46. package/dist/clients/cline.d.ts.map +1 -1
  47. package/dist/clients/cline.js +0 -33
  48. package/dist/clients/cline.js.map +1 -1
  49. package/dist/clients/codex-cli.d.ts +0 -39
  50. package/dist/clients/codex-cli.d.ts.map +1 -1
  51. package/dist/clients/codex-cli.js +0 -137
  52. package/dist/clients/codex-cli.js.map +1 -1
  53. package/dist/clients/continue.d.ts +0 -10
  54. package/dist/clients/continue.d.ts.map +1 -1
  55. package/dist/clients/continue.js +0 -26
  56. package/dist/clients/continue.js.map +1 -1
  57. package/dist/clients/cursor.d.ts +0 -9
  58. package/dist/clients/cursor.d.ts.map +1 -1
  59. package/dist/clients/cursor.js +0 -11
  60. package/dist/clients/cursor.js.map +1 -1
  61. package/dist/clients/gemini-cli.d.ts +0 -15
  62. package/dist/clients/gemini-cli.d.ts.map +1 -1
  63. package/dist/clients/gemini-cli.js +0 -17
  64. package/dist/clients/gemini-cli.js.map +1 -1
  65. package/dist/clients/home.d.ts +0 -12
  66. package/dist/clients/home.d.ts.map +1 -1
  67. package/dist/clients/home.js +0 -12
  68. package/dist/clients/home.js.map +1 -1
  69. package/dist/clients/index.d.ts +0 -150
  70. package/dist/clients/index.d.ts.map +1 -1
  71. package/dist/clients/index.js +0 -112
  72. package/dist/clients/index.js.map +1 -1
  73. package/dist/clients/roo-code.d.ts +0 -20
  74. package/dist/clients/roo-code.d.ts.map +1 -1
  75. package/dist/clients/roo-code.js +0 -22
  76. package/dist/clients/roo-code.js.map +1 -1
  77. package/dist/clients/zed.d.ts +0 -9
  78. package/dist/clients/zed.d.ts.map +1 -1
  79. package/dist/clients/zed.js +0 -18
  80. package/dist/clients/zed.js.map +1 -1
  81. package/dist/compat-floor.d.ts +35 -0
  82. package/dist/compat-floor.d.ts.map +1 -0
  83. package/dist/compat-floor.js +84 -0
  84. package/dist/compat-floor.js.map +1 -0
  85. package/dist/compat-probe.d.ts +6 -0
  86. package/dist/compat-probe.d.ts.map +1 -0
  87. package/dist/compat-probe.js +20 -0
  88. package/dist/compat-probe.js.map +1 -0
  89. package/dist/diagnostics/ca-reexec.d.ts +0 -30
  90. package/dist/diagnostics/ca-reexec.d.ts.map +1 -1
  91. package/dist/diagnostics/ca-reexec.js +0 -62
  92. package/dist/diagnostics/ca-reexec.js.map +1 -1
  93. package/dist/diagnostics/ca-trust.d.ts +0 -22
  94. package/dist/diagnostics/ca-trust.d.ts.map +1 -1
  95. package/dist/diagnostics/ca-trust.js +3 -42
  96. package/dist/diagnostics/ca-trust.js.map +1 -1
  97. package/dist/diagnostics/diagnose-network-error.d.ts +1 -38
  98. package/dist/diagnostics/diagnose-network-error.d.ts.map +1 -1
  99. package/dist/diagnostics/diagnose-network-error.js +26 -37
  100. package/dist/diagnostics/diagnose-network-error.js.map +1 -1
  101. package/dist/diagnostics/doctor.d.ts +13 -65
  102. package/dist/diagnostics/doctor.d.ts.map +1 -1
  103. package/dist/diagnostics/doctor.js +46 -70
  104. package/dist/diagnostics/doctor.js.map +1 -1
  105. package/dist/diagnostics/startup-self-probe.d.ts +3 -25
  106. package/dist/diagnostics/startup-self-probe.d.ts.map +1 -1
  107. package/dist/diagnostics/startup-self-probe.js +11 -48
  108. package/dist/diagnostics/startup-self-probe.js.map +1 -1
  109. package/dist/errors/hints.d.ts +0 -44
  110. package/dist/errors/hints.d.ts.map +1 -1
  111. package/dist/errors/hints.js +0 -32
  112. package/dist/errors/hints.js.map +1 -1
  113. package/dist/errors/mask.d.ts +0 -31
  114. package/dist/errors/mask.d.ts.map +1 -1
  115. package/dist/errors/mask.js +0 -32
  116. package/dist/errors/mask.js.map +1 -1
  117. package/dist/errors/redact-credentials.d.ts +2 -0
  118. package/dist/errors/redact-credentials.d.ts.map +1 -0
  119. package/dist/errors/redact-credentials.js +13 -0
  120. package/dist/errors/redact-credentials.js.map +1 -0
  121. package/dist/errors/sanitize.d.ts +0 -29
  122. package/dist/errors/sanitize.d.ts.map +1 -1
  123. package/dist/errors/sanitize.js +0 -40
  124. package/dist/errors/sanitize.js.map +1 -1
  125. package/dist/errors.d.ts +6 -17
  126. package/dist/errors.d.ts.map +1 -1
  127. package/dist/errors.js +16 -21
  128. package/dist/errors.js.map +1 -1
  129. package/dist/gateway/advanced-read-tool.d.ts +0 -32
  130. package/dist/gateway/advanced-read-tool.d.ts.map +1 -1
  131. package/dist/gateway/advanced-read-tool.js +43 -111
  132. package/dist/gateway/advanced-read-tool.js.map +1 -1
  133. package/dist/gateway/advanced-tool/discovery.d.ts +1 -12
  134. package/dist/gateway/advanced-tool/discovery.d.ts.map +1 -1
  135. package/dist/gateway/advanced-tool/discovery.js +18 -16
  136. package/dist/gateway/advanced-tool/discovery.js.map +1 -1
  137. package/dist/gateway/advanced-tool/domains.d.ts +7 -66
  138. package/dist/gateway/advanced-tool/domains.d.ts.map +1 -1
  139. package/dist/gateway/advanced-tool/domains.js +36 -80
  140. package/dist/gateway/advanced-tool/domains.js.map +1 -1
  141. package/dist/gateway/advanced-tool/execute.d.ts +0 -25
  142. package/dist/gateway/advanced-tool/execute.d.ts.map +1 -1
  143. package/dist/gateway/advanced-tool/execute.js +13 -49
  144. package/dist/gateway/advanced-tool/execute.js.map +1 -1
  145. package/dist/gateway/advanced-tool/index.d.ts +1 -32
  146. package/dist/gateway/advanced-tool/index.d.ts.map +1 -1
  147. package/dist/gateway/advanced-tool/index.js +1 -35
  148. package/dist/gateway/advanced-tool/index.js.map +1 -1
  149. package/dist/gateway/advanced-tool/register.d.ts +0 -14
  150. package/dist/gateway/advanced-tool/register.d.ts.map +1 -1
  151. package/dist/gateway/advanced-tool/register.js +22 -58
  152. package/dist/gateway/advanced-tool/register.js.map +1 -1
  153. package/dist/gateway/advanced-tool.d.ts +0 -11
  154. package/dist/gateway/advanced-tool.d.ts.map +1 -1
  155. package/dist/gateway/advanced-tool.js +0 -11
  156. package/dist/gateway/advanced-tool.js.map +1 -1
  157. package/dist/gateway/capturing-server.d.ts +3 -88
  158. package/dist/gateway/capturing-server.d.ts.map +1 -1
  159. package/dist/gateway/capturing-server.js +0 -77
  160. package/dist/gateway/capturing-server.js.map +1 -1
  161. package/dist/gateway/essentials.d.ts +1 -63
  162. package/dist/gateway/essentials.d.ts.map +1 -1
  163. package/dist/gateway/essentials.js +10 -174
  164. package/dist/gateway/essentials.js.map +1 -1
  165. package/dist/gateway/test-support.d.ts +0 -28
  166. package/dist/gateway/test-support.d.ts.map +1 -1
  167. package/dist/gateway/test-support.js +0 -29
  168. package/dist/gateway/test-support.js.map +1 -1
  169. package/dist/health-response.d.ts +2 -0
  170. package/dist/health-response.d.ts.map +1 -0
  171. package/dist/health-response.js +7 -0
  172. package/dist/health-response.js.map +1 -0
  173. package/dist/icon-data.js +0 -3
  174. package/dist/icon-data.js.map +1 -1
  175. package/dist/index.d.ts +7 -23
  176. package/dist/index.d.ts.map +1 -1
  177. package/dist/index.js +46 -130
  178. package/dist/index.js.map +1 -1
  179. package/dist/install-skill.d.ts +0 -44
  180. package/dist/install-skill.d.ts.map +1 -1
  181. package/dist/install-skill.js +5 -46
  182. package/dist/install-skill.js.map +1 -1
  183. package/dist/net/extra-headers.d.ts +0 -58
  184. package/dist/net/extra-headers.d.ts.map +1 -1
  185. package/dist/net/extra-headers.js +0 -67
  186. package/dist/net/extra-headers.js.map +1 -1
  187. package/dist/net/internal-host.d.ts +0 -31
  188. package/dist/net/internal-host.d.ts.map +1 -1
  189. package/dist/net/internal-host.js +7 -65
  190. package/dist/net/internal-host.js.map +1 -1
  191. package/dist/net/proxy-dispatcher.d.ts +0 -37
  192. package/dist/net/proxy-dispatcher.d.ts.map +1 -1
  193. package/dist/net/proxy-dispatcher.js +0 -40
  194. package/dist/net/proxy-dispatcher.js.map +1 -1
  195. package/dist/net/public-fetch.d.ts +53 -0
  196. package/dist/net/public-fetch.d.ts.map +1 -0
  197. package/dist/net/public-fetch.js +143 -0
  198. package/dist/net/public-fetch.js.map +1 -0
  199. package/dist/net/public-page-report.d.ts +26 -0
  200. package/dist/net/public-page-report.d.ts.map +1 -0
  201. package/dist/net/public-page-report.js +145 -0
  202. package/dist/net/public-page-report.js.map +1 -0
  203. package/dist/net/redirect-safe-fetch.d.ts +14 -0
  204. package/dist/net/redirect-safe-fetch.d.ts.map +1 -0
  205. package/dist/net/redirect-safe-fetch.js +61 -0
  206. package/dist/net/redirect-safe-fetch.js.map +1 -0
  207. package/dist/net/session-recovery-fetch.d.ts +7 -0
  208. package/dist/net/session-recovery-fetch.d.ts.map +1 -0
  209. package/dist/net/session-recovery-fetch.js +139 -0
  210. package/dist/net/session-recovery-fetch.js.map +1 -0
  211. package/dist/net/site-basic-auth.d.ts +1 -59
  212. package/dist/net/site-basic-auth.d.ts.map +1 -1
  213. package/dist/net/site-basic-auth.js +5 -94
  214. package/dist/net/site-basic-auth.js.map +1 -1
  215. package/dist/package-version.d.ts +2 -0
  216. package/dist/package-version.d.ts.map +1 -0
  217. package/dist/package-version.js +15 -0
  218. package/dist/package-version.js.map +1 -0
  219. package/dist/platform/detect.d.ts +0 -54
  220. package/dist/platform/detect.d.ts.map +1 -1
  221. package/dist/platform/detect.js +0 -53
  222. package/dist/platform/detect.js.map +1 -1
  223. package/dist/platform/index.d.ts +0 -100
  224. package/dist/platform/index.d.ts.map +1 -1
  225. package/dist/platform/index.js +0 -106
  226. package/dist/platform/index.js.map +1 -1
  227. package/dist/platform/joomla.d.ts +0 -35
  228. package/dist/platform/joomla.d.ts.map +1 -1
  229. package/dist/platform/joomla.js +0 -35
  230. package/dist/platform/joomla.js.map +1 -1
  231. package/dist/proxy/bridge.d.ts +36 -76
  232. package/dist/proxy/bridge.d.ts.map +1 -1
  233. package/dist/proxy/bridge.js +443 -136
  234. package/dist/proxy/bridge.js.map +1 -1
  235. package/dist/proxy/confirm-elicitation.d.ts +22 -0
  236. package/dist/proxy/confirm-elicitation.d.ts.map +1 -0
  237. package/dist/proxy/confirm-elicitation.js +170 -0
  238. package/dist/proxy/confirm-elicitation.js.map +1 -0
  239. package/dist/proxy/confirm-question-contract.d.ts +6 -0
  240. package/dist/proxy/confirm-question-contract.d.ts.map +1 -0
  241. package/dist/proxy/confirm-question-contract.js +9 -0
  242. package/dist/proxy/confirm-question-contract.js.map +1 -0
  243. package/dist/proxy/confirm-question.d.ts +2 -0
  244. package/dist/proxy/confirm-question.d.ts.map +1 -0
  245. package/dist/proxy/confirm-question.js +125 -0
  246. package/dist/proxy/confirm-question.js.map +1 -0
  247. package/dist/proxy/instructions.d.ts +4 -0
  248. package/dist/proxy/instructions.d.ts.map +1 -0
  249. package/dist/proxy/instructions.js +12 -0
  250. package/dist/proxy/instructions.js.map +1 -0
  251. package/dist/proxy/mode.d.ts +14 -9
  252. package/dist/proxy/mode.d.ts.map +1 -1
  253. package/dist/proxy/mode.js +58 -34
  254. package/dist/proxy/mode.js.map +1 -1
  255. package/dist/proxy/multi-site.d.ts +42 -0
  256. package/dist/proxy/multi-site.d.ts.map +1 -0
  257. package/dist/proxy/multi-site.js +134 -0
  258. package/dist/proxy/multi-site.js.map +1 -0
  259. package/dist/proxy/request-headers.d.ts +9 -0
  260. package/dist/proxy/request-headers.d.ts.map +1 -0
  261. package/dist/proxy/request-headers.js +17 -0
  262. package/dist/proxy/request-headers.js.map +1 -0
  263. package/dist/proxy/shadow-tools.d.ts +9 -0
  264. package/dist/proxy/shadow-tools.d.ts.map +1 -0
  265. package/dist/proxy/shadow-tools.js +42 -0
  266. package/dist/proxy/shadow-tools.js.map +1 -0
  267. package/dist/proxy/tools-list-cap.d.ts +7 -0
  268. package/dist/proxy/tools-list-cap.d.ts.map +1 -0
  269. package/dist/proxy/tools-list-cap.js +14 -0
  270. package/dist/proxy/tools-list-cap.js.map +1 -0
  271. package/dist/proxy/transport-uncertainty.d.ts +2 -0
  272. package/dist/proxy/transport-uncertainty.d.ts.map +1 -0
  273. package/dist/proxy/transport-uncertainty.js +8 -0
  274. package/dist/proxy/transport-uncertainty.js.map +1 -0
  275. package/dist/proxy/upstream-error.d.ts +7 -0
  276. package/dist/proxy/upstream-error.d.ts.map +1 -0
  277. package/dist/proxy/upstream-error.js +35 -0
  278. package/dist/proxy/upstream-error.js.map +1 -0
  279. package/dist/proxy/wire-dialect.d.ts +5 -0
  280. package/dist/proxy/wire-dialect.d.ts.map +1 -0
  281. package/dist/proxy/wire-dialect.js +33 -0
  282. package/dist/proxy/wire-dialect.js.map +1 -0
  283. package/dist/server.d.ts +14 -82
  284. package/dist/server.d.ts.map +1 -1
  285. package/dist/server.js +107 -203
  286. package/dist/server.js.map +1 -1
  287. package/dist/setup/banner-color.d.ts +13 -0
  288. package/dist/setup/banner-color.d.ts.map +1 -0
  289. package/dist/setup/banner-color.js +93 -0
  290. package/dist/setup/banner-color.js.map +1 -0
  291. package/dist/setup/banner.d.ts +22 -0
  292. package/dist/setup/banner.d.ts.map +1 -0
  293. package/dist/setup/banner.js +119 -0
  294. package/dist/setup/banner.js.map +1 -0
  295. package/dist/setup/ca-recovery.d.ts +20 -0
  296. package/dist/setup/ca-recovery.d.ts.map +1 -0
  297. package/dist/setup/ca-recovery.js +92 -0
  298. package/dist/setup/ca-recovery.js.map +1 -0
  299. package/dist/setup/closing.d.ts +15 -0
  300. package/dist/setup/closing.d.ts.map +1 -0
  301. package/dist/setup/closing.js +43 -0
  302. package/dist/setup/closing.js.map +1 -0
  303. package/dist/setup/corporate-ca.d.ts +16 -0
  304. package/dist/setup/corporate-ca.d.ts.map +1 -0
  305. package/dist/setup/corporate-ca.js +32 -0
  306. package/dist/setup/corporate-ca.js.map +1 -0
  307. package/dist/setup/host-platform.d.ts +8 -0
  308. package/dist/setup/host-platform.d.ts.map +1 -0
  309. package/dist/setup/host-platform.js +21 -0
  310. package/dist/setup/host-platform.js.map +1 -0
  311. package/dist/setup/install-configs.d.ts +18 -0
  312. package/dist/setup/install-configs.d.ts.map +1 -0
  313. package/dist/setup/install-configs.js +70 -0
  314. package/dist/setup/install-configs.js.map +1 -0
  315. package/dist/setup/multi-site-loop.d.ts +14 -0
  316. package/dist/setup/multi-site-loop.d.ts.map +1 -0
  317. package/dist/setup/multi-site-loop.js +20 -0
  318. package/dist/setup/multi-site-loop.js.map +1 -0
  319. package/dist/setup/phases.d.ts +18 -0
  320. package/dist/setup/phases.d.ts.map +1 -0
  321. package/dist/setup/phases.js +55 -0
  322. package/dist/setup/phases.js.map +1 -0
  323. package/dist/setup/registry-remedy.d.ts +12 -0
  324. package/dist/setup/registry-remedy.d.ts.map +1 -0
  325. package/dist/setup/registry-remedy.js +37 -0
  326. package/dist/setup/registry-remedy.js.map +1 -0
  327. package/dist/setup/resume.d.ts +28 -0
  328. package/dist/setup/resume.d.ts.map +1 -0
  329. package/dist/setup/resume.js +99 -0
  330. package/dist/setup/resume.js.map +1 -0
  331. package/dist/setup-ca-env.d.ts +0 -30
  332. package/dist/setup-ca-env.d.ts.map +1 -1
  333. package/dist/setup-ca-env.js +0 -28
  334. package/dist/setup-ca-env.js.map +1 -1
  335. package/dist/setup-ca-reprobe.d.ts +0 -25
  336. package/dist/setup-ca-reprobe.d.ts.map +1 -1
  337. package/dist/setup-ca-reprobe.js +0 -34
  338. package/dist/setup-ca-reprobe.js.map +1 -1
  339. package/dist/setup-cli-confirm.d.ts +0 -34
  340. package/dist/setup-cli-confirm.d.ts.map +1 -1
  341. package/dist/setup-cli-confirm.js +0 -36
  342. package/dist/setup-cli-confirm.js.map +1 -1
  343. package/dist/setup-cli.d.ts +6 -167
  344. package/dist/setup-cli.d.ts.map +1 -1
  345. package/dist/setup-cli.js +161 -173
  346. package/dist/setup-cli.js.map +1 -1
  347. package/dist/setup-npx-spec.d.ts +0 -36
  348. package/dist/setup-npx-spec.d.ts.map +1 -1
  349. package/dist/setup-npx-spec.js +0 -38
  350. package/dist/setup-npx-spec.js.map +1 -1
  351. package/dist/setup-prompts.d.ts +5 -70
  352. package/dist/setup-prompts.d.ts.map +1 -1
  353. package/dist/setup-prompts.js +67 -107
  354. package/dist/setup-prompts.js.map +1 -1
  355. package/dist/setup-token.d.ts +0 -34
  356. package/dist/setup-token.d.ts.map +1 -1
  357. package/dist/setup-token.js +0 -40
  358. package/dist/setup-token.js.map +1 -1
  359. package/dist/setup-wizard-defaults.d.ts +0 -36
  360. package/dist/setup-wizard-defaults.d.ts.map +1 -1
  361. package/dist/setup-wizard-defaults.js +0 -75
  362. package/dist/setup-wizard-defaults.js.map +1 -1
  363. package/dist/setup-wizard-handshake.d.ts +1 -19
  364. package/dist/setup-wizard-handshake.d.ts.map +1 -1
  365. package/dist/setup-wizard-handshake.js +8 -45
  366. package/dist/setup-wizard-handshake.js.map +1 -1
  367. package/dist/setup-wizard-types.d.ts +16 -209
  368. package/dist/setup-wizard-types.d.ts.map +1 -1
  369. package/dist/setup-wizard-types.js +0 -9
  370. package/dist/setup-wizard-types.js.map +1 -1
  371. package/dist/setup-wizard.d.ts +0 -28
  372. package/dist/setup-wizard.d.ts.map +1 -1
  373. package/dist/setup-wizard.js +167 -412
  374. package/dist/setup-wizard.js.map +1 -1
  375. package/dist/setup.d.ts +0 -15
  376. package/dist/setup.d.ts.map +1 -1
  377. package/dist/setup.js +0 -15
  378. package/dist/setup.js.map +1 -1
  379. package/dist/sites/bootstrap.d.ts +0 -38
  380. package/dist/sites/bootstrap.d.ts.map +1 -1
  381. package/dist/sites/bootstrap.js +0 -44
  382. package/dist/sites/bootstrap.js.map +1 -1
  383. package/dist/sites/cli/add-site.d.ts +0 -76
  384. package/dist/sites/cli/add-site.d.ts.map +1 -1
  385. package/dist/sites/cli/add-site.js +0 -71
  386. package/dist/sites/cli/add-site.js.map +1 -1
  387. package/dist/sites/cli/list-sites.d.ts +0 -31
  388. package/dist/sites/cli/list-sites.d.ts.map +1 -1
  389. package/dist/sites/cli/list-sites.js +0 -45
  390. package/dist/sites/cli/list-sites.js.map +1 -1
  391. package/dist/sites/cli/remove-site.d.ts +0 -37
  392. package/dist/sites/cli/remove-site.d.ts.map +1 -1
  393. package/dist/sites/cli/remove-site.js +0 -37
  394. package/dist/sites/cli/remove-site.js.map +1 -1
  395. package/dist/sites/cli/set-default.d.ts +0 -25
  396. package/dist/sites/cli/set-default.d.ts.map +1 -1
  397. package/dist/sites/cli/set-default.js +0 -22
  398. package/dist/sites/cli/set-default.js.map +1 -1
  399. package/dist/sites/cli/test-site.d.ts +0 -44
  400. package/dist/sites/cli/test-site.d.ts.map +1 -1
  401. package/dist/sites/cli/test-site.js +0 -39
  402. package/dist/sites/cli/test-site.js.map +1 -1
  403. package/dist/sites/client-pool.d.ts +11 -164
  404. package/dist/sites/client-pool.d.ts.map +1 -1
  405. package/dist/sites/client-pool.js +73 -194
  406. package/dist/sites/client-pool.js.map +1 -1
  407. package/dist/sites/config-problem.d.ts +2 -0
  408. package/dist/sites/config-problem.d.ts.map +1 -0
  409. package/dist/sites/config-problem.js +13 -0
  410. package/dist/sites/config-problem.js.map +1 -0
  411. package/dist/sites/env-bridge.d.ts +0 -69
  412. package/dist/sites/env-bridge.d.ts.map +1 -1
  413. package/dist/sites/env-bridge.js +2 -71
  414. package/dist/sites/env-bridge.js.map +1 -1
  415. package/dist/sites/op-inject.d.ts +23 -0
  416. package/dist/sites/op-inject.d.ts.map +1 -0
  417. package/dist/sites/op-inject.js +131 -0
  418. package/dist/sites/op-inject.js.map +1 -0
  419. package/dist/sites/paths.d.ts +0 -19
  420. package/dist/sites/paths.d.ts.map +1 -1
  421. package/dist/sites/paths.js +0 -19
  422. package/dist/sites/paths.js.map +1 -1
  423. package/dist/sites/probe.d.ts +1 -47
  424. package/dist/sites/probe.d.ts.map +1 -1
  425. package/dist/sites/probe.js +4 -46
  426. package/dist/sites/probe.js.map +1 -1
  427. package/dist/sites/registry.d.ts +4 -135
  428. package/dist/sites/registry.d.ts.map +1 -1
  429. package/dist/sites/registry.js +4 -98
  430. package/dist/sites/registry.js.map +1 -1
  431. package/dist/sites/schema.d.ts +3 -51
  432. package/dist/sites/schema.d.ts.map +1 -1
  433. package/dist/sites/schema.js +6 -52
  434. package/dist/sites/schema.js.map +1 -1
  435. package/dist/sites/secret-resolver.d.ts +6 -76
  436. package/dist/sites/secret-resolver.d.ts.map +1 -1
  437. package/dist/sites/secret-resolver.js +39 -104
  438. package/dist/sites/secret-resolver.js.map +1 -1
  439. package/dist/sites/store.d.ts +12 -69
  440. package/dist/sites/store.d.ts.map +1 -1
  441. package/dist/sites/store.js +37 -86
  442. package/dist/sites/store.js.map +1 -1
  443. package/dist/sites/substituted-registry.d.ts +11 -0
  444. package/dist/sites/substituted-registry.d.ts.map +1 -0
  445. package/dist/sites/substituted-registry.js +13 -0
  446. package/dist/sites/substituted-registry.js.map +1 -0
  447. package/dist/sites/tools/fetch-public-url.d.ts +4 -0
  448. package/dist/sites/tools/fetch-public-url.d.ts.map +1 -0
  449. package/dist/sites/tools/fetch-public-url.js +231 -0
  450. package/dist/sites/tools/fetch-public-url.js.map +1 -0
  451. package/dist/sites/tools/index.d.ts +2 -26
  452. package/dist/sites/tools/index.d.ts.map +1 -1
  453. package/dist/sites/tools/index.js +5 -28
  454. package/dist/sites/tools/index.js.map +1 -1
  455. package/dist/sites/tools/sites-list.d.ts +3 -47
  456. package/dist/sites/tools/sites-list.d.ts.map +1 -1
  457. package/dist/sites/tools/sites-list.js +68 -80
  458. package/dist/sites/tools/sites-list.js.map +1 -1
  459. package/dist/sites/tools/sites-overview.d.ts +4 -0
  460. package/dist/sites/tools/sites-overview.d.ts.map +1 -0
  461. package/dist/sites/tools/sites-overview.js +209 -0
  462. package/dist/sites/tools/sites-overview.js.map +1 -0
  463. package/dist/sites/tools/sites-test.d.ts +2 -45
  464. package/dist/sites/tools/sites-test.d.ts.map +1 -1
  465. package/dist/sites/tools/sites-test.js +1 -56
  466. package/dist/sites/tools/sites-test.js.map +1 -1
  467. package/dist/sites/tools/use-site.d.ts +2 -45
  468. package/dist/sites/tools/use-site.d.ts.map +1 -1
  469. package/dist/sites/tools/use-site.js +31 -80
  470. package/dist/sites/tools/use-site.js.map +1 -1
  471. package/dist/sites/unknown-name-result.d.ts +10 -0
  472. package/dist/sites/unknown-name-result.d.ts.map +1 -0
  473. package/dist/sites/unknown-name-result.js +13 -0
  474. package/dist/sites/unknown-name-result.js.map +1 -0
  475. package/dist/skill-instructions.d.ts +7 -0
  476. package/dist/skill-instructions.d.ts.map +1 -0
  477. package/dist/skill-instructions.js +84 -0
  478. package/dist/skill-instructions.js.map +1 -0
  479. package/dist/skill-loader.d.ts +0 -102
  480. package/dist/skill-loader.d.ts.map +1 -1
  481. package/dist/skill-loader.js +4 -142
  482. package/dist/skill-loader.js.map +1 -1
  483. package/dist/skill-manifest.d.ts +0 -20
  484. package/dist/skill-manifest.d.ts.map +1 -1
  485. package/dist/skill-manifest.js +1 -21
  486. package/dist/skill-manifest.js.map +1 -1
  487. package/dist/skill-topics.d.ts +19 -0
  488. package/dist/skill-topics.d.ts.map +1 -0
  489. package/dist/skill-topics.js +181 -0
  490. package/dist/skill-topics.js.map +1 -0
  491. package/dist/tools/active-site.d.ts +16 -0
  492. package/dist/tools/active-site.d.ts.map +1 -0
  493. package/dist/tools/active-site.js +55 -0
  494. package/dist/tools/active-site.js.map +1 -0
  495. package/dist/tools/bind-guidance.d.ts +2 -72
  496. package/dist/tools/bind-guidance.d.ts.map +1 -1
  497. package/dist/tools/bind-guidance.js +2 -72
  498. package/dist/tools/bind-guidance.js.map +1 -1
  499. package/dist/tools/budgeted-table.d.ts +16 -0
  500. package/dist/tools/budgeted-table.d.ts.map +1 -0
  501. package/dist/tools/budgeted-table.js +203 -0
  502. package/dist/tools/budgeted-table.js.map +1 -0
  503. package/dist/tools/elements/bind-output-schema.d.ts +69 -0
  504. package/dist/tools/elements/bind-output-schema.d.ts.map +1 -0
  505. package/dist/tools/elements/bind-output-schema.js +64 -0
  506. package/dist/tools/elements/bind-output-schema.js.map +1 -0
  507. package/dist/tools/elements/builders.d.ts +1 -22
  508. package/dist/tools/elements/builders.d.ts.map +1 -1
  509. package/dist/tools/elements/builders.js +171 -133
  510. package/dist/tools/elements/builders.js.map +1 -1
  511. package/dist/tools/elements/index.d.ts +0 -26
  512. package/dist/tools/elements/index.d.ts.map +1 -1
  513. package/dist/tools/elements/index.js +0 -25
  514. package/dist/tools/elements/index.js.map +1 -1
  515. package/dist/tools/elements/read-output-schemas.d.ts +121 -0
  516. package/dist/tools/elements/read-output-schemas.d.ts.map +1 -0
  517. package/dist/tools/elements/read-output-schemas.js +225 -0
  518. package/dist/tools/elements/read-output-schemas.js.map +1 -0
  519. package/dist/tools/elements/write-output-schemas.d.ts +185 -0
  520. package/dist/tools/elements/write-output-schemas.d.ts.map +1 -0
  521. package/dist/tools/elements/write-output-schemas.js +98 -0
  522. package/dist/tools/elements/write-output-schemas.js.map +1 -0
  523. package/dist/tools/elements.d.ts +0 -11
  524. package/dist/tools/elements.d.ts.map +1 -1
  525. package/dist/tools/elements.js +0 -11
  526. package/dist/tools/elements.js.map +1 -1
  527. package/dist/tools/format/health-format.d.ts +42 -43
  528. package/dist/tools/format/health-format.d.ts.map +1 -1
  529. package/dist/tools/format/health-format.js +32 -55
  530. package/dist/tools/format/health-format.js.map +1 -1
  531. package/dist/tools/format/inspection-format.d.ts +4 -49
  532. package/dist/tools/format/inspection-format.d.ts.map +1 -1
  533. package/dist/tools/format/inspection-format.js +46 -114
  534. package/dist/tools/format/inspection-format.js.map +1 -1
  535. package/dist/tools/format/pages-format.d.ts +32 -69
  536. package/dist/tools/format/pages-format.d.ts.map +1 -1
  537. package/dist/tools/format/pages-format.js +115 -133
  538. package/dist/tools/format/pages-format.js.map +1 -1
  539. package/dist/tools/format/sources-format.d.ts +5 -39
  540. package/dist/tools/format/sources-format.d.ts.map +1 -1
  541. package/dist/tools/format/sources-format.js +57 -87
  542. package/dist/tools/format/sources-format.js.map +1 -1
  543. package/dist/tools/health-output-schemas.d.ts +82 -0
  544. package/dist/tools/health-output-schemas.d.ts.map +1 -0
  545. package/dist/tools/health-output-schemas.js +130 -0
  546. package/dist/tools/health-output-schemas.js.map +1 -0
  547. package/dist/tools/health.d.ts +2 -20
  548. package/dist/tools/health.d.ts.map +1 -1
  549. package/dist/tools/health.js +59 -184
  550. package/dist/tools/health.js.map +1 -1
  551. package/dist/tools/index.d.ts +1 -26
  552. package/dist/tools/index.d.ts.map +1 -1
  553. package/dist/tools/index.js +13 -33
  554. package/dist/tools/index.js.map +1 -1
  555. package/dist/tools/inspection.d.ts +21 -17
  556. package/dist/tools/inspection.d.ts.map +1 -1
  557. package/dist/tools/inspection.js +431 -268
  558. package/dist/tools/inspection.js.map +1 -1
  559. package/dist/tools/library.d.ts +118 -126
  560. package/dist/tools/library.d.ts.map +1 -1
  561. package/dist/tools/library.js +723 -456
  562. package/dist/tools/library.js.map +1 -1
  563. package/dist/tools/local-content/builders.d.ts +1 -17
  564. package/dist/tools/local-content/builders.d.ts.map +1 -1
  565. package/dist/tools/local-content/builders.js +105 -48
  566. package/dist/tools/local-content/builders.js.map +1 -1
  567. package/dist/tools/local-content/index.d.ts +0 -15
  568. package/dist/tools/local-content/index.d.ts.map +1 -1
  569. package/dist/tools/local-content/index.js +0 -14
  570. package/dist/tools/local-content/index.js.map +1 -1
  571. package/dist/tools/local-content/schemas.d.ts +31 -47
  572. package/dist/tools/local-content/schemas.d.ts.map +1 -1
  573. package/dist/tools/local-content/schemas.js +33 -62
  574. package/dist/tools/local-content/schemas.js.map +1 -1
  575. package/dist/tools/modules/builders.d.ts +4 -0
  576. package/dist/tools/modules/builders.d.ts.map +1 -0
  577. package/dist/tools/modules/builders.js +58 -0
  578. package/dist/tools/modules/builders.js.map +1 -0
  579. package/dist/tools/modules/index.d.ts +2 -0
  580. package/dist/tools/modules/index.d.ts.map +1 -0
  581. package/dist/tools/modules/index.js +2 -0
  582. package/dist/tools/modules/index.js.map +1 -0
  583. package/dist/tools/modules/module-id-schema.d.ts +4 -0
  584. package/dist/tools/modules/module-id-schema.d.ts.map +1 -0
  585. package/dist/tools/modules/module-id-schema.js +14 -0
  586. package/dist/tools/modules/module-id-schema.js.map +1 -0
  587. package/dist/tools/modules/module-type-fields.d.ts +10 -0
  588. package/dist/tools/modules/module-type-fields.d.ts.map +1 -0
  589. package/dist/tools/modules/module-type-fields.js +34 -0
  590. package/dist/tools/modules/module-type-fields.js.map +1 -0
  591. package/dist/tools/modules/modules-create.d.ts +59 -0
  592. package/dist/tools/modules/modules-create.d.ts.map +1 -0
  593. package/dist/tools/modules/modules-create.js +154 -0
  594. package/dist/tools/modules/modules-create.js.map +1 -0
  595. package/dist/tools/modules/modules-delete.d.ts +51 -0
  596. package/dist/tools/modules/modules-delete.d.ts.map +1 -0
  597. package/dist/tools/modules/modules-delete.js +180 -0
  598. package/dist/tools/modules/modules-delete.js.map +1 -0
  599. package/dist/tools/modules/modules-update.d.ts +43 -0
  600. package/dist/tools/modules/modules-update.d.ts.map +1 -0
  601. package/dist/tools/modules/modules-update.js +177 -0
  602. package/dist/tools/modules/modules-update.js.map +1 -0
  603. package/dist/tools/modules/placement-hints.d.ts +5 -0
  604. package/dist/tools/modules/placement-hints.d.ts.map +1 -0
  605. package/dist/tools/modules/placement-hints.js +90 -0
  606. package/dist/tools/modules/placement-hints.js.map +1 -0
  607. package/dist/tools/modules/placement-ways.d.ts +13 -0
  608. package/dist/tools/modules/placement-ways.d.ts.map +1 -0
  609. package/dist/tools/modules/placement-ways.js +61 -0
  610. package/dist/tools/modules/placement-ways.js.map +1 -0
  611. package/dist/tools/multi-items/builders.d.ts +1 -21
  612. package/dist/tools/multi-items/builders.d.ts.map +1 -1
  613. package/dist/tools/multi-items/builders.js +22 -67
  614. package/dist/tools/multi-items/builders.js.map +1 -1
  615. package/dist/tools/multi-items/index.d.ts +0 -15
  616. package/dist/tools/multi-items/index.d.ts.map +1 -1
  617. package/dist/tools/multi-items/index.js +0 -15
  618. package/dist/tools/multi-items/index.js.map +1 -1
  619. package/dist/tools/multi-items/item-container-map.d.ts +0 -35
  620. package/dist/tools/multi-items/item-container-map.d.ts.map +1 -1
  621. package/dist/tools/multi-items/item-container-map.js +0 -35
  622. package/dist/tools/multi-items/item-container-map.js.map +1 -1
  623. package/dist/tools/multi-items/output-schemas.d.ts +101 -0
  624. package/dist/tools/multi-items/output-schemas.d.ts.map +1 -0
  625. package/dist/tools/multi-items/output-schemas.js +74 -0
  626. package/dist/tools/multi-items/output-schemas.js.map +1 -0
  627. package/dist/tools/navigation/builders.d.ts +3 -0
  628. package/dist/tools/navigation/builders.d.ts.map +1 -0
  629. package/dist/tools/navigation/builders.js +25 -0
  630. package/dist/tools/navigation/builders.js.map +1 -0
  631. package/dist/tools/navigation/index.d.ts +2 -0
  632. package/dist/tools/navigation/index.d.ts.map +1 -0
  633. package/dist/tools/navigation/index.js +2 -0
  634. package/dist/tools/navigation/index.js.map +1 -0
  635. package/dist/tools/navigation/schemas.d.ts +16 -0
  636. package/dist/tools/navigation/schemas.d.ts.map +1 -0
  637. package/dist/tools/navigation/schemas.js +11 -0
  638. package/dist/tools/navigation/schemas.js.map +1 -0
  639. package/dist/tools/output-schema-metadata.d.ts +5 -0
  640. package/dist/tools/output-schema-metadata.d.ts.map +1 -0
  641. package/dist/tools/output-schema-metadata.js +83 -0
  642. package/dist/tools/output-schema-metadata.js.map +1 -0
  643. package/dist/tools/pages/builders.d.ts +1 -41
  644. package/dist/tools/pages/builders.d.ts.map +1 -1
  645. package/dist/tools/pages/builders.js +373 -359
  646. package/dist/tools/pages/builders.js.map +1 -1
  647. package/dist/tools/pages/header-transparency.d.ts +34 -0
  648. package/dist/tools/pages/header-transparency.d.ts.map +1 -0
  649. package/dist/tools/pages/header-transparency.js +85 -0
  650. package/dist/tools/pages/header-transparency.js.map +1 -0
  651. package/dist/tools/pages/index.d.ts +1 -19
  652. package/dist/tools/pages/index.d.ts.map +1 -1
  653. package/dist/tools/pages/index.js +1 -19
  654. package/dist/tools/pages/index.js.map +1 -1
  655. package/dist/tools/pages/schemas.d.ts +392 -224
  656. package/dist/tools/pages/schemas.d.ts.map +1 -1
  657. package/dist/tools/pages/schemas.js +605 -458
  658. package/dist/tools/pages/schemas.js.map +1 -1
  659. package/dist/tools/pages/write-output-schemas.d.ts +139 -0
  660. package/dist/tools/pages/write-output-schemas.d.ts.map +1 -0
  661. package/dist/tools/pages/write-output-schemas.js +138 -0
  662. package/dist/tools/pages/write-output-schemas.js.map +1 -0
  663. package/dist/tools/pages.d.ts +0 -11
  664. package/dist/tools/pages.d.ts.map +1 -1
  665. package/dist/tools/pages.js +0 -11
  666. package/dist/tools/pages.js.map +1 -1
  667. package/dist/tools/pool-resolve-helper.d.ts +0 -52
  668. package/dist/tools/pool-resolve-helper.d.ts.map +1 -1
  669. package/dist/tools/pool-resolve-helper.js +29 -104
  670. package/dist/tools/pool-resolve-helper.js.map +1 -1
  671. package/dist/tools/progress-phases.d.ts +0 -42
  672. package/dist/tools/progress-phases.d.ts.map +1 -1
  673. package/dist/tools/progress-phases.js +0 -44
  674. package/dist/tools/progress-phases.js.map +1 -1
  675. package/dist/tools/response-budget.d.ts +8 -0
  676. package/dist/tools/response-budget.d.ts.map +1 -0
  677. package/dist/tools/response-budget.js +7 -0
  678. package/dist/tools/response-budget.js.map +1 -0
  679. package/dist/tools/shared-schemas.d.ts +12 -43
  680. package/dist/tools/shared-schemas.d.ts.map +1 -1
  681. package/dist/tools/shared-schemas.js +42 -97
  682. package/dist/tools/shared-schemas.js.map +1 -1
  683. package/dist/tools/skill/builders.d.ts +3 -0
  684. package/dist/tools/skill/builders.d.ts.map +1 -0
  685. package/dist/tools/skill/builders.js +81 -0
  686. package/dist/tools/skill/builders.js.map +1 -0
  687. package/dist/tools/sources/builders.d.ts +1 -11
  688. package/dist/tools/sources/builders.d.ts.map +1 -1
  689. package/dist/tools/sources/builders.js +195 -173
  690. package/dist/tools/sources/builders.js.map +1 -1
  691. package/dist/tools/sources/index.d.ts +0 -17
  692. package/dist/tools/sources/index.d.ts.map +1 -1
  693. package/dist/tools/sources/index.js +0 -16
  694. package/dist/tools/sources/index.js.map +1 -1
  695. package/dist/tools/sources/read-output-schemas.d.ts +101 -0
  696. package/dist/tools/sources/read-output-schemas.d.ts.map +1 -0
  697. package/dist/tools/sources/read-output-schemas.js +105 -0
  698. package/dist/tools/sources/read-output-schemas.js.map +1 -0
  699. package/dist/tools/sources/source-name-argument.d.ts +17 -0
  700. package/dist/tools/sources/source-name-argument.d.ts.map +1 -0
  701. package/dist/tools/sources/source-name-argument.js +64 -0
  702. package/dist/tools/sources/source-name-argument.js.map +1 -0
  703. package/dist/tools/sources/write-output-schemas.d.ts +100 -0
  704. package/dist/tools/sources/write-output-schemas.d.ts.map +1 -0
  705. package/dist/tools/sources/write-output-schemas.js +61 -0
  706. package/dist/tools/sources/write-output-schemas.js.map +1 -0
  707. package/dist/tools/sources.d.ts +0 -12
  708. package/dist/tools/sources.d.ts.map +1 -1
  709. package/dist/tools/sources.js +0 -12
  710. package/dist/tools/sources.js.map +1 -1
  711. package/dist/tools/sparse-fields.d.ts +19 -194
  712. package/dist/tools/sparse-fields.d.ts.map +1 -1
  713. package/dist/tools/sparse-fields.js +212 -196
  714. package/dist/tools/sparse-fields.js.map +1 -1
  715. package/dist/tools/strict-input.d.ts +5 -0
  716. package/dist/tools/strict-input.d.ts.map +1 -0
  717. package/dist/tools/strict-input.js +55 -0
  718. package/dist/tools/strict-input.js.map +1 -0
  719. package/dist/tools/sublayout/builders.d.ts +1 -21
  720. package/dist/tools/sublayout/builders.d.ts.map +1 -1
  721. package/dist/tools/sublayout/builders.js +10 -40
  722. package/dist/tools/sublayout/builders.js.map +1 -1
  723. package/dist/tools/sublayout/index.d.ts +1 -11
  724. package/dist/tools/sublayout/index.d.ts.map +1 -1
  725. package/dist/tools/sublayout/index.js +1 -11
  726. package/dist/tools/sublayout/index.js.map +1 -1
  727. package/dist/tools/sublayout/output-schemas.d.ts +57 -0
  728. package/dist/tools/sublayout/output-schemas.d.ts.map +1 -0
  729. package/dist/tools/sublayout/output-schemas.js +51 -0
  730. package/dist/tools/sublayout/output-schemas.js.map +1 -0
  731. package/dist/tools/tool-builder/annotations.d.ts +1 -49
  732. package/dist/tools/tool-builder/annotations.d.ts.map +1 -1
  733. package/dist/tools/tool-builder/annotations.js +9 -49
  734. package/dist/tools/tool-builder/annotations.js.map +1 -1
  735. package/dist/tools/tool-builder/confirm-preview.d.ts +24 -0
  736. package/dist/tools/tool-builder/confirm-preview.d.ts.map +1 -0
  737. package/dist/tools/tool-builder/confirm-preview.js +37 -0
  738. package/dist/tools/tool-builder/confirm-preview.js.map +1 -0
  739. package/dist/tools/tool-builder/define.d.ts +6 -26
  740. package/dist/tools/tool-builder/define.d.ts.map +1 -1
  741. package/dist/tools/tool-builder/define.js +0 -25
  742. package/dist/tools/tool-builder/define.js.map +1 -1
  743. package/dist/tools/tool-builder/index.d.ts +3 -26
  744. package/dist/tools/tool-builder/index.d.ts.map +1 -1
  745. package/dist/tools/tool-builder/index.js +2 -25
  746. package/dist/tools/tool-builder/index.js.map +1 -1
  747. package/dist/tools/tool-builder/results.d.ts +4 -111
  748. package/dist/tools/tool-builder/results.d.ts.map +1 -1
  749. package/dist/tools/tool-builder/results.js +258 -250
  750. package/dist/tools/tool-builder/results.js.map +1 -1
  751. package/dist/tools/tool-builder/types.d.ts +5 -59
  752. package/dist/tools/tool-builder/types.d.ts.map +1 -1
  753. package/dist/tools/tool-builder/types.js +0 -7
  754. package/dist/tools/tool-builder/types.js.map +1 -1
  755. package/dist/tools/tool-builder.d.ts +0 -14
  756. package/dist/tools/tool-builder.d.ts.map +1 -1
  757. package/dist/tools/tool-builder.js +0 -14
  758. package/dist/tools/tool-builder.js.map +1 -1
  759. package/dist/uninstall-skill.d.ts +0 -41
  760. package/dist/uninstall-skill.d.ts.map +1 -1
  761. package/dist/uninstall-skill.js +0 -49
  762. package/dist/uninstall-skill.js.map +1 -1
  763. package/dist/uninstall.d.ts +0 -41
  764. package/dist/uninstall.d.ts.map +1 -1
  765. package/dist/uninstall.js +1 -29
  766. package/dist/uninstall.js.map +1 -1
  767. package/dist/update/update-check.d.ts +0 -26
  768. package/dist/update/update-check.d.ts.map +1 -1
  769. package/dist/update/update-check.js +0 -25
  770. package/dist/update/update-check.js.map +1 -1
  771. package/manifest.json +4 -5
  772. package/package.json +5 -5
  773. package/skills/yt-builder-mcp/SKILL.md +2958 -382
  774. package/dist/tools/elements/handlers-write.d.ts +0 -70
  775. package/dist/tools/elements/handlers-write.d.ts.map +0 -1
  776. package/dist/tools/elements/handlers-write.js +0 -700
  777. package/dist/tools/elements/handlers-write.js.map +0 -1
  778. package/dist/tools/elements/handlers.d.ts +0 -125
  779. package/dist/tools/elements/handlers.d.ts.map +0 -1
  780. package/dist/tools/elements/handlers.js +0 -571
  781. package/dist/tools/elements/handlers.js.map +0 -1
  782. package/dist/tools/elements/layout-hierarchy-rules.d.ts +0 -62
  783. package/dist/tools/elements/layout-hierarchy-rules.d.ts.map +0 -1
  784. package/dist/tools/elements/layout-hierarchy-rules.js +0 -154
  785. package/dist/tools/elements/layout-hierarchy-rules.js.map +0 -1
  786. package/dist/tools/elements/schema-validation.d.ts +0 -96
  787. package/dist/tools/elements/schema-validation.d.ts.map +0 -1
  788. package/dist/tools/elements/schema-validation.js +0 -249
  789. package/dist/tools/elements/schema-validation.js.map +0 -1
  790. package/dist/tools/elicitation.d.ts +0 -87
  791. package/dist/tools/elicitation.d.ts.map +0 -1
  792. package/dist/tools/elicitation.js +0 -100
  793. package/dist/tools/elicitation.js.map +0 -1
  794. package/dist/tools/format/elements-format.d.ts +0 -75
  795. package/dist/tools/format/elements-format.d.ts.map +0 -1
  796. package/dist/tools/format/elements-format.js +0 -324
  797. package/dist/tools/format/elements-format.js.map +0 -1
  798. package/dist/tools/format/source-schema-format.d.ts +0 -74
  799. package/dist/tools/format/source-schema-format.d.ts.map +0 -1
  800. package/dist/tools/format/source-schema-format.js +0 -208
  801. package/dist/tools/format/source-schema-format.js.map +0 -1
  802. package/dist/tools/layout-flatten.d.ts +0 -84
  803. package/dist/tools/layout-flatten.d.ts.map +0 -1
  804. package/dist/tools/layout-flatten.js +0 -129
  805. package/dist/tools/layout-flatten.js.map +0 -1
  806. package/dist/tools/local-content/handlers.d.ts +0 -54
  807. package/dist/tools/local-content/handlers.d.ts.map +0 -1
  808. package/dist/tools/local-content/handlers.js +0 -316
  809. package/dist/tools/local-content/handlers.js.map +0 -1
  810. package/dist/tools/multi-items/clean-implode-handler.d.ts +0 -37
  811. package/dist/tools/multi-items/clean-implode-handler.d.ts.map +0 -1
  812. package/dist/tools/multi-items/clean-implode-handler.js +0 -65
  813. package/dist/tools/multi-items/clean-implode-handler.js.map +0 -1
  814. package/dist/tools/multi-items/format/inspection-format.d.ts +0 -41
  815. package/dist/tools/multi-items/format/inspection-format.d.ts.map +0 -1
  816. package/dist/tools/multi-items/format/inspection-format.js +0 -64
  817. package/dist/tools/multi-items/format/inspection-format.js.map +0 -1
  818. package/dist/tools/multi-items/inspect-handler.d.ts +0 -97
  819. package/dist/tools/multi-items/inspect-handler.d.ts.map +0 -1
  820. package/dist/tools/multi-items/inspect-handler.js +0 -323
  821. package/dist/tools/multi-items/inspect-handler.js.map +0 -1
  822. package/dist/tools/multi-items/inspection-cache.d.ts +0 -85
  823. package/dist/tools/multi-items/inspection-cache.d.ts.map +0 -1
  824. package/dist/tools/multi-items/inspection-cache.js +0 -126
  825. package/dist/tools/multi-items/inspection-cache.js.map +0 -1
  826. package/dist/tools/pages/handlers-audit.d.ts +0 -129
  827. package/dist/tools/pages/handlers-audit.d.ts.map +0 -1
  828. package/dist/tools/pages/handlers-audit.js +0 -704
  829. package/dist/tools/pages/handlers-audit.js.map +0 -1
  830. package/dist/tools/pages/handlers-read.d.ts +0 -95
  831. package/dist/tools/pages/handlers-read.d.ts.map +0 -1
  832. package/dist/tools/pages/handlers-read.js +0 -929
  833. package/dist/tools/pages/handlers-read.js.map +0 -1
  834. package/dist/tools/pages/handlers-write.d.ts +0 -212
  835. package/dist/tools/pages/handlers-write.d.ts.map +0 -1
  836. package/dist/tools/pages/handlers-write.js +0 -418
  837. package/dist/tools/pages/handlers-write.js.map +0 -1
  838. package/dist/tools/pages/target.d.ts +0 -162
  839. package/dist/tools/pages/target.d.ts.map +0 -1
  840. package/dist/tools/pages/target.js +0 -239
  841. package/dist/tools/pages/target.js.map +0 -1
  842. package/dist/tools/sources/handlers-bind.d.ts +0 -148
  843. package/dist/tools/sources/handlers-bind.d.ts.map +0 -1
  844. package/dist/tools/sources/handlers-bind.js +0 -655
  845. package/dist/tools/sources/handlers-bind.js.map +0 -1
  846. package/dist/tools/sources/handlers.d.ts +0 -114
  847. package/dist/tools/sources/handlers.d.ts.map +0 -1
  848. package/dist/tools/sources/handlers.js +0 -285
  849. package/dist/tools/sources/handlers.js.map +0 -1
  850. package/dist/tools/sublayout/inspect-sublayout-handler.d.ts +0 -95
  851. package/dist/tools/sublayout/inspect-sublayout-handler.d.ts.map +0 -1
  852. package/dist/tools/sublayout/inspect-sublayout-handler.js +0 -181
  853. package/dist/tools/sublayout/inspect-sublayout-handler.js.map +0 -1
@@ -12,18 +12,20 @@ description: Drive the YOOtheme Pro Page Builder on WordPress or Joomla 5/6. Dis
12
12
  > WordPress and Joomla 5/6.
13
13
 
14
14
  This skill helps AI assistants drive the YOOtheme Pro Page Builder through the
15
- `@wootsup/yt-builder-mcp` server. The server catalogues 49 typed, scoped,
16
- idempotent tools (51 callable end-to-end with the two gateways). 41 of them
15
+ `@wootsup/yt-builder-mcp` server. The server catalogues 60 typed, scoped
16
+ tools (62 callable end-to-end with the two gateways). 49 of them
17
17
  advertised as first-class entries in `tools/list`
18
- (37 essential L1 forwards + 2 direct L3 entries + 2 gateways), and 10 advanced
18
+ (45 essential L1 forwards + 2 direct L3 entries + 2 gateways), and 13 advanced
19
19
  tools reachable through the gateways. The write-capable
20
- `yootheme_builder_advanced` gateway routes all 10; the read-only
20
+ `yootheme_builder_advanced` gateway routes all 13; the read-only
21
21
  `yootheme_builder_advanced_read` gateway routes only the read-only subset
22
- (page_get_schema, inspect_sublayout, layout_query, local_content_types,
23
- local_content_read, pages_audit) and is itself
22
+ (inspect_sublayout, layout_query, local_content_types,
23
+ local_content_read, pages_audit, heading_outline, navigation) and is itself
24
24
  read-only-annotated, so MCP hosts can auto-allow pure inspection without a
25
- per-call write-approval prompt. This 3-lane split keeps `tools/list` well below
26
- the ~40-tool Cursor cap while every catalogued tool stays fully reachable.
25
+ per-call write-approval prompt. This 3-lane split exists because Cursor (and
26
+ similar hosts) cap a server at roughly 40 first-class `tools/list` entries —
27
+ it keeps the advertised surface small while every catalogued tool stays
28
+ fully reachable through the gateways.
27
29
 
28
30
  ## How to use this MCP server
29
31
 
@@ -54,8 +56,8 @@ Some clients (notably Claude Desktop with the `.mcpb` bundle) expose **two**
54
56
  entries when the user types "YT Builder MCP" into the picker:
55
57
 
56
58
  - **`YT Builder MCP for YOOtheme Pro (unofficial)`**: the MCP **server**.
57
- Provides the 41 first-class tools (37 essential, 2 direct, 2 gateways).
58
- The `yootheme_builder_advanced` gateway routes all 10 advanced tools; the
59
+ Provides the 49 first-class tools (45 essential, 2 direct, 2 gateways).
60
+ The `yootheme_builder_advanced` gateway routes all 13 advanced tools; the
59
61
  read-only `yootheme_builder_advanced_read` gateway routes the read-only
60
62
  subset without a write-approval prompt.
61
63
  - **`Von YT Builder MCP for YOOtheme Pro`**: the bundled **skill** (this
@@ -82,39 +84,40 @@ The server exposes:
82
84
  - **2 direct top-level tools**, always callable, always in `tools/list`:
83
85
  `yootheme_builder_health` and `yootheme_builder_diagnose`. These are
84
86
  the "the gateway itself might be broken" escape hatch.
85
- - **37 essential forwarded tools**: common reads + the most-used writes
86
- (pages_list, pages_resolve, pages_create, pages_delete, page_dynamize, scaffold_section,
87
- get_etag, element_list / add / update_settings / get / move / clone / delete,
87
+ - **45 essential forwarded tools**: common reads + the most-used writes
88
+ (pages_list, pages_resolve, pages_create, pages_delete, pages_set_status, pages_restore, page_generate_dynamic, scaffold_section,
89
+ get_etag, get_skill, element_list / add / update_settings / set_status / get / move / clone / delete,
88
90
  page_get_layout, sources_list, source_get_schema, element_types_list, element_type_get_schema,
89
91
  template_summary, inspect_multi_items_binding, library_browse, library_get, library_insert,
90
- image_search, image_import,
92
+ image_search, image_import, modules_delete,
91
93
  sites_list, sites_test,
92
- use_site, the bind→verify→save→publish chain element_bind_source /
93
- element_get_binding / page_save / page_publish, and the archive-rebind /
94
- refactor surface render_preview / element_set_source / rename_source /
94
+ use_site, the bind→verify→save→publish→unbind chain element_bind_source /
95
+ element_get_binding / page_save / page_publish / element_unbind_source, and the archive-rebind /
96
+ refactor surface render_preview / element_set_source / repair_source_references /
95
97
  template_set_type). Always advertised in
96
98
  `tools/list` so AI clients see them first-class. `element_get_binding`
97
99
  (B4) is L1 so the verify half of a bind is a direct call, not a
98
100
  gateway hop. `source_get_schema` (U4-L1) is L1 so the archive/category
99
101
  binding entry-point — introspect a source's query methods + relations +
100
102
  the current-term archive-context convention before binding — is a direct
101
- call too. `render_preview`, `element_set_source`, `rename_source` and
102
- `template_set_type` (Glöckle HANDOFF-4/5) are L1 so the core write/refactor +
103
+ call too. `render_preview`, `element_set_source`, `repair_source_references`
104
+ and `template_set_type` (customer HANDOFF-4/5) are L1 so the core write/refactor +
103
105
  render-verify tools an agent reaches for after a taxonomy/CPT rename — set a
104
106
  precomposed source at a pointer, render-verify the saved page is not silently
105
- blank, rename a source/field template-wide, re-type an orphaned archive/single
107
+ blank, repair a source/field binding REFERENCE template-wide (the registered
108
+ source itself is untouched), re-type an orphaned archive/single
106
109
  template — are direct calls, not undiscoverable gateway hops. `scaffold_section`
107
110
  creates a valid
108
111
  section → row → column hull in one call — the easiest correct way to
109
112
  start a layout block (content placed anywhere else is rejected by the
110
- structural-hierarchy guard). `library_browse` (Task 2, Layout Library) is L1
113
+ structural-hierarchy guard). `library_browse` (Layout Library) is L1
111
114
  so browsing the YOOtheme Pro Library catalog — pre-designed page layouts and
112
115
  element presets, filterable by topic/type/role/website/element_type/query —
113
116
  is a direct call before hand-building a section from scratch. `library_get`
114
- (Task 3, Layout Library) is L1 so fetching a SINGLE browsed item's full tree
117
+ (Layout Library) is L1 so fetching a SINGLE browsed item's full tree
115
118
  + derived section list (`{id, name, path, element_count}` per top-level
116
119
  section) — the natural next call once `library_browse` returns an `id` —
117
- is a direct call too. `library_insert` (Task 6, Layout Library) is L1 so
120
+ is a direct call too. `library_insert` (Layout Library) is L1 so
118
121
  composing + persisting a browsed/fetched item (layout, section, or preset)
119
122
  onto a page or existing template — target `new_page` / `{template_id,
120
123
  element_path}` / `sublayout` — through the SAME hardened write chain as
@@ -124,21 +127,98 @@ The server exposes:
124
127
  the chosen photo into the site's Media Library, are direct calls — the
125
128
  step that turns a placeholder-filled skeleton into a finished-looking
126
129
  page (see "Fill image slots with real on-topic photos" under Layout
127
- Library).
128
- - **10 advanced captured tools**: everything else (page_get_schema,
129
- element_unbind_source, clean_implode_directives,
130
+ Library). `get_skill` (Design E2, 2026-08-10) is L1 because it is the
131
+ Rettungsanker back to this document: the connect-time `instructions`
132
+ field now carries only a short curated preamble instead of the full
133
+ SKILL.md, so without this tool being directly callable a cold agent
134
+ would have no way to reach the rest of the guide. Call it without
135
+ `topic` for the list of chapters, or with `topic: "<slug>"` for one
136
+ chapter verbatim. `element_unbind_source` is L1 so the write half of
137
+ the bind pair is discoverable the same way its siblings
138
+ (`element_bind_source`, `element_get_binding`, `element_set_source`)
139
+ already are — a customer looking for "how do I remove a binding" could
140
+ otherwise not find it by name in `tools/list`, nor from any of the
141
+ other three tools' own descriptions. `pages_restore` is L1 for the same
142
+ discoverability reason: two cold-customer runs each measured the same gap
143
+ from opposite ends — a trashed page/article could not be found, and even
144
+ once found there was no correct way back — and one caller searched
145
+ explicitly for "restore"/"untrash"/"recover" and found nothing, because a
146
+ gateway-only placement is discoverable only to a caller who already knows
147
+ the tool exists.
148
+ - **13 advanced captured tools**: everything else (
149
+ clean_implode_directives,
130
150
  `inspect_sublayout`, `layout_query`, `library_save_as`, the two Local
131
151
  Content Read tools `local_content_types` / `local_content_read`,
132
- `pages_audit`, and `library_delete` — the INVERSE of `library_save_as`).
152
+ `pages_audit`, `library_delete` — the INVERSE of `library_save_as`
153
+ `heading_outline`, a real doc-order H1-H6 outline of one page,
154
+ `navigation`, the site's menus as a parent -> children tree,
155
+ `header_transparency_set`, the transparent-header inspect/repair,
156
+ `modules_update`, the widget/module write surface, and
157
+ `modules_create`, the widget/module CREATE surface).
158
+ `modules_delete`, the widget/module DELETE surface, is first-class (L1) —
159
+ not one of these 14 (live-test-befunde, 2026-08-11): the one
160
+ gateway-only destructive tool that removes data OUTSIDE the layout tree (a
161
+ Joomla module row + its `#__modules_menu` assignments), so a host can gate
162
+ it individually instead of only via the all-or-nothing "Advanced Tool
163
+ Gateway" permission.
164
+ To remove a widget/module — the clean-up verb create/update lacked, so a
165
+ probe no longer leaves permanent litter on a customer site — pass the widget
166
+ `id` and PREVIEW first (`confirm` defaults FALSE and the call deletes
167
+ NOTHING, answering with the row plus `usage.element_refs` /
168
+ `usage.assigned_pages`):
169
+ `yootheme_builder_modules_delete({ id: "custom_html-2" })`,
170
+ then repeat with `confirm: true` AND an `intent` — a call missing or
171
+ misnaming it is refused with a 400 naming exactly which intents are
172
+ possible for the module's CURRENT state, each with its own
173
+ `effect`/`reversible`/`native_name`, so calling with neither `confirm` nor
174
+ `intent` doubles as that discovery step.
175
+ WHICH INTENTS ARE OFFERED DEPENDS ON THE PLATFORM AND STATE, because each
176
+ arm mirrors its own CMS rather than inventing a common one. `intent:
177
+ "permanent"` is offered on BOTH platforms and deletes the row for good —
178
+ irreversible, and `modules_create` makes a NEW id with default fields
179
+ rather than restoring this one. `intent: "trash"` exists ONLY on JOOMLA
180
+ and is REVERSIBLE: it sets `published = -2` — the module manager offers
181
+ `Trash` and no direct `Delete` at all, and its `Delete` button exists only
182
+ in the *Trashed* view. The row and its `#__modules_menu` assignments
183
+ survive and Joomla restores both; it does stop rendering at once (Joomla
184
+ loads modules with `published = 1`), so an element bound to it shows
185
+ nothing meanwhile. Joomla offers `"permanent"` directly too — trashing
186
+ first is no longer required to remove a module for good — but once a
187
+ module is ALREADY trashed, `"trash"` stops being offered (only
188
+ `"permanent"` empties it from there). On WORDPRESS widgets have no trash
189
+ step at all (`widgets.php` offers deactivate-by-drag and an immediate
190
+ `Delete` link, not a recoverable one), so only `"permanent"` is ever
191
+ offered there and the confirmed call is always the irreversible one. Read
192
+ the MEASURED `trashed` / `deleted` flags in the response rather than
193
+ assuming which happened.
194
+ Only wanted it off the page, not gone? That is not a removal at all, and
195
+ `modules_delete` is the wrong tool: `modules_update({ id, active: false })`
196
+ parks the widget in WordPress's **Inactive Widgets** area (settings stay
197
+ intact, dragging it back into a widget area restores it) or unpublishes the
198
+ module on Joomla (`published = 0`, position kept) — the same wording the
199
+ 400 response's `notes` gives when you call `modules_delete` without an
200
+ `intent`.
201
+ A layout element that references the id DIRECTLY
202
+ (`usage.element_refs` with `via:"element"`) BLOCKS the IRREVERSIBLE step with
203
+ a 409 naming every stranded location, until you either remove those elements
204
+ with `element_delete` or pass `acknowledge_element_refs`: true. Joomla's
205
+ reversible `"trash"` step is never refused — a restore puts the reference
206
+ back in working order — so on that platform the 409 can only come from
207
+ `intent: "permanent"`. An INDIRECT
208
+ reference (`via:"position"`, a `module_position` element naming the POSITION
209
+ this module sits in) never blocks, because it points at a position rather
210
+ than at this module and stays valid for whatever else occupies it.
211
+ `assigned_pages` never blocks either: a Joomla module's `#__modules_menu`
212
+ rows are its OWN rows and go with it.
133
213
  Reachable through one gateway tool:
134
214
  `yootheme_builder_advanced({ tool: "<name>", arguments: { ... } })`. For
135
215
  example, to inspect a panel/grid_item `< SUBLAYOUT`:
136
- `yootheme_builder_advanced({ tool: "yootheme_builder_inspect_sublayout", arguments: { template_id, element_path } })`.
216
+ `yootheme_builder_advanced({ tool: "yootheme_builder_inspect_sublayout", arguments: { layout_id, element_path } })`.
137
217
  And to find elements by a structured filter (G5 — no expression language,
138
- AND-combined `type`/`name_matches`/`has_binding`/`max_depth`/`under_section`):
139
- `yootheme_builder_advanced({ tool: "yootheme_builder_layout_query", arguments: { template_id, type: "headline", has_binding: false, under_section: "Hero" } })`
218
+ AND-combined `element_type`/`name_matches`/`has_binding`/`max_depth`/`under_section`):
219
+ `yootheme_builder_advanced({ tool: "yootheme_builder_layout_query", arguments: { layout_id, element_type: "headline", has_binding: false, under_section: "Hero" } })`
140
220
  (it's read-only, so the read gateway `yootheme_builder_advanced_read({ tool: "yootheme_builder_layout_query", … })` works too).
141
- And to save a page/subtree INTO "My Layouts" (Task 7 — the reverse of
221
+ And to save a page/subtree INTO "My Layouts" (the reverse of
142
222
  `library_insert`; omit `element_path` to save the whole page):
143
223
  `yootheme_builder_advanced({ tool: "yootheme_builder_library_save_as", arguments: { source: { template_id, element_path }, name: "Hero Section" } })`.
144
224
  It flags `invalid_source_possible: true` when the saved subtree carries
@@ -156,21 +236,75 @@ The server exposes:
156
236
  `yootheme_builder_advanced_read({ tool: "yootheme_builder_pages_audit", arguments: { checks: ["seo", "structure"] } })`
157
237
  (the write gateway routes it too, if you prefer one entry point:
158
238
  `yootheme_builder_advanced({ tool: "yootheme_builder_pages_audit" })`).
159
-
160
- The core write/refactor + render-verify tools the Glöckle HANDOFF-4/5 ask for
239
+ And to get a real, doc-order H1-H6 outline of ONE page (each heading with
240
+ its source element and TEXT, plus the alt-status of every image) —
241
+ read-only STRUCTURE inspection, not findings:
242
+ `yootheme_builder_advanced_read({ tool: "yootheme_builder_heading_outline", arguments: { layout_id } })`
243
+ (or via the write gateway: `yootheme_builder_advanced({ tool: "yootheme_builder_heading_outline" })`).
244
+ And to see the site's Haupt- + Sub-Menüstruktur as a parent -> children
245
+ TREE (not a flat list) — for "which page sits under which menu item"
246
+ (product pages usually live in the main menu), each item carrying a
247
+ `page_role` facet (home/landing/product/legal/thankyou/blog/mockup/
248
+ template/generic, the SAME vocabulary `pages_list` uses) and, when
249
+ derivable at zero extra cost, a `target_page` (`page:<id>` /
250
+ `article:<id>`) ready for `page_get_layout`:
251
+ `yootheme_builder_advanced_read({ tool: "yootheme_builder_navigation" })`
252
+ (or via the write gateway: `yootheme_builder_advanced({ tool: "yootheme_builder_navigation" })`).
253
+ Pass `menu: "Footer"` to narrow to one menu by name.
254
+ And to inspect or repair the transparent header — the navigation pulled over
255
+ the first section — a WRITE, so the write gateway only. `dry_run` defaults to
256
+ TRUE, so this call reports the current state, the theme-declared
257
+ `--uk-inverse`, `risk_class`, `confidence` and `would_change` without
258
+ touching anything:
259
+ `yootheme_builder_advanced({ tool: "yootheme_builder_header_transparency_set", arguments: { layout_id } })`
260
+ Repeat with `mode: "auto", dry_run: false, etag` to write the theme-declared
261
+ colour. See "Transparent header (the exception — never a default)".
262
+ And to update a widget's (WordPress) or module's (Joomla) title,
263
+ position, or theme_overlay — a WRITE, so the write gateway only.
264
+ `dry_run` defaults to TRUE, so this call previews the diff without
265
+ touching anything:
266
+ `yootheme_builder_advanced({ tool: "yootheme_builder_modules_update", arguments: { id: "custom_html-2", title: "New Title" } })`
267
+ Repeat with `dry_run: false` to persist. See "Now change what you found —
268
+ `modules_update`" above for the platform-specific field split.
269
+ And to CREATE a new widget/module — one of exactly five known types
270
+ (WordPress custom_html/text/block; Joomla mod_custom/mod_menu) — a WRITE,
271
+ so the write gateway only. `placement` is REQUIRED (`position` /
272
+ `module_element` / `position_element` — how the module reaches a page); a
273
+ call without it creates NOTHING and returns BOTH halves of the answer:
274
+ `ways`, every way for this platform with its reach and a summary, and
275
+ `notes`, what `modules_create` can build and when you need no module at all.
276
+ Which ways additionally need a `position` is a PLATFORM fact: all three on
277
+ WordPress, two on Joomla. It is never defaulted on either. `dry_run` defaults to TRUE, so once `placement` is chosen this call
278
+ previews (type, position, fields) without creating anything:
279
+ `yootheme_builder_advanced({ tool: "yootheme_builder_modules_create", arguments: { type: "custom_html", title: "New Widget", content: "<p>Hi</p>", placement: "module_element", position: "builder-1" } })`
280
+ Repeat with `dry_run: false` to persist — the response's `row` is the full
281
+ `local_content_read(kind:'modules')` shape and `next_step` is the exact
282
+ follow-up call **for the placement you chose**, which is an `element_add`
283
+ for only two of the three: `module_element` and `position_element` get one,
284
+ `position` gets `modules_update{assignment}` on Joomla and a read-only
285
+ `local_content_read{kind:"modules", include_usage:true}` on WordPress — the
286
+ widget is already live site wide (no write to narrow it with), but add
287
+ `verify_on_urls` to that same call to PROVE guest-visible rendering rather
288
+ than trust `active`/`builder_visible` alone. See "Create → wire in (the
289
+ two-step recipe)" below.
290
+
291
+ The core write/refactor + render-verify tools the customer HANDOFF-4/5 ask for
161
292
  are now **L1 — call them directly by name**, not through a gateway:
162
293
  - To render-check a saved page over the authenticated channel (F5+N5 — does
163
294
  it render, with how many items, or is it silently blank? where an external
164
295
  cURL 401s behind staging Basic-Auth), call the now-L1
165
- `yootheme_builder_render_preview({ template_id })` directly — or address it by
296
+ `yootheme_builder_render_preview({ layout_id })` directly — or address it by
166
297
  a public URL with `yootheme_builder_render_preview({ url })` (the URL is
167
298
  resolved to its template for you, exactly like `page_get_layout`), so if you
168
299
  only hold a resolved category/archive URL you can render-check it in ONE call
169
- without first converting it to a template_id. It takes no post/term target: it
300
+ without first converting it to a layout_id. It takes no post/term target: it
170
301
  renders YOOtheme's implicit fallback context, so a source-bound template comes
171
302
  back non-empty. Treat a non-empty render as the success signal, not "blank
172
- despite a binding". `item_count` counts the TOP-LEVEL repeat-source rows only
173
- (a static grid/list nested in a repeated card no longer inflates it). Pass
303
+ despite a binding". `rendered_item_wrapper_count` counts the TOP-LEVEL repeat-item
304
+ wrappers the render emitted, BOUND OR NOT (a static grid/list nested in a repeated
305
+ card does not inflate it); `source_item_counts` is the per-source ROW count,
306
+ and `unresolved_sources` names any bound source the registry does not recognise
307
+ (the render still succeeds, only that source contributes nothing). Pass
174
308
  `expect: "<substring>"` to confirm a specific edit reached the HTML in one call
175
309
  (the response adds `matched` + `snippet`).
176
310
  - To surgically SET a precomposed `source` + the native item-level
@@ -178,30 +312,52 @@ The server exposes:
178
312
  leaving the rest of the layout byte-unchanged (F2 — the one-call source edit
179
313
  bind_source can't do because it cannot write `source_extended`), call the
180
314
  now-L1
181
- `yootheme_builder_element_set_source({ template_id, element_path, source: { query: { name: "categoryFaqs.taxonomyCategoryFaq", field: { name: "faq" } } }, source_extended: { props: { title: { query: { name: "#node" }, name: "title" } } }, etag })` directly.
182
- - To template-wide RENAME a source/type/field name across EVERY binding in
183
- one ETag-locked write — after a taxonomy/CPT rename (faq→help) breaks every
184
- bound layout, preserving each binding's arguments + props + field_mappings
185
- (N1 — instead of a whole-layout page_save), call the now-L1
186
- `yootheme_builder_rename_source({ template_id, from: "categoryFaqs", to: "categoryHelps", source_map: { taxonomyCategoryFaq: "taxonomyCategoryHelp", faq: "help" }, field_map: { faq_title: "help_title" }, etag })` directly.
315
+ `yootheme_builder_element_set_source({ layout_id, element_path, source: { query: { name: "categoryFaqs.taxonomyCategoryFaq", field: { name: "faq" } } }, source_extended: { props: { title: { query: { name: "#node" }, name: "title" } } }, etag })` directly.
316
+ - To template-wide REPAIR binding REFERENCES to a source/type/field name
317
+ across EVERY binding in one template, in one ETag-locked write — after
318
+ that name changed OUTSIDE the builder (a taxonomy/CPT rename such as
319
+ faq→help, a re-published flow), preserving each binding's arguments +
320
+ props + field_mappings this does NOT rename the source itself,
321
+ `sources_list` stays unchanged; call the now-L1
322
+ `yootheme_builder_repair_source_references({ layout_id, from: "categoryFaqs", to: "categoryHelps", source_map: { taxonomyCategoryFaq: "taxonomyCategoryHelp", faq: "help" }, field_map: { faq_title: "help_title" }, etag })` directly.
323
+ `to` (and each `source_map` value landing at the top-level source
324
+ position) must be a currently registered source name — an unregistered
325
+ target is refused with the nearest matches, unless you pass
326
+ `allow_unregistered:true` for the case where the target only exists
327
+ after a flow republish.
328
+ That write is per-template, so find WHICH templates carry the binding FIRST —
329
+ `yootheme_builder_layout_query({ source_name: "categoryFaqs" })` with **no**
330
+ `layout_id` sweeps the whole site and answers per-source counts plus the
331
+ templates each one lives on (add `max_paths` for the individual element
332
+ paths). One call instead of one per template.
333
+ The sweep recognises a binding by the SAME rule `has_binding` uses per
334
+ template, so an unfiltered call splits its `total_bindings` three ways and
335
+ only the first is a source: `sources` (real source names — the ones
336
+ `sources_list` also knows), `inherited_context_bindings` (a `#`-prefixed
337
+ YOOtheme Pro self-reference such as `#parent`, i.e. a binding that INHERITS
338
+ the row context of an iterating ancestor — on real sites this is often the
339
+ majority of the count and it is NOT a source), and `unnamed_bindings`
340
+ (bound via field mappings or a query-field selector only, so there is no
341
+ name for `repair_source_references` to match). Repair covers the first
342
+ bucket; the third names the templates you still have to open by hand.
187
343
  - To reassign which archive/single a THEME template applies to — set its
188
344
  `type` condition (the value pages_list shows) in one ETag-locked write, the
189
345
  step a taxonomy rename leaves orphaned (N3 — re-type
190
346
  `taxonomy-category_faq` → `taxonomy-category_help`), call the now-L1
191
- `yootheme_builder_template_set_type({ template_id, type: "taxonomy-category_help", etag })` directly.
347
+ `yootheme_builder_template_set_type({ layout_id, type: "taxonomy-category_help", etag })` directly.
192
348
 
193
349
  To introspect a source's query methods + relations + the current-term
194
350
  archive-context convention (U4-L1 — turns a ~25-call YOOtheme code-dive into
195
351
  one call before binding on an archive/category template), call the now-L1
196
352
  `yootheme_builder_source_get_schema({ name: "categoryFaqs" })` directly.
197
353
  - **2 gateway tools**: `yootheme_builder_advanced` (write-capable; can route
198
- into delete/unbind, so hosts gate it) and `yootheme_builder_advanced_read`
199
- (read-only; routes ONLY the read-only advanced subset — page_get_schema,
354
+ into delete, so hosts gate it) and `yootheme_builder_advanced_read`
355
+ (read-only; routes ONLY the read-only advanced subset —
200
356
  inspect_sublayout, layout_query, local_content_types, local_content_read,
201
- pages_audit —
357
+ pages_audit, heading_outline, navigation
202
358
  and is read-only-annotated so hosts auto-allow it). Prefer the read gateway
203
359
  for pure inspection:
204
- `yootheme_builder_advanced_read({ tool: "yootheme_builder_page_get_schema", arguments: { template_id } })`.
360
+ `yootheme_builder_advanced_read({ tool: "yootheme_builder_pages_audit", arguments: { site_id } })`.
205
361
  Discover the site's NATIVE content the same way — the landscape then the records:
206
362
  `yootheme_builder_advanced_read({ tool: "yootheme_builder_local_content_types" })`
207
363
  then `yootheme_builder_advanced_read({ tool: "yootheme_builder_local_content_read", arguments: { kind: "terms", taxonomy: "category" } })`.
@@ -209,9 +365,9 @@ The server exposes:
209
365
  `yootheme_builder_advanced({ tool: "yootheme_builder_local_content_types" })` and
210
366
  `yootheme_builder_advanced({ tool: "yootheme_builder_local_content_read", arguments: { kind: "entities", type: "post" } })`.)
211
367
 
212
- `tools/list` therefore advertises 41 names (37 + 2 + 2). That's 37 L1
213
- essentials + 2 L3 direct + 2 gateways. The total callable surface is 51
214
- (41 advertised + 10 advanced reachable through the gateways). If the AI
368
+ `tools/list` therefore advertises 49 names (45 + 2 + 2). That's 45 L1
369
+ essentials + 2 L3 direct + 2 gateways. The total callable surface is 62
370
+ (49 advertised + 13 advanced reachable through the gateways). If the AI
215
371
  client reports "tool not found", you are almost certainly calling an
216
372
  advanced tool by its raw name. Wrap it in
217
373
  `yootheme_builder_advanced({ tool, arguments })` (or the read gateway for
@@ -222,10 +378,14 @@ read-only tools) instead.
222
378
  The server surfaces the connected site's URLs in two places so you never have to
223
379
  guess where the agent is pointing:
224
380
 
225
- - **`yootheme_builder_health` (Bearer-authenticated) and
226
- `yootheme_builder_diagnose`** return `site_url` and `home_url` for the
227
- connected install. Call one of them when the user asks "which site are you
228
- on?" or before deep-linking the user back into wp-admin / Joomla administrator.
381
+ - **`yootheme_builder_health` (Bearer-authenticated)** returns `site_url` for
382
+ the connected install, and on WordPress `home_url` as well, because WordPress
383
+ distinguishes the two; Joomla has a single site URL and sends only
384
+ `site_url`. Call it when the user asks "which site are you on?" or before
385
+ deep-linking the user back into wp-admin / Joomla administrator.
386
+ `yootheme_builder_diagnose` carries `site_url` too, so read it there when you
387
+ are already triaging an auth failure — but **only** `site_url`. `home_url` is
388
+ on health alone.
229
389
  - **`yootheme_builder_pages_list`** returns per-template `frontend_url`,
230
390
  `frontend_url_template`, and `frontend_url_description` columns when the
231
391
  host plugin can resolve them. Use these when the user asks for a verification
@@ -233,9 +393,54 @@ guess where the agent is pointing:
233
393
  template"): find the matching row, return `frontend_url` (resolved) or
234
394
  `frontend_url_template` (with placeholders the user fills in).
235
395
 
396
+ ### `available_endpoints` is request-ready, on both platforms
397
+
398
+ `yootheme_builder_health` also returns `available_endpoints`: every REST path the
399
+ host plugin actually serves. On **both** WordPress and Joomla this list is
400
+ derived from the **live route registration** — WordPress enumerates
401
+ `rest_get_server()->get_routes()`, Joomla reads the ApiRouter that matched the
402
+ request — so it can never drift from what the server accepts.
403
+
404
+ Use it as-is when documenting or probing a site's surface: **every path is
405
+ request-ready as printed**, relative to the platform's REST root
406
+ (`/wp-json` on WordPress, `/api/index.php` on Joomla). Captures appear as
407
+ readable placeholders (`<templateId>`, `<path>`) that you substitute.
408
+
409
+ Do not hand-write a Joomla endpoint list from memory: the Joomla paths carry a
410
+ `yt-builder-mcp` segment (`/v1/yt-builder-mcp/pages/audit`, not `/v1/pages/audit`)
411
+ and the router rejects the short form.
412
+
413
+ If the field is **missing** from a health answer, the host could not read its own
414
+ route table for this request — read that as "not knowable here", never as "this
415
+ install serves nothing". It is omitted rather than sent as an empty list for
416
+ exactly that reason.
417
+
236
418
  Treat `frontend_url: null` as "host plugin could not resolve a public URL for
237
419
  this template". Surface that honestly rather than fabricating one.
238
420
 
421
+ ### What a write clears, and what it does not
422
+
423
+ `page_save` and `page_publish` say they flush caches. They flush **the caches
424
+ this plugin owns**, and that is a narrower set than "the caches on this site":
425
+
426
+ | layer | who clears it after a write |
427
+ |---|---|
428
+ | YOOtheme's own render / schema cache | this plugin |
429
+ | the option entries this plugin writes | this plugin |
430
+ | the CMS object-cache entry for the page itself | **the CMS core**, on its own post/article save — not this plugin |
431
+ | a full-page cache or CDN (WP Rocket, LiteSpeed, Cloudflare, a reverse proxy) | **nobody, from here** |
432
+
433
+ The last row is the one that reaches visitors. **No full-page-cache or CDN
434
+ plugin was measured**; what was measured is that the CMS core hooks such
435
+ plugins register on do fire during a write. That is an indication, not a
436
+ guarantee — and on Joomla there is no equivalent link at all.
437
+
438
+ So after a publish that must be visible immediately: purge the page cache / CDN
439
+ yourself, then prove it with `yootheme_builder_fetch_public_url`, which fetches
440
+ the page as an anonymous visitor and reports whether a cache served the answer.
441
+ `render_preview` cannot settle this — it renders the stored layout server-side
442
+ and never touches the visitor's path.
443
+
239
444
  ## Resolve a URL back to the template that renders it
240
445
 
241
446
  The two sections above go template → URL (forward). To go the OTHER way — the
@@ -244,18 +449,35 @@ complementary tools.
244
449
 
245
450
  **`yootheme_builder_pages_resolve({ url })` — the direct reverse lookup.** Paste
246
451
  any public frontend URL and it returns
247
- `{ template_id, post_id, type, frontend_url, context, resolved_via }` (or a
452
+ `{ layout_id, post_id, type, frontend_url, context, resolved_via }` (or a
248
453
  `url_not_resolvable` when it genuinely maps to no template — it degrades to an
249
454
  honest null rather than guessing a wrong one). It handles **SEF / pretty
250
455
  permalinks on BOTH WordPress and Joomla** (not just raw `?option=…&id=…` query
251
- URLs), including deep sub-category paths like `/matches/first-men-s-team`. For a
456
+ URLs), including deep sub-category paths like `/matches/first-men-s-team` and
457
+ the deep ARTICLE paths a Joomla site prints for an article routed below a
458
+ category menu item (`/company/blog/9-news/36-my-article`) — the id and the alias
459
+ in that last segment are both checked against the article, so an address whose
460
+ alias belongs to something else is refused rather than guessed. For a
252
461
  **category or term URL it returns the SPECIFICALLY-ASSIGNED template** — the one
253
462
  whose YOOtheme `query.catid` (Joomla) / `query.terms` (WordPress) binds THAT
254
463
  category/term, honoring **include-child-categories** (a template assigned to a
255
464
  parent category with child-inclusion covers its subcategories; the nearest
256
465
  specific assignment wins). Only when nothing is specifically assigned does it
257
466
  fall back to the governing generic theme template. Then feed the returned
258
- `template_id` straight into `page_get_layout` / `element_list` / the write tools.
467
+ `layout_id` straight into `page_get_layout` / `element_list` / the write tools.
468
+
469
+ **The site's own front page resolves too, in every spelling** — bare host,
470
+ trailing slash, `…/index.php`, and the subdirectory forms of an install served
471
+ from one (`https://example.com/joomla/index.php`). It resolves to whatever
472
+ renders it, which on Joomla is usually the single article the default menu item
473
+ points at. `pages_list` hands out one of those spellings as the front page's
474
+ `frontend_url`, so an address this server issues is one it accepts.
475
+
476
+ A front page that resolves to NOTHING is answered by name, not as
477
+ `url_not_resolvable`: the code is **`url_resolves_but_no_layout`** and the
478
+ message says which page was identified and why nothing renders it. Re-spelling
479
+ the URL will not help there — give that page a layout, or address the governing
480
+ template by `layout_id`.
259
481
 
260
482
  **`serves_context_kind` + `serves_context_ids` on `pages_list` rows — resolve it
261
483
  yourself, zero extra calls.** Every `pages_list` row also carries these two Layer-C
@@ -280,6 +502,26 @@ serves_context_ids: [11] }`. Use `pages_resolve` when you just want the answer;
280
502
  use the `serves_context_*` columns when you already hold `pages_list` and want to
281
503
  match locally.
282
504
 
505
+ **`page_role` on `pages_list` rows — a coarse content-role facet for filtering.**
506
+ Every row also carries `page_role`, one of `home`, `landing`, `product`, `legal`,
507
+ `thankyou`, `blog`, `mockup`, `template`, `generic`. It answers "which of these 80
508
+ rows are actually PRODUCT pages?" without you guessing from names — filter the
509
+ rows you already have by `page_role === "product"` instead of pattern-matching
510
+ labels yourself. It is derived HEURISTICALLY from fields already on the row
511
+ (URL slug, the raw YOOtheme type condition, the row's name) — a legal/thank-you/
512
+ product/landing match comes from the URL path (`/impressum`, `/datenschutz`,
513
+ `/danke`, `/produkt/…`); `home` comes from the site's authoritative front-page
514
+ resolution OR the row's own URL being the bare site root; `blog` from a native
515
+ WP post / Joomla article type; `mockup` from a designer's own "demo"/"mockup"/
516
+ "placeholder" wording in the row's name; `template` from a bare internal
517
+ condition. **`generic` is the honest default, not a bug** — exactly like an
518
+ empty `serves_context_ids`, it means no signal fired, not that classification
519
+ failed. Treat `page_role` as a filter/identification AID, not a guarantee: it
520
+ never re-reads anything (zero extra calls), so it can occasionally miss a page
521
+ whose role isn't visible from its URL/type/name — cross-check with
522
+ `page_get_layout` when precision genuinely matters (e.g. before a destructive
523
+ bulk action).
524
+
283
525
  ### Address a page WITHOUT its URL — the `target` object
284
526
 
285
527
  `pages_resolve` and `pages_audit` take the **same** `target` object, so one
@@ -288,7 +530,7 @@ this page". Supply **exactly ONE of** these five keys:
288
530
 
289
531
  | Key | Value | Example |
290
532
  |-----|-------|---------|
291
- | `template_id` | An exact template id | `{ template_id: "page:4816" }` |
533
+ | `template_id` | An exact layout id | `{ template_id: "page:4816" }` |
292
534
  | `post_id` | The numeric WP post / Joomla article id | `{ post_id: 4816 }` |
293
535
  | `url` | A public frontend URL (same resolver as `pages_resolve({ url })`) | `{ url: "https://example.com/schedule-demo/" }` |
294
536
  | `special` | A semantic page ROLE (vocabulary below) | `{ special: "home" }` |
@@ -359,17 +601,22 @@ never a transport failure — report it verbatim rather than retrying blindly.
359
601
  | `no_target` | 400 | A `target` WAS supplied but none of its members is a usable address — every member must be a **non-empty scalar**. `target[special][x]=y` (a nested value) and a templated `target[title]=` that rendered empty both land here. | Re-send with exactly one non-empty scalar key. Never re-send without a `target` "to see what happens": that is a whole-site sweep whose findings you would attribute to one page. |
360
602
  | `too_many_targets` | 400 | Two or more address keys, or `url` alongside `target`. | Pick one. Use `title` or `pages_list` when you only know the page by name. |
361
603
  | `types_conflicts_with_target` | 400 | `types` was sent with a `target`. | Drop `types` — a target already names exactly one page, so there is nothing for a type filter to narrow. |
362
- | `unknown_check_category` | 400 | A `checks` value that is not one of the four categories (`seo`, `structure`, `content`, `links`) — a typo, a finding CODE passed where a category belongs, or a case variant: the vocabulary is **case-sensitive**, so `SEO` is not `seo`. | Re-send with a real category (the message names both the unknown members and the valid set). This is refused rather than filtered because an unrecognised value selects ZERO checks, and zero checks over a normally-scanned site returns `findings: []` — a confident CLEAN verdict for a typo. **Never read such an answer as clean.** |
604
+ | `unknown_check_category` | 400 | A `checks` value that is not one of the five categories (`seo`, `structure`, `content`, `links`, `design`) — a typo, a finding CODE passed where a category belongs, or a case variant: the vocabulary is **case-sensitive**, so `SEO` is not `seo`. | Re-send with a real category (the message names both the unknown members and the valid set). This is refused rather than filtered because an unrecognised value selects ZERO checks, and zero checks over a normally-scanned site returns `findings: []` — a confident CLEAN verdict for a typo. **Never read such an answer as clean.** |
605
+ | `unknown_audit_mode` | 400 | A `mode` value that is not `builder` or `rendered` (a typo, e.g. `render`). | Re-send with one of the two valid values. Refused rather than silently falling back to `builder`, for the same reason as `unknown_check_category`: nothing was scanned, so the previous answer is NOT a clean result. |
606
+ | `unknown_heading_outline_mode` | 400 | The SAME `mode` vocabulary (`builder`/`rendered`), on `yootheme_builder_heading_outline` instead of `pages_audit` — a dedicated code so the two are never confused. | Re-send with `builder` or `rendered`. |
363
607
  | `no_template_matched_types` | 400 | A site-sweep `types` filter matched NO template on this site — a typo or, most often, a case variant (`PAGE` is not `page`). Unlike `checks`, the `types` vocabulary is **open and site-dependent** (`page`, `article`, `single-post`, `com_content.category`, …), so it cannot be validated against a fixed list — only a zero match is knowable, and it is knowable only after the sweep. | Re-send with one of the types the message lists (it names what this site actually has), or omit `types` to sweep everything. Refused for the same reason as `unknown_check_category`: a filter that selected nothing answered HTTP 200 with `findings: []` and an all-zero summary, which reads as CLEAN. **Never read such an answer as clean.** |
364
- | `template_not_found` | 404 | The `template_id` names no template carrying a YOOtheme layout on this site. | Re-read the id from `pages_list`; ids are site-specific and change when a page is recreated. |
608
+ | `insufficient_capability` | 403 | The object EXISTS and this key may not read it — a permission answer, not a missing page. The message names the object (`page:5040` / `article:42`). **These tools answer this way**, the ones that read ONE layout: `page_get_layout`, `layout_query` (single template), `element_list`, `element_get`, `element_get_binding`, `inspect_multi_items_binding`, `inspect_sublayout`, `render_preview`, `template_summary`, `heading_outline`. **The others do NOT**, on purpose: `pages_list`, `pages_resolve`, `pages_audit`, `get_etag` and a site-wide `layout_query` sweep report a forbidden object as absent, because a half-sharpened sweep is more misleading than an honestly lenient one. **Never read their silence about an object as proof that it does not exist.** | Do not repeat the call: it gives the same answer while the key stays the same. Ask an administrator to open the object to this key, or use a key whose subject can read it. See [Scopes](#scopes-bearer-key-permissions). |
609
+ | `template_not_found` | 404 | The `layout_id` names no template carrying a YOOtheme layout on this site. | Re-read the id from `pages_list`; ids are site-specific and change when a page is recreated. |
365
610
  | `post_not_found` | 404 | The `post_id` names no WP post / Joomla article with a per-page layout. | Same: confirm via `pages_list`. A post that exists but has no per-page layout is rendered by a THEME template — find that template instead. |
366
- | `url_no_match` | 404 | The URL is well-formed but resolves to no per-page layout usually because a theme template renders it. | Use `pages_list` to find the governing template, or `pages_resolve({ url })` for the fuller hint. |
611
+ | `url_no_match` | 404 | The URL is well-formed but this server could not map it to any page at all — neither a per-page layout nor an archive/term/author theme template matched. Note this is NOT the "page exists, has no layout" case: that one has its own code in the next row. | Check the address is this site's and names a public, singular page/article. A LIST view is usually reached with `target: { special: }` instead. Failing both, browse `pages_list`. |
612
+ | `url_resolves_but_no_layout` | 404 | The URL DID resolve to a page — WordPress mapped it to a post, this server read the Joomla article the address names, or the address is this site's front page — but that page carries no YOOtheme layout of its own; something else renders it. The message names the page. | Nothing here is fixed by re-spelling the address. Give that page a layout in the builder, or find the governing theme template in `pages_list` (its `serves_context_kind` column says which context each one serves) and address it by `layout_id`. |
367
613
  | `title_no_match` | 404 | No page NAME contains that substring. | Try a shorter substring, or list with `pages_list`. |
368
614
  | `special_not_resolvable` | 404 | The token is in the vocabulary and resolvable in principle, but matches no template on THIS site. | Report which role was requested; do not silently fall back to another page. |
369
615
  | `title_ambiguous` | 409 | The substring matches two or more pages. | A `candidates` list (max 10) rides along — show it and let the user choose. |
370
616
  | `blog_ambiguous` | 409 | Joomla only: several distinct blog pages. | Same `candidates` shape; show it (see the Joomla caveat above). |
371
617
  | `special_not_supported` | 422 | The token is recognised but this platform has no such page role (today: `date`, on both). | Say so plainly instead of guessing an analogue. |
372
618
  | `target_not_scannable` | 422 | **The target resolved to a real page, but this site does not enumerate it as an auditable page** — a post type or status the page enumerator does not cover, or a trashed item. | **This is NOT a clean result.** An audit that answered 200 with zero findings would read as "your page is fine"; this code exists precisely so it cannot. Audit it via `template_summary`, or pick a page `pages_list` returns. |
619
+ | `render_failed` | 422 | `yootheme_builder_heading_outline` in `mode:"rendered"` — the target resolved to a real template, but rendering it genuinely failed (a broken binding or other structural problem — NOT a Joomla `module`/`module_position` gap, which the server tolerates transparently; see `render_fallback`/`render_fallback_used` below). | The page is real but cannot currently be rendered. Read the message for the underlying cause; fix the layout, or fall back to `mode:"builder"` (structural inspection over the layout tree, no render needed). |
373
620
 
374
621
  `server_too_old` is the one code the SERVER never sends — see
375
622
  [Audit ONE page instead of the whole site](#audit-one-page-instead-of-the-whole-site).
@@ -395,11 +642,35 @@ Do not loop on auth errors.
395
642
  > capabilities like `manage_options` gate the admin settings page only; the
396
643
  > REST API surface is Bearer-gated.
397
644
 
398
- > **Joomla L2 articles surface.** The Joomla plugin ships `/v1/articles*`
399
- > REST endpoints for per-article custom layouts, but they are NOT exposed
400
- > via MCP tools in v1.x. To use that surface, call the REST endpoints
401
- > directly with the Bearer key. MCP tool coverage for L2 articles is
402
- > planned for a future release.
645
+ > **Joomla L2 articles surface.** The Joomla plugin ships READ-ONLY
646
+ > `/v1/articles*` REST endpoints (`GET /articles`, `GET /articles/<id>/page-layout`,
647
+ > `GET /articles/<id>/elements/<path>`) for inspecting an article's Builder
648
+ > state, but they are NOT exposed via MCP tools in v1.x. To read that
649
+ > surface, call the REST endpoints directly with the Bearer key. To WRITE
650
+ > an article's layout, address it the same way as any other page — target
651
+ > `article:<id>` with the normal page-editing tools (`page_save`,
652
+ > `element_add`, `element_update_settings`, …); there is no separate write
653
+ > route for articles.
654
+
655
+ ### The write budget is attached to the KEY, not to you
656
+
657
+ Writes are rate-limited **per bearer key**. Several clients — several agents,
658
+ several editors, a script running alongside you — sharing one key share **one**
659
+ budget, so a burst that is well within the cap for you alone can still be
660
+ refused. This is reproducible: six agents on one key hit it.
661
+
662
+ `yootheme_builder_health` publishes the cap as `write_rate_limit`
663
+ (`{limit, window_seconds, scope: "kid", applies_to: "writes"}`), read from the
664
+ limiter the site actually enforces. **Read it before a burst instead of
665
+ discovering it as a refusal.** Two bounds worth knowing:
666
+
667
+ - `limit` is the CAP, never the headroom you have left — no counter is read.
668
+ - Reads are not capped, so there is no reason to slow a read loop down for it.
669
+
670
+ Exceeding it answers HTTP 429 with `retry_after_seconds`, and **the write did
671
+ not happen** — wait out the window and retry rather than treating it as a
672
+ failed write to investigate. Anything throttling in FRONT of the site (a CDN,
673
+ a WAF, the host) is invisible here and answers with its own status.
403
674
 
404
675
  ---
405
676
 
@@ -442,6 +713,16 @@ To verify a specific site before doing work on it, call
442
713
  and `/etag` in parallel and returns `plugin_reachable` + `bearer_valid`
443
714
  without mutating anything.
444
715
 
716
+ For the whole fleet at once — "which of my sites are up, and how much is on
717
+ them" — call `yootheme_builder_sites_overview()`. It answers in ONE call what
718
+ `sites_list` plus one `sites_test` and one `pages_list` per site answered
719
+ before, probing every site in parallel: one row per site with `reachable`,
720
+ `bearer_valid` and the page counts. A site that is down becomes a row carrying
721
+ `error`, never a failed call — which is the point: you want to see WHICH site
722
+ is broken, not lose the answer for the others. Pass `include_pages: false` to
723
+ skip the per-site page read. The content/theme-template split on a row is
724
+ ABSENT on a host that does not report it; it is never reported as zero.
725
+
445
726
  ### Default-site mechanics
446
727
 
447
728
  The default site is set automatically on first add:
@@ -462,7 +743,9 @@ You can store the Bearer key two ways per site:
462
743
  - **1Password reference**: `bearer_ref: "op://Vault/Item/credential"` in
463
744
  `sites.json`. The plaintext token never touches disk. The MCP server shells
464
745
  out to the `op` CLI at first use per site to fetch the live token, then
465
- caches it in memory for that process lifetime.
746
+ caches it in memory for that process lifetime. `op` itself does not cache
747
+ — restart the MCP client after rotating a key so the server re-resolves
748
+ the new value.
466
749
 
467
750
  **Recommendation for production sites**: use `bearer_ref`. You get rotation
468
751
  without editing `sites.json`, and your 1Password audit log captures every
@@ -470,6 +753,24 @@ fetch. The `op` CLI must be installed and signed in on the machine running
470
753
  the MCP server. If `op` is missing, the resolver returns a structured error
471
754
  (`op CLI not found in PATH`) pointing at the install docs.
472
755
 
756
+ ### Prompt friction depends on how the client was launched
757
+
758
+ Per 1Password's own documentation, an app-integration session binds to a
759
+ **TTY** (macOS/Linux) or PID + start time (Windows) — a GUI-launched process
760
+ (Claude Desktop, Cursor) has neither, so the confirmation-free window a
761
+ terminal session gets does not carry over there. Three environment classes,
762
+ worst-first:
763
+
764
+ | Class | Confirmation dialogs | Who |
765
+ |---|---|---|
766
+ | Service account (`OP_SERVICE_ACCOUNT_TOKEN`) | none | agencies, servers, CI |
767
+ | Terminal + app integration | one per window, valid ~10 min | developers running the CLI |
768
+ | **GUI-launched + app integration** | **per call** | **Claude Desktop, Cursor — the customer default** |
769
+
770
+ **For multi-site customers a service account is the frictionless path** —
771
+ worth setting up explicitly rather than discovering the per-call prompts one
772
+ site at a time.
773
+
473
774
  ### `sites.json` location
474
775
 
475
776
  The registry lives at `~/.config/yt-builder-mcp/sites.json` (XDG-conform).
@@ -542,9 +843,9 @@ one conversation.
542
843
  2. For each site, the agent runs the same sequence with `site_id` set:
543
844
  - `yootheme_builder_pages_list({ site_id: "wp-acme", fields: ["id", "label"] })`
544
845
  - `yootheme_builder_get_etag({ site_id: "wp-acme" })`
545
- - `yootheme_builder_element_update_settings({ site_id: "wp-acme", template_id: "home", element_path: "/children/0/children/0/children/0/children/0", props: { content: "New headline" }, merge: true, etag: "<etag>" })`
546
- - `yootheme_builder_page_save({ site_id: "wp-acme", template_id: "home", etag: "<fresh>" })`
547
- - `yootheme_builder_page_publish({ site_id: "wp-acme", template_id: "home", etag: "<fresh>" })`
846
+ - `yootheme_builder_element_update_settings({ site_id: "wp-acme", layout_id: "home", element_path: "/children/0/children/0/children/0/children/0", props: { content: "New headline" }, merge: true, etag: "<etag>" })`
847
+ - `yootheme_builder_page_save({ site_id: "wp-acme", layout_id: "home", etag: "<fresh>" })`
848
+ - `yootheme_builder_page_publish({ site_id: "wp-acme", layout_id: "home", etag: "<fresh>" })`
548
849
  3. The customer sees a stream of replies, each prefixed with the matching
549
850
  `[label @ host]`, so it is obvious which site is at which step.
550
851
 
@@ -558,7 +859,7 @@ for a focused diagnosis without touching the others.
558
859
  ## Pages vs theme templates (know what you are editing)
559
860
 
560
861
  `pages_list` returns TWO different kinds of editable layout, and every tool
561
- takes a `template_id` that addresses either. Know which one you have before
862
+ takes a `layout_id` that addresses either. Know which one you have before
562
863
  you write — they have very different blast radius.
563
864
 
564
865
  | Kind | `id` shape | `type` value(s) | What it is |
@@ -569,7 +870,7 @@ you write — they have very different blast radius.
569
870
  **The safety rule: editing a theme template changes the layout for ALL
570
871
  matching content, not one page.** If the user says "restyle the blog post
571
872
  template", `pages_list` shows it as e.g. `{ id: "I99YS8Ii", label: "Post",
572
- type: "single-post" }` — editing that `template_id` restyles EVERY blog post
873
+ type: "single-post" }` — editing that `layout_id` restyles EVERY blog post
573
874
  on the site at once. That is often exactly what's wanted (that's what theme
574
875
  templates are for), but confirm the intent — do not edit a `single-post` /
575
876
  `taxonomy-*` / `archive-*` template when the user meant one specific page.
@@ -581,6 +882,33 @@ templates are for), but confirm the intent — do not edit a `single-post` /
581
882
  - `pages_create` creates a **page/article** (an individual layout). Theme
582
883
  templates are defined in the theme itself and are not created via this MCP;
583
884
  you edit the existing ones `pages_list` reports.
885
+ - `yootheme_builder_pages_delete` removes one — an L1 tool, call it directly
886
+ (no gateway). `confirm: true` is REQUIRED on every call (a bare call is
887
+ refused, `pages.confirm_required`); there is no preview-by-omitting-confirm
888
+ the way `modules_delete` has. Preview instead with `dry_run: true`
889
+ (reports `element_count`, `has_dynamic_binding`, and whether the real call
890
+ would be refused for lacking an `intent`; nothing is written and no
891
+ request is sent). The real call additionally needs `intent`: call
892
+ `confirm: true` without it once and the server refuses with a 400 naming
893
+ which intents are possible for THIS page's/article's CURRENT status, each
894
+ with its own `effect`/`reversible`/`native_name` — the same
895
+ `intent_not_chosen` shape `modules_delete` uses. Unlike modules,
896
+ `intent: "trash"` is REVERSIBLE on BOTH platforms: it sets the WordPress
897
+ `post_status` to `trash` or the Joomla article `state` to `-2`;
898
+ `intent: "permanent"` removes the row and its layout for good on either
899
+ platform, and neither CMS can recreate them. There is a way back from the
900
+ trash step, but it is NOT this tool and NOT `pages_set_status` (see "Finding
901
+ and un-trashing a page" below).
902
+ `yootheme_builder_pages_delete({ layout_id: 42, confirm: true, intent: "trash" })`.
903
+ A page/article ALREADY in the trash short-circuits BEFORE the intent gate:
904
+ passing `intent: "permanent"` still deletes it for good (the only intent
905
+ offered there); passing anything else — no `intent`, `intent: "trash"`, or
906
+ an unrecognised one — instead answers `200 {deleted:false,
907
+ already_trashed:true}`, a MEASUREMENT, not an error, that names
908
+ `intent:"permanent"` or the way back (below) as the two ways forward. The
909
+ EXECUTED trash response (`confirm:true, intent:"trash"`) carries the same
910
+ pointer in its own `note` field — the caller who just trashed something and
911
+ regrets it never has to re-read the preview to find it.
584
912
 
585
913
  **Reaching a theme template by ROLE instead of by id.** You rarely know a theme
586
914
  template's opaque id (`I99YS8Ii`). The `special` tokens of the shared `target`
@@ -601,6 +929,40 @@ response's `resolved_target` tells you which template that actually was. The
601
929
  same safety rule still applies: a finding on a theme template is a finding on
602
930
  EVERY page that template renders.
603
931
 
932
+ **Finding and un-trashing a page you cannot see in `pages_list`.** A trashed
933
+ page/article never appears there, and by default `local_content_read`
934
+ answers the same `total: 0` for "genuinely nothing" and "it's in the trash" —
935
+ pass `kind: "entities", status: "trash"` for the EXCLUSIVE trashed view (never
936
+ mixed into the default list), with the identifier in `id`. Even without
937
+ asking: a plain `local_content_read({kind:"entities"})` call carries
938
+ `trashed_total` in the response whenever a match exists in the trash, so the
939
+ gap surfaces unasked. Then `yootheme_builder_pages_restore({ layout_id: <id> })`
940
+ brings it back — the ONE correct way, do NOT try `pages_set_status`, which
941
+ refuses a trashed page with a 409 pointing at this tool. **The two platforms
942
+ genuinely diverge here, and the tool says so rather than hiding it**:
943
+ WordPress' `wp_untrash_post()` reinstates BOTH the exact pre-trash status AND
944
+ the pre-trash URL slug (a hand-rolled status write would leave the trash's
945
+ `__trashed` slug suffix in place, silently changing the address) — Joomla
946
+ stores no pre-trash status at all, so a restored article can only become
947
+ `unpublished`; call `pages_set_status` with `status: "published"` afterwards
948
+ to make it visible again.
949
+
950
+ **A trashed Joomla MODULE has no restore tool of its own, and the way out is
951
+ NOT the equivalent of the above — it lands on the opposite side.** Measured:
952
+ `yootheme_builder_modules_update({active:true})` writes `published = 1`, so a
953
+ module comes out of the trash **PUBLISHED** whatever it was before. Joomla
954
+ keeps no pre-trash state for modules either (`#__modules` has one state column
955
+ and the trash writes `-2` over it), but here the single reachable value is the
956
+ VISIBLE one — an unpublished module you trash and bring back is live on the
957
+ site. Its counterpart `{active:false}` is not a second way out: a trashed
958
+ module already reads `active:false`, so that call writes nothing and leaves it
959
+ in the trash, reporting only `changed: []`. Coming back unpublished is
960
+ therefore two calls in order — `{active:true}` then `{active:false}` — and the
961
+ tool says so in `restore_state_note` on the trash row, in the delete preview's
962
+ notes, and in the `notes` of the restoring call itself.
963
+ `local_content_read({kind:"modules"})` lists a trashed module unasked, marked
964
+ `publish_state:"trashed"`.
965
+
604
966
  ## Workflow 1: Build a hero section
605
967
 
606
968
  **Goal:** Add a fresh hero section (heading + sub-heading + CTA button)
@@ -616,34 +978,124 @@ to an existing page.
616
978
  named a specific page, match on `label` (exact then fuzzy).
617
979
  3. `yootheme_builder_get_etag()`: fetch the current top-level
618
980
  optimistic-lock ETag. Every write tool requires it via `etag`.
619
- 4. `yootheme_builder_element_add({ template_id: "<id>", parent_path: "", element_type: "section", props: { style: "primary" }, etag: "<etag>" })`:
620
- append a new section at the template root (`parent_path: ""`). The
621
- section background is set with `style` (enum: `default` / `muted` /
622
- `primary` / `secondary`) there is NO `background` key (it hard-rejects
623
- as an unknown prop). Always call `element_type_get_schema("section")`
624
- first to confirm the real prop keys.
625
- Returns `{ rel_path: "/children/0/children/N", etag: "<fresh>" }`.
626
- 5. `yootheme_builder_element_add({ template_id, parent_path: "<section-path>", element_type: "row", etag: "<fresh-etag>" })`:
627
- add a row inside the section. Use the etag returned by the
628
- previous write (etags rotate every mutation).
629
- 6. `yootheme_builder_element_add({ template_id, parent_path: "<row-path>", element_type: "headline", props: { content: "<h1 text>", title_style: "h1" }, etag })`:
630
- add a headline. `title_style: "h1"` uses the semantic type scale (the
631
- tasteful default). See "Typography & heading styles" below before
632
- reaching for a `heading-*` display size.
633
- 7. `yootheme_builder_element_add({ template_id, parent_path: "<row-path>", element_type: "text", props: { content: "<sub text>" }, etag })`:
634
- add a text element.
635
- 8. `yootheme_builder_element_add({ template_id, parent_path: "<row-path>", element_type: "button", etag })`
981
+ **Chaining writes: every write answers with `etag` AND `new_etag`, and both
982
+ carry the value read AFTER that write.** They are interchangeable; pass
983
+ either into the next write. You only need `get_etag` again after a 412, or
984
+ when you did not just write.
985
+ 4. `yootheme_builder_scaffold_section({ layout_id: "<id>", columns: 1, etag: "<etag>" })`:
986
+ build a VALID `section → row → column` hull at the template root in ONE
987
+ atomic call the correct way to start a layout block. Only `section` may
988
+ sit at the layout root; a bare section accepts only `row`; a bare row
989
+ accepts only `column`; content/leaf elements (headline, text, button, …)
990
+ are rejected anywhere except inside a `column`. `columns: 1` (the
991
+ default) is a single full-width column the shape a hero needs.
992
+ Returns `{ section_path: "/children/0/children/N", row_path: "…/children/0",
993
+ column_paths: ["…/children/0/children/0"], etag: "<fresh>" }`. Drop every
994
+ content element below into `column_paths[0]`.
995
+ 5. (Optional) `yootheme_builder_element_update_settings({ layout_id, element_path: "<section-path>", props: { style: "primary" }, etag: "<fresh-etag>" })`:
996
+ `scaffold_section` does not take section `props`, so set the background
997
+ as a follow-up call when the hero needs one. `style` (enum: `default` /
998
+ `muted` / `primary` / `secondary`) — there is NO `background` key. Writing
999
+ one is ACCEPTED and stored; the answer names it under `warnings` with
1000
+ `code: "unknown_prop_keys"`, and nothing renders. Always call
1001
+ `element_type_get_schema("section")` first to confirm the real prop keys.
1002
+ 6. `yootheme_builder_element_add({ layout_id, parent_path: "<column-path>", element_type: "headline", props: { content: "<h1 text>", title_style: "h1" }, etag })`:
1003
+ add a headline INSIDE the column `scaffold_section` returned
1004
+ (`column_paths[0]`) — never directly in the row or the section. `title_style:
1005
+ "h1"` uses the semantic type scale (the tasteful default). See "Typography
1006
+ & heading styles" below before reaching for a `heading-*` display size.
1007
+ 7. `yootheme_builder_element_add({ layout_id, parent_path: "<column-path>", element_type: "text", props: { content: "<sub text>" }, etag })`:
1008
+ add a text element into the SAME column.
1009
+ 8. `yootheme_builder_element_add({ layout_id, parent_path: "<column-path>", element_type: "button", etag })`
636
1010
  then add a `button_item` CHILD for the actual label/link/style:
637
- `yootheme_builder_element_add({ template_id, parent_path: "<button-path>", element_type: "button_item", props: { content: "<cta>", link: "<url>", button_style: "primary" }, etag })`.
1011
+ `yootheme_builder_element_add({ layout_id, parent_path: "<button-path>", element_type: "button_item", props: { content: "<cta>", link: "<url>", button_style: "primary" }, etag })`.
638
1012
  The `button` element is a CONTAINER — its label, link and style live on
639
1013
  a `button_item` child, NOT on the button's own props. Adding a lone
640
1014
  `button` with `{content, link}` renders an empty button. (Same
641
1015
  container→item pattern as `grid`→`grid_item`, `list`→`list_item`.)
642
- 9. `yootheme_builder_page_save({ template_id, etag })`:
1016
+ 9. `yootheme_builder_page_save({ layout_id, etag })`:
643
1017
  persist the working copy (visible in YOOtheme Customizer preview).
644
1018
  `page_save` is a first-class L1 tool; call it directly.
645
- 10. `yootheme_builder_page_publish({ template_id, etag })`:
1019
+ > **`render_preview` cannot see an element whose rendering depends on CMS
1020
+ > runtime state.** On Joomla a `module` / `module_position` subtree is EXCLUDED
1021
+ > from every render probe, whatever the module's state, and a `newsletter`
1022
+ > element is excluded wherever its provider is unconfigured. The response says
1023
+ > so with `fallback_used:true` + `dropped_count:N`, and `dropped_count_note`
1024
+ > names WHICH types were excluded, why, and that they are still in the saved
1025
+ > layout. So an `expect` aimed at such output can never match: a `matched:false`
1026
+ > there is **not** evidence that the element fails to render, and
1027
+ > `match_conclusive:false` marks exactly that case. A HIT is always conclusive.
1028
+ > To check a MODULE, read its placement with
1029
+ > `local_content_read({kind:"modules", include_usage:true, verify_on_urls:[...]})`.
1030
+ > That read answers for modules only; for any other excluded type it returns
1031
+ > nothing, which is why the response points at it only when a module was
1032
+ > actually excluded.
1033
+
1034
+ 10. `yootheme_builder_page_publish({ layout_id, etag })`:
646
1035
  make the changes live on the front-end. Also a first-class L1 tool.
1036
+ **`published:true` is a statement about the LAYOUT, not about the page.**
1037
+ A WordPress post left in `draft`, or a Joomla article at `state=0` /
1038
+ trashed, or one whose `publish_up` is still in the future, is not served
1039
+ to a visitor afterwards. Read `cms_visibility.findable_by_visitor` before you
1040
+ tell anyone the page is live; `cms_warning` names the exact fix when it is
1041
+ `false`.
1042
+ **For the ordinary case — build it and make it live — pass the CMS status
1043
+ in the same call**: `page_publish({ layout_id, cms_status: "publish" })` on
1044
+ WordPress, `cms_status: "published"` on Joomla, and on Joomla add
1045
+ `menu_item: true` when the article still needs a SEF route. Same words and
1046
+ same refusals as `pages_set_status`; the other platform's word publishes
1047
+ nothing. The response then carries `cms_status_applied` with the STORED
1048
+ status, and `cms_visibility` reflects the new one. Omit `cms_status` and this
1049
+ call changes nothing about who may see the page — that separation stays, it
1050
+ is simply no longer the only way through.
1051
+ > **On Joomla, "not findable" and "not reachable" are two different things, and
1052
+ > only one of them is a privacy control.** Measured anonymously with curl on
1053
+ > 2026-08-04: the STATE decides the 404 (`state` 1 published and 2 archived both
1054
+ > answer **200**, `state` 0 unpublished and -2 trashed both answer **404**) and
1055
+ > the ACCESS LEVEL decides the 403 (a level a guest does not hold answers **403**
1056
+ > at state 1 and at state 2 alike). A missing menu item decides NEITHER: it
1057
+ > costs the SEF url, the breadcrumb and the navigation path, while the article
1058
+ > keeps answering 200 on its own
1059
+ > `?option=com_content&view=article&id=N` url for anyone. So `findable_by_visitor:
1060
+ > false` on a published article usually means "no visitor will FIND it", not
1061
+ > "no visitor can OPEN it". Two consequences worth saying out loud: **archiving
1062
+ > does not hide anything** (it is a 200), and if an article must not be public,
1063
+ > set its state with `yootheme_builder_pages_set_status` or give it an access
1064
+ > level a guest does not hold. Never leave the menu item off and call that
1065
+ > private.
1066
+
1067
+ **The `cms_visibility` block carries the facts, so you do not have to infer
1068
+ them.** It rides on `page_publish`, `page_generate_dynamic` and `pages_set_status`.
1069
+ Read the field that answers YOUR question:
1070
+
1071
+ | field | question it answers | platform |
1072
+ |---|---|---|
1073
+ | `findable_by_visitor` | will a visitor FIND this via a navigation path? (NOT the same as reaching it: an unrouted article still answers on its direct URL) | both |
1074
+ | `status_permits_direct_url` | does the stored state ALONE serve the item's own direct URL? | both |
1075
+ | `state_and_access_permit_direct_url` | state AND access level together: is it served? | Joomla |
1076
+ | `access_is_guest_visible` | does an anonymous visitor hold this access level? | Joomla, when the probe ran |
1077
+ | `article_access` / `post_status` / `article_state` | the raw stored values | per platform |
1078
+ | `publish_up_in_future` | is it scheduled? | both |
1079
+ | `has_route` | does a menu item route it? (WordPress: always equals published) | both |
1080
+
1081
+ > **One row of that table is NOT in the block, and it is the one people
1082
+ > confuse it with:**
1083
+ >
1084
+ > | field | question it answers | where it rides |
1085
+ > |---|---|---|
1086
+ > | `visibility` | do the STORED FIELDS rule this page out for a visitor? | every `pages_list` row |
1087
+ >
1088
+ > `visibility` reads the CMS status plus, on WordPress, password
1089
+ > protection. It never reads the menu route or the access level, so on
1090
+ > Joomla it answers `undetermined` where `findable_by_visitor` gives a verdict.
1091
+ > That is not a contradiction: the per-page field looked at two levers the
1092
+ > listing never touched, so when they seem to disagree, believe the
1093
+ > per-page one.
1094
+
1095
+ On WordPress the first three necessarily agree: a published page resolves at
1096
+ its permalink and there is no separate route step. On Joomla they come
1097
+ apart, and that divergence is the whole reason the extra fields exist. A
1098
+ `null` in any of them means the input was not readable, never "no".
647
1099
 
648
1100
  > **Building a whole page? Prefer ONE atomic `page_save` over ~30 incremental
649
1101
  > writes.** The step-by-step `element_add` sequence above is clearest for
@@ -652,8 +1104,8 @@ to an existing page.
652
1104
  > (especially when other agents edit the same site — the builder ETag is a
653
1105
  > single shared option). For a full page, assemble the complete
654
1106
  > `{type:"layout",children:[...]}` tree in memory and persist it in ONE call:
655
- > `yootheme_builder_page_save({ template_id, layout: <full-tree> })` (ETag
656
- > optional last-write-wins, scoped to this page). `dry_run:true` preflights
1107
+ > `yootheme_builder_page_save({ layout_id, layout: <full-tree> })` (ETag is
1108
+ > required read it via `yootheme_builder_get_etag` first). `dry_run:true` preflights
657
1109
  > it first. A fast, robust pattern: `scaffold_section` one block, read it
658
1110
  > back to learn the exact node shape, then build the rest of the tree from
659
1111
  > that shape and `page_save` it whole.
@@ -661,16 +1113,35 @@ to an existing page.
661
1113
  **Common pitfalls:**
662
1114
 
663
1115
  - **Wrong parameter names.** Every tool uses snake_case. Use
664
- `template_id` (not `pageId`), `parent_path` (not `parentPath`),
1116
+ `layout_id` (not `pageId`), `parent_path` (not `parentPath`),
665
1117
  `element_type` (not `type`), `props` (not `settings`), `etag`
666
1118
  (not `ifMatch`). The MCP server rejects unknown keys with a
667
1119
  Zod-validation error.
668
1120
  - **Forgetting `etag`.** Every write tool needs the latest etag. The
669
1121
  shared schema marks it required (min length 1). On `412 Precondition
670
- Failed` re-fetch via `yootheme_builder_get_etag` and retry.
671
- - **Adding non-row elements directly to a section.** Sections expect a
672
- row in between. The server returns a structured error with a
673
- human-readable hint when you skip the row.
1122
+ Failed`, RE-READ the element or page you were writing before you retry.
1123
+ A fresh etag from `yootheme_builder_get_etag` makes the retry succeed,
1124
+ but the etag is site-wide and its hash does not cover per-page layouts,
1125
+ so a 412 cannot tell you whether the write that bumped it was on YOUR
1126
+ page. Retrying on a fresh etag alone overwrites whatever landed.
1127
+ - **Adding content elements directly to a section or a row.** A section
1128
+ accepts only `row`; a row accepts only `column`; headline/text/button and
1129
+ every other content element are rejected anywhere except inside a
1130
+ `column` — a `422 layout_invalid_hierarchy`. Use `scaffold_section` to
1131
+ build the whole `section → row → column` hull in one call and drop content
1132
+ into the returned `column_paths`; don't hand-build a section + row and add
1133
+ content straight into the row. The server returns a structured error with a
1134
+ human-readable hint when you skip a level.
1135
+ - **Adding a child to a leaf, or hitting an empty element registry.** A leaf
1136
+ type — one that never accepts ANY child, e.g. `text`, `headline`, `button`
1137
+ — refuses every child with `422 layout_leaf_has_children`, a distinct code
1138
+ from the mismatch above. Separately, if YOOtheme's element registry answers
1139
+ empty on the site (usually a transient boot-order state, not a fault in
1140
+ your layout), the parent/child check cannot run at all and the write is
1141
+ refused with `422 registry_unavailable` rather than guessed through —
1142
+ section, row, column and the sublayout are decided without the registry
1143
+ and keep working meanwhile. Both leave the tree untouched; for
1144
+ `registry_unavailable`, give the site a moment and retry.
674
1145
  - **Saving without publishing.** `page_save` is the equivalent of the
675
1146
  YOOtheme Customizer "Save" button. Content lives in the staging
676
1147
  copy. Visitors see nothing until `page_publish`.
@@ -686,19 +1157,27 @@ to an existing page.
686
1157
  **Worked example (tool-call snippet):**
687
1158
 
688
1159
  ```jsonc
689
- // Step 4. Add the section. parent_path: "" means template root.
690
- yootheme_builder_element_add({
691
- template_id: "home",
692
- parent_path: "",
693
- element_type: "section",
694
- props: { style: "primary" }, // section background = `style`, NOT `background`
1160
+ // Step 4. Build the section row column hull in ONE call.
1161
+ yootheme_builder_scaffold_section({
1162
+ layout_id: "home",
1163
+ columns: 1,
695
1164
  etag: "abc123" // from yootheme_builder_get_etag
696
1165
  })
697
- // Response: { rel_path: "/children/0/children/3", etag: "def456" }
698
- // next call uses etag "def456"
1166
+ // Response: { section_path: "/children/0/children/3", row_path: "/children/0/children/3/children/0",
1167
+ // column_paths: ["/children/0/children/3/children/0/children/0"], etag: "def456" }
1168
+ // → content goes into column_paths[0]; next call uses etag "def456"
1169
+
1170
+ // Step 5 (optional). Set the section background — `style`, NOT `background`.
1171
+ yootheme_builder_element_update_settings({
1172
+ layout_id: "home",
1173
+ element_path: "/children/0/children/3", // the section_path above
1174
+ props: { style: "primary" },
1175
+ etag: "def456"
1176
+ })
1177
+ // Response: { rel_path: "/children/0/children/3", etag: "ghi789" }
699
1178
 
700
1179
  // Step 9. page_save is a first-class L1 tool; call it directly.
701
- yootheme_builder_page_save({ template_id: "home", etag: "<latest>" })
1180
+ yootheme_builder_page_save({ layout_id: "home", etag: "<latest>" })
702
1181
  ```
703
1182
 
704
1183
  **Edge case:** YOOtheme allows nested sections (rare). If the user
@@ -709,11 +1188,169 @@ section sits inside another section.
709
1188
  **Success criterion:** After `page_publish`, navigating to the page
710
1189
  URL on the front-end shows the new hero section above the previous
711
1190
  content. Re-reading the layout via
712
- `yootheme_builder_page_get_layout({ template_id })` shows the new
1191
+ `yootheme_builder_page_get_layout({ layout_id })` shows the new
713
1192
  section as the last child of the template root.
714
1193
 
715
1194
  ---
716
1195
 
1196
+ ## Transparent header (the exception — never a default)
1197
+
1198
+ A transparent header pulls the page's **first** section up behind the site
1199
+ header and recolours the navigation over it. It only works over a hero medium
1200
+ that was made for it. **The default is: do not pull the section behind the
1201
+ header.**
1202
+
1203
+ **Never turn `header_transparent` on by your own initiative** — not while
1204
+ scaffolding a hero, not while inserting a Library layout, not "because it looks
1205
+ more modern". Turn it on only when the customer asks for it, or when the
1206
+ template you inserted already ships a hero medium built for it. **When in
1207
+ doubt, leave it off.** That is always the safe answer, never a compromise.
1208
+ Whoever turns it on picks the colour deliberately; with no deliberate choice
1209
+ the colour is `""`, never `"light"`.
1210
+
1211
+ ### The three props (they live on a `section`)
1212
+
1213
+ | Prop | Values | Read by YOOtheme only when |
1214
+ |---|---|---|
1215
+ | `header_transparent` | `true` / `false` | — (this is the master switch) |
1216
+ | `header_transparent_noplaceholder` | `true` / `false` | `header_transparent` is true |
1217
+ | `header_transparent_text_color` | `""` / `"light"` / `"dark"` | `header_transparent` is true |
1218
+
1219
+ ### Three traps that break pages silently
1220
+
1221
+ 1. **Only the FIRST section counts.** YOOtheme applies the transparent header
1222
+ from the first section of the layout and ignores the props on every later
1223
+ section — no error, no builder warning. "I set it and nothing happened" is
1224
+ almost always this. Put the props on the first section, or remove them.
1225
+ 2. **Setting the colour without the master switch does nothing.**
1226
+ `header_transparent_text_color` / `header_transparent_noplaceholder` on a
1227
+ section whose `header_transparent` is off are dead props. They read as
1228
+ configuration and are never applied.
1229
+ 3. **`"light"` is not a safe default — it is white navigation text.** The
1230
+ recolour fires even when the section has no image, no video and no `style`,
1231
+ so `"light"` over a light theme background is literally white-on-white. The
1232
+ THEME already states which colour fits each section style (its compiled
1233
+ `theme.css` declares `--uk-inverse` per style, e.g. `dark` for
1234
+ `.uk-section-default`). Read that, or — where no image or video is in the way
1235
+ — leave the colour at `""` and let the theme decide. Never assume `"light"`.
1236
+
1237
+ **Over a hero image or video, no colour choice is provable from the layout
1238
+ alone.** The image hides the background the theme's declaration describes, and
1239
+ which part of a `cover` image ends up under the header depends on the viewport.
1240
+
1241
+ Do not hand-write a colour there — and do **not** "play it safe" by pushing an
1242
+ existing one back to `""`. `""` is the theme's own default colour: a real
1243
+ rendered value, not an abstention. Over a dark hero on `.uk-section-default`
1244
+ (which declares `dark`) reverting to `""` puts dark navigation on a dark photo,
1245
+ which is the very break you were trying to avoid.
1246
+
1247
+ Let the tool leave it alone — `mode: "auto"` writes NO colour over a medium —
1248
+ then LOOK at the rendered page (see "Look at what you built") at the customer's
1249
+ viewport — unscrolled AND after scrolling, because a
1250
+ sticky header drops its transparency on scroll. Then write what you SAW with
1251
+ `mode: "explicit"`. Do not guess a colour, and do
1252
+ not reason about "the top N% of the image".
1253
+
1254
+ ### Use `header_transparency_set` — do not hand-write these props
1255
+
1256
+ `yootheme_builder_header_transparency_set` (advanced gateway) is the tool for
1257
+ all of this. It **always targets the first section**, touches only the three
1258
+ props, and **`dry_run` defaults to `true`** — so the first call is an
1259
+ inspection that writes nothing.
1260
+
1261
+ ```jsonc
1262
+ // 1. INSPECT (writes nothing — dry_run defaults true, no etag needed).
1263
+ // Returns the current state, the theme-declared --uk-inverse, risk_class,
1264
+ // confidence, a recommendation and would_change/changes.
1265
+ yootheme_builder_header_transparency_set({ layout_id: "home" })
1266
+
1267
+ // 2. REPAIR with the theme's own declared colour.
1268
+ yootheme_builder_header_transparency_set({
1269
+ layout_id: "home", mode: "auto", dry_run: false, etag: "<latest>"
1270
+ })
1271
+
1272
+ // 3. Turn it ON deliberately, without asserting a colour.
1273
+ // mode "auto" derives it; over a hero medium it writes NO colour and tells
1274
+ // you to look at the page instead.
1275
+ yootheme_builder_header_transparency_set({
1276
+ layout_id: "home", mode: "auto", transparent: true, dry_run: false, etag: "<latest>"
1277
+ })
1278
+
1279
+ // 4. A colour you MEASURED over a hero medium (only mode "explicit" takes one).
1280
+ yootheme_builder_header_transparency_set({
1281
+ layout_id: "home", mode: "explicit", text_color: "light",
1282
+ dry_run: false, etag: "<latest>"
1283
+ })
1284
+ ```
1285
+
1286
+ `mode: "explicit"` refuses a colour the theme's declaration contradicts when no
1287
+ image or video hides that background (HTTP 409,
1288
+ `error_code: header_contrast_contradicts_theme`). Override with
1289
+ `acknowledge_contrast_risk: true` only after LOOKING at the rendered page.
1290
+
1291
+ To undo it, call with `transparent: false` — the tool removes all three props,
1292
+ so no dead configuration is left behind. It is never refused —
1293
+ **turning transparency OFF is always safe and never needs a justification.**
1294
+
1295
+ ### Writing the props by hand — what `element_update_settings` refuses
1296
+
1297
+ You *can* write the props by hand with `element_update_settings` (`merge: true`,
1298
+ on `/children/0`). That path now refuses the one mistake it can prove on its own,
1299
+ and quietly does the safe thing for the other:
1300
+
1301
+ - **Dead props are rejected.** A `header_transparent_text_color` or
1302
+ `header_transparent_noplaceholder` that this write leaves without a true
1303
+ `header_transparent` comes back as
1304
+ `error_code: header_transparent_dead_prop` and nothing is written. A prop
1305
+ YOOtheme never reads is dead whatever the theme declares, so this needs no
1306
+ theme knowledge. Note the merge semantics: `element_update_settings` REPLACES
1307
+ props unless you pass `merge: true`, so a full-replace write that omits
1308
+ `header_transparent` switches the header off.
1309
+ - **Turning it on without a colour writes `""`** — the theme's own default —
1310
+ and tells you so. It is never defaulted to `"light"`. Under `merge: true` the
1311
+ stored colour cannot be read from here, so nothing is seeded and you are
1312
+ asked to state the colour yourself rather than have a guess clobber a value
1313
+ you measured.
1314
+ - **Over a hero image or video the write is ALLOWED**, with a
1315
+ `header_contrast_unverifiable_over_media` warning. Nothing is corrected there
1316
+ — only your eyes can settle it (see "Look at what you built"). That warning
1317
+ **only fires when THIS write states the `image`/`video` itself.** A medium
1318
+ already stored on the section is not readable from here, so under
1319
+ `merge: true` — the shape recommended just above — such a write goes through
1320
+ **silently**. Read no warning as "there is no medium": the usual hand-write
1321
+ (`merge: true` with only the header props, on a section that already has its
1322
+ hero) is exactly that silent case. `pages_audit` and
1323
+ `header_transparency_set` read the stored layout and do see it.
1324
+
1325
+ **What that hand-write path still cannot check for you:** the **first-section
1326
+ rule** — a single element write does not know where its section sits in the
1327
+ layout — and the **theme declaration**, because the compiled `theme.css` is not
1328
+ readable from the MCP server. A colour that contradicts `--uk-inverse` therefore
1329
+ goes through untouched there. `header_transparency_set` and `pages_audit` are
1330
+ where both of those are checked.
1331
+
1332
+ **You do not have to spot this by eye.** `pages_audit` carries two `design`
1333
+ codes for it. Both read the first section's props plus the theme's own
1334
+ `--uk-inverse` declaration for that section style out of the compiled
1335
+ `theme.css`:
1336
+
1337
+ - **`header-contrast-risk`** (severity `warning`) — the layout forces a colour
1338
+ the theme's declaration contradicts, and **no image or video covers** the
1339
+ background that declaration describes. Provable from the props alone: the
1340
+ recommended colour is the theme's own declared value, so it never guesses a colour.
1341
+ - **`header-contrast-unverifiable`** (severity `info`) — a transparent header
1342
+ over a hero image or video, **whatever the colour**. The medium covers exactly
1343
+ the background the declaration describes, so nothing is provable there and the
1344
+ code recommends no colour at all: go and look at the page.
1345
+
1346
+ The split is "is a medium in the way?", never "is the colour right?". Behind a
1347
+ hero medium no value is verifiable and none is safer than another — not even
1348
+ `""`, which is simply the theme's default colour. When `theme.css` cannot be
1349
+ read, both say nothing at all rather than judging on a hunch. See Workflow 6's
1350
+ remediation catalog.
1351
+
1352
+ ---
1353
+
717
1354
  ## Multi-column layouts (columns that sit side-by-side)
718
1355
 
719
1356
  **The single most common layout mistake: a `row` with several `column`
@@ -770,16 +1407,16 @@ never by narrowing the column. Narrowing a single column WITHOUT a matching
770
1407
  **Easiest correct way — `scaffold_section` builds the whole factory-shaped
771
1408
  hull for you (row grid string + per-column responsive widths in one call):**
772
1409
 
773
- - `scaffold_section({ template_id, columns: 3, etag })` → a section whose row
1410
+ - `scaffold_section({ layout_id, columns: 3, etag })` → a section whose row
774
1411
  carries `layout: "1-3,1-3,1-3"` and whose 3 columns each carry
775
1412
  `width_default: "1-1"` + `width_medium: "1-3"` → clean, gap-free, side-by-side
776
1413
  from ≥960px. `columns: N` distributes equal widths (`1-N` for N ≤ 6,
777
1414
  `expand` for N > 6).
778
1415
  - For an ASYMMETRIC row pass explicit tokens, one per column:
779
- `scaffold_section({ template_id, columns: 2, column_widths: ["2-3", "1-3"], etag })`
1416
+ `scaffold_section({ layout_id, columns: 2, column_widths: ["2-3", "1-3"], etag })`
780
1417
  → a row with `layout: "2-3,1-3"` and the matching column widths. A
781
1418
  wrong-length array or a bad token returns a friendly `400` before the write.
782
- - `scaffold_section({ template_id, columns: 1, etag })` → a single full-width
1419
+ - `scaffold_section({ layout_id, columns: 1, etag })` → a single full-width
783
1420
  column with no `row.layout` and no column width (the factory single-column
784
1421
  shape).
785
1422
 
@@ -787,7 +1424,20 @@ hull for you (row grid string + per-column responsive widths in one call):**
787
1424
  into them with `element_add({ parent_path: "<column-path>", ... })`. If you
788
1425
  build columns by hand with `element_add` instead, remember to set BOTH
789
1426
  `row.props.layout` (the grid string) AND each column's `width_default` /
790
- `width_medium`, or the row reads as "Whole" and shows gaps in the builder.
1427
+ `width_medium`, or the row reads as "Whole" and shows gaps in the builder:
1428
+
1429
+ ```jsonc
1430
+ // Hand-built alternative to scaffold_section — set the grid string on the row…
1431
+ yootheme_builder_element_add({
1432
+ layout_id, parent_path: "<section-path>", element_type: "row",
1433
+ props: { layout: "1-3,1-3,1-3" }, etag,
1434
+ })
1435
+ // …and the matching responsive width on EACH column.
1436
+ yootheme_builder_element_add({
1437
+ layout_id, parent_path: "<row-path>", element_type: "column",
1438
+ props: { width_default: "1-1", width_medium: "1-3" }, etag,
1439
+ })
1440
+ ```
791
1441
 
792
1442
  **Success criterion:** in the builder's left panel the row shows N distinct
793
1443
  columns (not one column + an empty gap), and after publish the columns sit
@@ -898,7 +1548,7 @@ token rotates (re-login, session timeout, a different tab), the preview
898
1548
  renders as a plain page without the builder-data injection and the panel
899
1549
  reports "not available". **Fix: reload the Customizer tab** (or re-open the
900
1550
  builder from Pages → the page → Builder). The stored layout is intact —
901
- confirm with `page_get_layout({ template_id })`, which reads it directly
1551
+ confirm with `page_get_layout({ layout_id })`, which reads it directly
902
1552
  and is unaffected by the browser session.
903
1553
 
904
1554
  ---
@@ -914,6 +1564,148 @@ version on a layout you read-modify-write is preserved, never overwritten.
914
1564
 
915
1565
  ---
916
1566
 
1567
+ ## Per-element render quirks (the single-element tail)
1568
+
1569
+ The rules that apply to a whole CLASS of props ride in the schema itself:
1570
+ `element_type_get_schema` returns `renders_only_if`, `placeholder_fallback`,
1571
+ `blanked_by_parent`, `media_autoswap`, per-field `value_hint` / `no_effect` /
1572
+ `empty_means_inherit`, and a `rules` id list resolved by a `rule_notes` map
1573
+ (the `enable`-vs-render disagreements, the equal-height model, the `striptags`
1574
+ on link toggles, positional dependence, the collapse cascade, iframe video, the
1575
+ Maps API key, and link attributes under a lightbox). **Read those from the
1576
+ response, not from here.** The response also states which build they were read
1577
+ on — see `claims_verified_on` above.
1578
+
1579
+ What follows is the tail: quirks that belong to ONE element and would cost more
1580
+ in schema bytes than they are worth. Each was read in the YOOtheme **5.0.37**
1581
+ source at the cited location — so on a site running a different build, treat
1582
+ this section the way `claims_verified_on: "differs"` asks you to treat the
1583
+ schema's own claims: a report to check, not a fact about your build.
1584
+
1585
+ | Element / prop | The quirk |
1586
+ |---|---|
1587
+ | `grid.grid_divider` | The divider class is emitted only when the grid has **more than one child** (`grid/templates/template.php:44`, `=> count($children) > 1`). A one-item grid silently has no divider. |
1588
+ | `grid.grid_parallax_justify` | Setting it while `grid_parallax` is empty **forces `grid_parallax` to `'0'`** (`grid/templates/template.php:10-11`). You cannot have justify without parallax being on. |
1589
+ | `grid.panel_style: 'tile-checked'` | A **parent-only** value: the checked pattern comes from `uk-grid-checked` on the grid container, driven by the container's own `panel_style` (`grid/templates/template.php:56`; the render transform branches on it at `grid/element.php:118`). Setting it on an item does nothing. |
1590
+ | `grid_item.tags` | Inert unless the **parent grid's `filter`** is on: the whole tag-collection block runs inside `if (!empty($node->props['filter']))` (`grid/element.php:74`). Tags are also `strip_tags()`'d and their keys have **spaces replaced by dashes** (`:80-83`), so "New York" becomes the key `New-York`. |
1591
+ | `grid_item.title_color` vs `title_decoration` | `title_color: 'background'` and `title_decoration: 'line'` are decided by one `if`/`elseif` (`grid_item/templates/template-content.php:112-114`) — with `background` set, `line` never renders. Not additive. |
1592
+ | `grid.image_vertical_align` | Stops centering once `panel_expand` is active: the class carries `{@!panel_expand}` (`grid_item/templates/template.php:129`). |
1593
+ | `section` without `width` | Still emits a container when the section has a **video or an overlay** (`section/templates/template.php:158`), and that container then also gets `uk-panel` (`:167`) — which zeroes the last child's bottom margin. A width-less section is not always container-less. |
1594
+ | `slideshow` nav (not `thumbnav`) | Borrows the **slide's `title`** as the link text (`slideshow/templates/template-nav.php`: `$thumbnail ?: $child->props['title']`). An untitled slide gets an empty nav link. |
1595
+ | `accordion_item` | Declares **no `title_element`** at all (`grep -c title_element` = 0 in both `accordion_item/element.php` and `accordion/element.php`), so accordion titles can never carry a heading tag and never enter the heading outline. Do not try to write one. |
1596
+
1597
+ ## Placing a module or widget
1598
+
1599
+ A module or widget can reach a page in three ways, and they are not
1600
+ interchangeable: they differ in how many pages the thing shows up on, in how
1601
+ many edits one change costs, and in what happens on a page nobody has thought
1602
+ about yet. `modules_create` therefore REQUIRES a `placement` argument and
1603
+ refuses a call without one as `placement_not_chosen`, answering with exactly
1604
+ the rows below for the platform in question, plus that platform's notes.
1605
+
1606
+ The three ways are the same on both platforms. What differs is the vocabulary,
1607
+ and one behaviour: Joomla can narrow a site-wide placement to chosen pages,
1608
+ WordPress cannot.
1609
+
1610
+ **Joomla** (module, template position):
1611
+
1612
+ | `placement` | Reach | What it does |
1613
+ | --- | --- | --- |
1614
+ | `position` | every page | The module sits in a template position such as top and appears on EVERY page. Pass the position with this call. No layout edit, and one module serves the whole site. To narrow it to chosen pages, set the menu assignment with modules_update. |
1615
+ | `module_element` | one page | A Module element inside a column of ONE page layout embeds this module, and it appears on that page only. Call scaffold_section first for a valid column path. |
1616
+ | `position_element` | one page | The module goes into one of the positions builder-1 to builder-6. On its own it appears NOWHERE. A Position element placed in a page layout is what renders it, and it then appears on that page only. |
1617
+
1618
+ Two notes travel with that answer:
1619
+
1620
+ - modules_create builds two native module types (mod_custom, mod_menu). A
1621
+ builder module (mod_yootheme_builder) must already exist. Once it does, all
1622
+ three placements work for it exactly as they do for a native module.
1623
+ - You may not need a module at all: a YOOtheme element such as alert placed
1624
+ straight into a page layout does the same job, at the cost of a separate copy
1625
+ per page.
1626
+
1627
+ **WordPress** (widget, widget area):
1628
+
1629
+ | `placement` | Reach | What it does |
1630
+ | --- | --- | --- |
1631
+ | `position` | every page | The widget sits in a widget area and appears on EVERY page. Pass the area with this call. No layout edit, and one widget serves the whole site. WordPress has no built-in per-page control, so narrowing it to chosen pages needs a plugin. |
1632
+ | `module_element` | one page | A Widget element inside a column of ONE page layout embeds this widget, and it appears on that page only. Needs a position: builder-1 to builder-6, the areas that render nothing on their own. Call scaffold_section first for a valid column path. |
1633
+ | `position_element` | one page | The widget goes into one of the areas builder-1 to builder-6. On its own it appears NOWHERE. An Area element placed in a page layout is what renders it, and it then appears on that page only. |
1634
+
1635
+ And its two notes:
1636
+
1637
+ - modules_create builds three native widget types (custom_html, text, block). A
1638
+ builder widget must already exist. Once it does, all three placements work
1639
+ for it exactly as they do for a native widget.
1640
+ - You may not need a widget at all: a YOOtheme element such as alert placed
1641
+ straight into a page layout does the same job, at the cost of a separate copy
1642
+ per page.
1643
+
1644
+ Which ways need a `position` argument is a PLATFORM fact since 2026-08-08. On
1645
+ JOOMLA it is two of the three: a real theme position (top, bottom, navbar) for
1646
+ `position`, one of builder-1 to builder-6 for `position_element`, and
1647
+ `module_element` takes none, because a module with no position is a native state
1648
+ there. On WORDPRESS it is ALL THREE — a widget exists only inside a registered
1649
+ area, so there is no positionless widget to create, and `module_element` takes
1650
+ builder-1 to builder-6. A real theme position under `module_element` stays
1651
+ refused on both, because the module would ALSO show site wide and render twice
1652
+ on that page. Every way that needs one is REQUIRED, never defaulted: omit it and
1653
+ the call creates NOTHING and comes back `placement_needs_a_position`. A position you DO name is checked against what
1654
+ this site declares or registers, and the two ways it can be wrong are answered
1655
+ by two different codes: a value this site has never heard of (or one too long
1656
+ to be a position at all) comes back as `placement_position_unknown`, and a
1657
+ value that is real but describes a different outcome than the way you chose
1658
+ comes back as `placement_position_conflict`. Each names which half of the call
1659
+ was meant, instead of guessing.
1660
+
1661
+ Two things the layout itself will not let you do: a module or module_position
1662
+ element belongs inside a `column`, never on the layout root (the root takes
1663
+ `section` only, and a root-level module renders outside the section flow, over
1664
+ the navigation on a transparent header), and a `module_position` element renders
1665
+ with YOOtheme's own default layout (`stack`) when you leave the `layout` prop
1666
+ out. Set it only if you want `grid`. Never copy the `sidebar` value the element
1667
+ schema reports as that field's default: it is not one of the two accepted values
1668
+ and is rejected with a 422.
1669
+
1670
+ **"Every page" above is the assignment a `position` placement makes. On
1671
+ Joomla it takes effect immediately: every module write evicts Joomla's own
1672
+ module-list cache exactly like the native module manager does. On WordPress
1673
+ the widget write goes through `update_option()`, which refreshes WordPress'
1674
+ own options cache the same way any core option save does - if the site runs
1675
+ a persistent object-cache drop-in, this server behaves exactly as WordPress
1676
+ itself does there, no better and no worse. If a write looks invisible
1677
+ anyway, you are almost certainly looking at one of two things. (1) On
1678
+ Joomla, the site caches aggressively and the module was written by an OLDER
1679
+ plugin version or by a tool that bypasses the module manager - Joomla's
1680
+ conservative cache then serves the pre-write module list for up to `cachetime`
1681
+ (15 minutes by default), while the database row is already correct. Clearing
1682
+ the site cache or waiting resolves it; nothing is wrong with the module. (2)
1683
+ The position is one of the `builder-N` slots, which never render standalone -
1684
+ they reach a page only through a Position/Area element. For a quick
1685
+ confirmation either way, call `local_content_read` with `include_usage:true`
1686
+ and `verify_on_urls:["<a page url>"]` and read `usage.observed.urls[].status`
1687
+ (`present`/`absent`/`not_detectable`/`error` - see T1.7 in the local-content
1688
+ chapter), or fetch the page as a guest and search for the module's own
1689
+ signature (`id="<widget-id>"` on WordPress, `id="module-<id>"` on Joomla).
1690
+
1691
+ **Before doing N per-page edits, check whether one site-wide module already
1692
+ covers the need.** If a site-wide Builder module already sits in the header
1693
+ area, adding the new content to that ONE module can replace a separate edit
1694
+ on every page. A `local_content_read({kind:"modules"})` pass (T1.6 shows
1695
+ what `usage` returns) tells you what is already there before you start a
1696
+ per-page loop.
1697
+
1698
+ **A module or widget placed BOTH in its position and as an element on the same
1699
+ page renders twice.** This is not a hypothetical: the T1.7 section of the
1700
+ local-content chapter documents it measured live on
1701
+ `dev5wp.wootsup.com/mcp-module-probe/`, where a widget carrying both an element
1702
+ reference and a position reference served its content twice on one document.
1703
+ Before adding a `module`/`module_position` element for something that is ALSO
1704
+ assigned a rendering position, check its `usage.element_refs` first (T1.6), or
1705
+ drop the position assignment.
1706
+
1707
+ ---
1708
+
917
1709
  ## Workflow 2: Bind a dynamic source to a grid
918
1710
 
919
1711
  **Goal:** Wire an existing Grid (or other multi-item element) to a
@@ -924,30 +1716,30 @@ renders dynamic items.
924
1716
 
925
1717
  1. `yootheme_builder_health`: confirm host plugin reachable.
926
1718
  2. `yootheme_builder_pages_list({ fields: ["id", "label"] })` and
927
- `yootheme_builder_page_get_layout({ template_id: "<id>", flat: false })`:
1719
+ `yootheme_builder_page_get_layout({ layout_id: "<id>", flat: false })`:
928
1720
  locate the target Grid. Note its JSON-Pointer `path` (e.g.
929
1721
  `/children/0/children/2/children/0`).
930
- 3. `yootheme_builder_element_get({ template_id, element_path })`:
1722
+ 3. `yootheme_builder_element_get({ layout_id, element_path })`:
931
1723
  fetch the Grid's current props so you can preserve them. Binding
932
1724
  sets `props.source` and leaves the rest alone.
933
1725
  4. `yootheme_builder_sources_list()`: enumerate available Sources.
934
1726
  Each returns `{ name, label, origin, kind }`. Pick the one the
935
1727
  user asked for.
936
- 5. `yootheme_builder_element_get_binding({ template_id, element_path })`
1728
+ 5. `yootheme_builder_element_get_binding({ layout_id, element_path })`
937
1729
  check whether the Grid is already bound (idempotency: skip step
938
1730
  7 if `source_name` already matches the user's intent).
939
1731
  `element_get_binding` is a first-class L1 tool (B4) — call it directly.
940
1732
  6. `yootheme_builder_get_etag()`: fetch the optimistic-lock etag for
941
1733
  the upcoming mutation.
942
- 7. `yootheme_builder_element_bind_source({ template_id, element_path, source_name: "<name>", etag: "<etag>" })`
1734
+ 7. `yootheme_builder_element_bind_source({ layout_id, element_path, source_name: "<name>", etag: "<etag>" })`
943
1735
  applies the binding. Returns `{ path, etag, has_binding: true }`.
944
1736
  Pass `source_id: "<origin>:<name>"` as well **only** when two
945
1737
  plugins register a source with the same `source_name` (the server
946
1738
  surfaces the ambiguity as an elicitation prompt; if the host
947
1739
  doesn't support elicitation you'll see a structured error listing
948
1740
  the candidates). This is a first-class L1 tool — call it directly.
949
- 8. `yootheme_builder_page_save({ template_id, etag: "<fresh>" })`
950
- then `yootheme_builder_page_publish({ template_id, etag: "<fresh>" })`.
1741
+ 8. `yootheme_builder_page_save({ layout_id, etag: "<fresh>" })`
1742
+ then `yootheme_builder_page_publish({ layout_id, etag: "<fresh>" })`.
951
1743
 
952
1744
  **Common pitfalls:**
953
1745
 
@@ -957,9 +1749,27 @@ renders dynamic items.
957
1749
  `relation`/`field`, `arguments` and `directives`; see the Tool Catalog
958
1750
  appendix for the full `element_bind_source` input-key list rather than
959
1751
  guessing a subset.
960
- - **Wrong parameter names.** Use `template_id` (not `pageId`),
1752
+ - **Wrong parameter names.** Use `layout_id` (not `pageId`),
961
1753
  `element_path` (not `path`), `source_name` (not `sourceName`),
962
1754
  `etag` (not `ifMatch`).
1755
+ - **A misspelled `field_mappings` KEY is refused, and be glad it is.** The keys
1756
+ are element PROPS; the values are source FIELDS. A key that is not a prop of
1757
+ the target element is rejected with `422 unknown_prop_keys`, listing
1758
+ `valid_prop_keys` and `did_you_mean`, and nothing is written. The reason is
1759
+ the ASYMMETRY, and the honest form of it is two measurements that disagree:
1760
+ on a native `articles` source, `{title, content}` rendered 10 rows while
1761
+ `{titel, content}` rendered **0** — the untouched `content` included; on a
1762
+ `grid_item` at an API-Mapper list source, the same substitution left all 5
1763
+ rows standing and cost only that slot. So a misspelled key sometimes empties
1764
+ the WHOLE binding and sometimes only its own slot, `element_get_binding`
1765
+ reads either back as healthy, and which one you get is not established. A
1766
+ wrong refusal costs you one call and tells you the valid names; a wrong
1767
+ acceptance can cost the page in silence. If you genuinely need a prop this
1768
+ build does not declare, `element_set_source` writes the carriers verbatim and
1769
+ only warns.
1770
+ (This is the opposite of the free `props` path — see Workflow 5, where an
1771
+ undeclared key is stored with a warning, because there real layouts carry
1772
+ such props and refusing them would refuse YOOtheme's own work.)
963
1773
  - **Source not in the list.** API Mapper sources only appear once
964
1774
  they're PUBLISHED in API Mapper (not just saved). If you bind an
965
1775
  unknown `source_name`, `element_bind_source` rejects it and the error
@@ -974,8 +1784,9 @@ renders dynamic items.
974
1784
  Switcher, Table) accept a source binding. Binding a single-item
975
1785
  element like Headline returns a structured `validation` error.
976
1786
  - **Forgetting `etag`.** Every write requires the optimistic-lock
977
- etag. On `412 Precondition Failed` re-fetch via
978
- `yootheme_builder_get_etag` and retry.
1787
+ etag. On `412 Precondition Failed`, re-read what you were writing
1788
+ first a fresh etag alone makes the retry succeed without telling you
1789
+ whether someone else's change is what you are about to overwrite.
979
1790
  - **Mis-routing bind vs the read helpers.** `element_bind_source` AND
980
1791
  its verify sibling `element_get_binding` (B4) are both first-class L1
981
1792
  tools — call them directly by name. Only `element_unbind_source` stays
@@ -988,7 +1799,7 @@ renders dynamic items.
988
1799
  // Step 7. Bind a Posts source onto a Grid element. element_bind_source
989
1800
  // is a first-class L1 tool — call it directly.
990
1801
  yootheme_builder_element_bind_source({
991
- template_id: "home",
1802
+ layout_id: "home",
992
1803
  element_path: "/children/0/children/2/children/0",
993
1804
  source_name: "wp_posts",
994
1805
  etag: "abc123"
@@ -997,16 +1808,55 @@ yootheme_builder_element_bind_source({
997
1808
  // Response: { rel_path: "/children/0/children/2/children/0", etag: "def456", has_binding: true }
998
1809
  // Verify (direct L1 call):
999
1810
  yootheme_builder_element_get_binding({
1000
- template_id: "home", element_path: "/children/0/children/2/children/0"
1811
+ layout_id: "home", element_path: "/children/0/children/2/children/0"
1001
1812
  })
1002
1813
  // → { source_name: "wp_posts", source_config: { ... }, ... }
1003
1814
  ```
1004
1815
 
1005
- **Edge case:** A Source can render zero items at runtime (e.g. empty
1006
- search filter). The bind call still succeeds; the front-end Grid just
1007
- shows the YOOtheme "no items" placeholder. Don't treat empty render
1008
- as a binding failure. Verify by re-reading
1009
- `yootheme_builder_element_get_binding` directly.
1816
+ **Edge case:** A Source can render zero items at runtime (e.g. an empty
1817
+ search filter, or an archive source on a single page that has no term to
1818
+ scope to). The bind call still succeeds; the front-end Grid just shows the
1819
+ YOOtheme "no items" placeholder. That is a DATA state, not a broken binding.
1820
+
1821
+ **But do not verify it by re-reading the binding.** `element_get_binding`
1822
+ answers "is a source attached", which is a different question from "does it
1823
+ deliver rows", and no amount of re-reading it will ever turn into the second
1824
+ answer. A customer once bound, published, looked, re-bound with `limit`,
1825
+ published, looked again, and only then ran `render_preview` — two full cycles
1826
+ for one fact.
1827
+
1828
+ There is ONE thing the re-read does now answer, and it is the one a blank card
1829
+ sends you looking for: if a `field_mappings` VALUE names a source field the
1830
+ source does not emit, `element_get_binding` says so — the same three keys the
1831
+ bind answers with, under the same names: `unknown_source_field_values`,
1832
+ `unknown_source_field_values_available` (what you should have written) and
1833
+ `unknown_source_field_values_hint`. That warning used to appear exactly once, at the
1834
+ moment of writing, so whoever debugged a blank card LATER read the state back
1835
+ and saw a binding indistinguishable from a healthy one. Absence of the keys
1836
+ means "nothing to report OR the source's field set could not be resolved here",
1837
+ never "checked and fine" — and it says nothing about the KEY side: a misspelled
1838
+ element prop is still read back as healthy, which is why the bind refuses it up
1839
+ front instead.
1840
+
1841
+ What the tools give you instead:
1842
+
1843
+ - Every bind answers with a `checks` block: `source_exists`, `field_mappings`,
1844
+ `field_mapping_props_checked`, `container_item_ok`. It costs nothing and is
1845
+ always there. It also states its own limit — none of them says whether rows
1846
+ arrive. Read the pair `field_mappings` + `field_mapping_props_checked`
1847
+ together: the first is a COUNT, not a verdict (a customer read
1848
+ `field_mappings: 2` as confirmation while one of the two was a typo), the
1849
+ second says whether those keys were measured against the element's declared
1850
+ props at all — `false` means the type's field list did not resolve here, not
1851
+ that a check failed.
1852
+ - `element_bind_source({… , probe: true})` renders the binding and answers
1853
+ `probe: {rows, rendered, empty, probed_path}`; at zero rows it adds a warning
1854
+ naming the likely causes. It costs a real render (~0.3-0.6s), so it is off by
1855
+ default. `probed_path` is the node that was rendered — the nearest container,
1856
+ because a `grid_item` renders nothing outside its `grid`.
1857
+ - `page_publish` / `page_save` report `binding-renders-nothing` in `quick_check`
1858
+ when the page's render produced zero rows and something on it was supposed to
1859
+ iterate. That one is free: the publish already renders for its own precheck.
1010
1860
 
1011
1861
  **Success criterion:** After publish, the Grid on the front-end shows
1012
1862
  items from the Source (verify by item count and at least one
@@ -1067,58 +1917,476 @@ a row layout. (Cross-template duplication is **not** supported by
1067
1917
  `element_clone`. See "Important scope note" below.)
1068
1918
 
1069
1919
  **Important scope note:** `yootheme_builder_element_clone` is
1070
- **sibling-only and intra-template**. Its real schema is
1071
- `{ template_id, element_path, etag }`. There is **no** `destPageId`
1072
- or `destParentPath`. The cloned element lands at the same parent,
1073
- right after the source. To move the clone elsewhere in the SAME
1074
- template, call `yootheme_builder_element_move` afterwards. To
1075
- duplicate into a DIFFERENT template, flag to the user that
1076
- cross-template clone is not currently supported and suggest a
1077
- CMS-level template duplication (in wp-admin or Joomla administrator).
1920
+ **intra-template**. Its real schema is
1921
+ `{ layout_id, element_path, etag, to_parent_path?, to_index? }`.
1922
+ There is **no** `destPageId` and no cross-template argument. Omit
1923
+ `to_parent_path` and the copy lands at the same parent, right after
1924
+ the source; pass it and the copy lands under that parent instead
1925
+ (`to_index` picks the slot, default append) the follow-up
1926
+ `element_move` this used to require is no longer needed. To duplicate
1927
+ into a DIFFERENT template, flag to the user that cross-template clone
1928
+ is not currently supported and suggest a CMS-level template
1929
+ duplication (in wp-admin or Joomla administrator).
1078
1930
 
1079
1931
  **Canonical tool-call sequence (real parameter names, snake_case):**
1080
1932
 
1081
1933
  1. `yootheme_builder_health`: confirm host plugin reachable.
1082
1934
  2. `yootheme_builder_pages_list({ fields: ["id", "label"] })`:
1083
1935
  locate the template by `label`. Note its `id`.
1084
- 3. `yootheme_builder_advanced({ tool: "yootheme_builder_page_get_schema", arguments: { template_id } })`
1085
- returns a flat schema view (lighter than `page_get_layout`) showing every
1086
- element path + type. Pick the JSON-Pointer path of the section
1087
- to clone. `page_get_schema` is L2; call via the gateway.
1936
+ 3. `yootheme_builder_element_list({ layout_id })`
1937
+ returns a flat view (lighter than `page_get_layout`) showing every
1938
+ element path + type + label + binding. Pick the JSON-Pointer path of the
1939
+ section to clone. `element_list` is L1; call it directly, no gateway.
1088
1940
  4. `yootheme_builder_get_etag()`: fetch the optimistic-lock etag.
1089
- 5. `yootheme_builder_element_clone({ template_id, element_path: "<src-path>", etag: "<etag>" })`
1941
+ 5. `yootheme_builder_element_clone({ layout_id, element_path: "<src-path>", etag: "<etag>" })`
1090
1942
  clones as sibling. Returns `{ path: "<new-path>", etag: "<fresh>" }`.
1091
1943
  The new path is at the same parent, immediately after the source.
1092
- 6. (Optional) `yootheme_builder_element_move({ template_id, element_path: "<new-path>", to_parent_path: "<other-parent>", to_index: 0, etag: "<fresh>" })`
1093
- re-parents the clone within the same template if needed.
1094
- 7. `yootheme_builder_element_update_settings({ template_id, element_path: "<final-path>", props: { ... }, etag: "<fresh>" })`
1944
+ 6. (Optional) To land the copy somewhere other than beside its source,
1945
+ pass the destination to step 5 directly:
1946
+ `yootheme_builder_element_clone({ layout_id, element_path: "<src-path>", to_parent_path: "<other-parent>", to_index: 0, etag: "<etag>" })`.
1947
+ `yootheme_builder_element_move` remains the way to relocate an
1948
+ element that already exists.
1949
+ 7. `yootheme_builder_element_update_settings({ layout_id, element_path: "<final-path>", props: { ... }, etag: "<fresh>" })`
1095
1950
  replaces the `props` on the clone. **Existing props NOT in the
1096
1951
  request are removed** (update_settings is a full replace by default;
1097
1952
  pass `merge: true` to apply a server-side deep-merge instead).
1098
1953
  Read the current props first via `yootheme_builder_element_get`
1099
1954
  if you only want to tweak a subset.
1100
- 8. `yootheme_builder_page_save({ template_id, etag })`
1101
- then `yootheme_builder_page_publish({ template_id, etag })`.
1955
+ 8. `yootheme_builder_page_save({ layout_id, etag })`
1956
+ then `yootheme_builder_page_publish({ layout_id, etag })`.
1102
1957
 
1103
1958
  **Common pitfalls:**
1104
1959
 
1105
1960
  - **Inventing destination parameters.** `element_clone` does NOT
1106
- accept `destPageId`, `destParentPath`, or any cross-template
1107
- argument. It's sibling-only within ONE template.
1961
+ accept `destPageId` or any cross-template argument. Its destination
1962
+ is optional and stays within ONE template: `to_parent_path` plus
1963
+ `to_index`, both snake_case.
1108
1964
  - **Treating `element_update_settings` as a merge by default.** The handler
1109
1965
  REPLACES the entire `props` object on the element unless you pass
1110
1966
  `merge: true`. Read the existing shape via `yootheme_builder_element_get`
1111
1967
  first if you only want to tweak a subset and prefer not to use merge.
1968
+ - **Reading a prop's value never needs a second call.** `element_get`'s
1969
+ output, both the JSON and the printed summary, lists every prop KEY
1970
+ together with its VALUE (e.g. `title_element: "h1"`), each value capped
1971
+ at roughly 200 characters with a visible `…(+N chars)` marker when
1972
+ truncated, never a silent drop. There's no need to cross-check via
1973
+ `element_list` just to see what a specific prop is currently set to.
1974
+ - **A `module`/`module_position` element is not a dead end either (T1.4).**
1975
+ On its own, a WordPress Widget or Joomla Module element's `props` are just
1976
+ a reference (`{widget: "custom_html-1"}` / `{module: 42}`) — no title, no
1977
+ content, no settings. `element_get` on that node ALSO carries a `resolved`
1978
+ field: the referenced widget/module's own row (title/type/settings/
1979
+ theme_overlay — the SAME shape `local_content_read(kind:"modules")`
1980
+ returns), or `resolved: null` plus a `resolved_reason` string when it can't
1981
+ be resolved (missing reference, deleted id, empty position) — never a
1982
+ throw. A `module_position` node resolves to `{position, modules: [...]}`,
1983
+ every widget assigned there. There's no need to separately call
1984
+ `local_content_read` just to see what a module element actually shows.
1985
+ - **"Where does this widget/module appear?" — `include_usage:true` (T1.6).**
1986
+ `local_content_read({ kind: "modules", include_usage: true })` adds a `usage`
1987
+ object per row: `element_refs` is the EXACT `{layout_id, path,
1988
+ via:"element"|"position"}` list of every layout location that reaches this
1989
+ widget/module — `via:"element"` is a DIRECT `module` element reference,
1990
+ `via:"position"` is an INDIRECT one (a `module_position` element pointing at
1991
+ this row's own `position`) — so you know whether to edit the element or the
1992
+ position assignment. `declared.position_visibility` classifies the row's
1993
+ `position` into `site-wide` (a theme panel/area — appears wherever that
1994
+ position renders), `never-standalone` (a `builder-1`..`builder-6` position —
1995
+ RENDERS ONLY through a `module_position` element, never on its own — live
1996
+ A/B-proven on Joomla), or `not-on-builder-pages` (`sidebar` — not rendered on
1997
+ Builder pages); null when the row has no position at all.
1998
+ `declared.assigned_pages` is Joomla-only real assignment info (`mode`
1999
+ `all`/`except`/`selected`/`none` resolved to concrete menu-item titles); on
2000
+ WordPress it is the explicit platform fact `{applicable:false, reason:
2001
+ "WordPress widget areas are global — widgets are not assigned to individual
2002
+ pages."}` — never simply absent. `declared` states what the CONFIGURATION
2003
+ claims, not what a visitor actually sees — a visibility plugin (Advanced
2004
+ Module Manager, Widget Context, …) or a time-gated rule can still override
2005
+ it. Default `false`: the layout scan this costs is never paid unasked.
2006
+ **A row with NO `usage` is not automatically a row you did not ask about.**
2007
+ There are THREE states and the key `usage_unavailable` is what separates the
2008
+ last two: neither `usage` nor `usage_unavailable` means `include_usage` was
2009
+ not requested; `usage` present means the enrichment ran; `usage_unavailable`
2010
+ present means it WAS requested and FAILED for that row. The row's primary
2011
+ fields (id, title, position, active, ...) stay fully trustworthy either way:
2012
+ an optional enrichment never deletes the answer it was decorating. Read
2013
+ `usage_unavailable` before concluding "this widget is used nowhere": on such
2014
+ a row nothing at all is known about where it appears.
2015
+ - **"What does a GUEST actually get served?" — `verify_on_urls` (T1.7).** Pair
2016
+ it with `include_usage:true` to add `usage.observed.urls[]` per row: fetches
2017
+ each url ONCE, anonymously, and checks whether this row's DOM signature
2018
+ (`id="<widget-id>"` on WordPress, `id="module-<id>"` on Joomla) appears —
2019
+ PLUGIN-AGNOSTIC, since it reads the rendered result, never any plugin's
2020
+ rules, so every visibility mechanism is covered without a line of
2021
+ plugin-specific code. Per url: `present`, `absent`, `not_detectable`, or
2022
+ `error`. `not_detectable` — NOT `absent` — covers FOUR cases, all meaning a
2023
+ missing signature proves nothing. **(1)** A `builder-1`..`builder-6`
2024
+ (`never-standalone`) position with no `via:"position"` reference: such a
2025
+ position renders EXCLUSIVELY through an explicit `module_position`
2026
+ element, so with none swept, YOOtheme's element-bound (DOM-anonymous, no id
2027
+ at all) rendering can't be ruled out. **(2)** The row sits in no position at
2028
+ all (empty `position` — an unplaced WordPress widget, Joomla's "NONE"
2029
+ group): nothing is expected on any url either way, so a missing signature
2030
+ says nothing about it. **(3)** The row is ALSO placed as a `module` ELEMENT
2031
+ **on the template THIS url renders**: that path carries no DOM id either, so
2032
+ even on an otherwise checkable position a not-found signature cannot tell
2033
+ "not rendered" from "rendered anonymously". MEASURED live on
2034
+ `dev5wp.wootsup.com/mcp-module-probe/`: `custom_html-1` has both an
2035
+ `element` and a `position` reference, and one document served its content
2036
+ **twice** while `id="custom_html-1"` appeared **once** — the element-rendered
2037
+ copy is `<div class="uk-panel widget widget_custom_html">`, with no instance
2038
+ attribute at all. The signature is not widened to cover it because the only
2039
+ token there is the widget TYPE class, which occurs 3× on that same page: a
2040
+ wider match would trade a false `absent` for a false `present`.
2041
+ **(4)** Where that element renders **could not be determined** — either the
2042
+ url did not map to a template (`pages_resolve` declines it; a Joomla site
2043
+ root served by a theme template is the common case), or the element sits on
2044
+ a THEME template, which governs many urls and is not enumerated here.
2045
+ **Cases (3) and (4) are decided PER URL, not per row.** An element reference
2046
+ on ANOTHER page cannot render on this url — a per-page layout renders on
2047
+ exactly its own page — so there you get the measured `present`/`absent`
2048
+ instead of a withheld answer. MEASURED on both platforms: `custom_html-1`'s
2049
+ element reference is on `page:2987` while the queried url was the site root
2050
+ (`page:1`), an anonymous fetch found 0 signature hits and 0 content hits, and
2051
+ `absent` is the correct decidable answer. The `reason` on every
2052
+ `not_detectable` names which case applies and, for (3)/(4), which template.
2053
+ Any OTHER
2054
+ position (`site-wide` — a real theme panel like navbar/header/a template's
2055
+ own `bottom`/`top` — or `not-on-builder-pages`) renders
2056
+ DIRECTLY from the theme with no element needed at all, so it's checkable
2057
+ even with completely empty `element_refs` — provided it has no `element`
2058
+ reference either. `error` = no observation was
2059
+ obtained from this url: a network failure, a timeout, a non-2xx, or a url
2060
+ the same-origin guard refused (off-site host, private address, disallowed
2061
+ scheme) — never treated as `absent`, never a throw. An `absent` result on a
2062
+ row `declared` calls unconditionally
2063
+ visible (`builder_visible:true`) adds a `discrepancy` — a named SUSPICION
2064
+ (a visibility plugin, a time-gated rule, a stale cache), never phrased as a
2065
+ diagnosis, and never attached to `not_detectable`/`error`.
2066
+ **Redirects are followed by this tool, bounded, and REPORTED.** When a url
2067
+ ends somewhere else, its entry carries `final_url` — the page whose markup
2068
+ was actually searched. That changes what the result means: `/contact` → 301
2069
+ → `/kontakt` scans the KONTAKT page, so a module assigned to `/contact`'s
2070
+ own menu item is not expected in it, and the `discrepancy` then LEADS with
2071
+ the measured redirect instead of naming a visibility plugin. Re-check with
2072
+ the `final_url` before suspecting anything else. Every hop is re-checked
2073
+ against the same-origin guard, so a redirect off-site is refused with its
2074
+ own reason rather than silently fetched. Momentary
2075
+ snapshot per url, a sample (max 10 urls) — never a full crawl. Omit
2076
+ (default): zero requests, `observed` stays absent.
2077
+ - **"What's on THIS page from widgets/modules?" — `include_modules` (T1.8), the
2078
+ reverse of T1.6/T1.7.** Where `local_content_read`'s `usage` answers "for
2079
+ this ONE module, which pages?", `include_modules:true` on
2080
+ `yootheme_builder_page_get_layout` (→ `modules_on_page`) and
2081
+ `yootheme_builder_pages_audit` (→ `modules_by_page[layout_id]`) answers
2082
+ "for THIS page, which modules?" — the SAME D11 index, read backwards, never
2083
+ a second scan. Each result splits `from_elements` (a `module`/
2084
+ `module_position` element placed DIRECTLY in this page's own layout) from
2085
+ `from_positions` (a `site-wide` theme position — navbar/header/footer —
2086
+ rendering here with NO element in this page's layout at all). On Joomla, a
2087
+ `site-wide` module can still be assignment-EXCLUDED from one specific page
2088
+ (`except`); when that decision can't be resolved for this page (no menu
2089
+ item addresses it, or more than one does), the row lands in
2090
+ `from_positions_undecidable` instead of being guessed in or out. Default
2091
+ `false`: the layout sweep this costs is never paid unasked, and the
2092
+ response is byte-identical without it.
2093
+ - **A short list is never silently a FAILED list — two presence-only keys say
2094
+ so.** `unresolved_positions` appears when a `module_position` element on
2095
+ that page could not be resolved (a database error, unavailable widget
2096
+ APIs), and `unresolved_module_universe` appears when the site's module
2097
+ store could not be read AT ALL — which makes `from_positions` unknowable
2098
+ rather than empty. Both are emitted ONLY on failure, so a healthy answer
2099
+ omits them and is byte-identical to before. If you see either, the answer
2100
+ is INCOMPLETE: do not conclude "this page has no modules", and above all do
2101
+ not create a replacement module to "fix" it.
2102
+ - **The same rule for reading, one level up: an empty `local_content_read` is
2103
+ never a failed one.** A read that did not run to completion answers
2104
+ `error_code: local_content_unavailable` (HTTP 503), never a 200 with
2105
+ `{items:[], total:0}` — so an empty-and-OK response can only mean the site
2106
+ genuinely has none of that kind. The landscape tool
2107
+ (`local_content_types`) is the deliberate exception: it is a composite of
2108
+ independent sub-reads, so instead of failing the whole call it keeps the
2109
+ parts it could read and lists the rest under `degraded` — every count named
2110
+ there is a placeholder, not a measurement.
2111
+ - **"Now change what you found" — `modules_update` (W2), the write
2112
+ counterpart of `local_content_read(kind:"modules")` / `include_modules`.**
2113
+ Updates a widget's (WordPress) or module's (Joomla) SAFE writable subset —
2114
+ `title`, `position`, `active`, `theme_overlay` on BOTH platforms; `showtitle`,
2115
+ `ordering`, `access`, `assignment` on JOOMLA ONLY (WordPress rejects those
2116
+ four with a 400). `settings`/`params`/`content` are NOT in the schema at all
2117
+ — content editing stays the CMS editor's job; this tool is the
2118
+ display/placement layer.
2119
+ `dry_run` DEFAULTS true: a bare call inspects and writes NOTHING, returning
2120
+ the `{before, after, changed, notes, after_read_back}` diff of what a real
2121
+ write WOULD do.
2122
+ Every field you do NOT pass stays byte-identical — proven at the SQL/option-write
2123
+ level, not merely "looks unchanged".
2124
+ **A real write REPORTS WHAT IT READ BACK, not what you asked for.** After
2125
+ committing, the Joomla arm re-reads the stored record and fills `after` and
2126
+ `changed` from that read. So a field that did not land is reported at its
2127
+ STORED value, with a `notes` entry naming the divergence — never echoed back
2128
+ as if it had succeeded. This is the ONE way a dry run's answer and a real
2129
+ write's answer can differ, and it is deliberate: measured on Joomla,
2130
+ `modules_update({showtitle:false, ordering:3})` once answered
2131
+ `after.showtitle:false` about a row that held `3`. If the verification read
2132
+ itself fails, the write still stands and `notes` says the answer is
2133
+ unverified rather than pretending it was checked. That holds per FIELD, not
2134
+ only for the whole row: `assignment` is read back from a second table, and
2135
+ when THAT read fails the requested assignment is kept and named unverified,
2136
+ never replaced by the `none` an unread table looks like.
2137
+ **`after_read_back` tells a MEASURED `changed` entry from a merely REQUESTED
2138
+ one.** It lists exactly the keys whose `after` came out of storage. A key
2139
+ named in `changed` but ABSENT from `after_read_back` is a transition this
2140
+ call asked for and did not measure — the write committed, so `changed` keeps
2141
+ naming it, but check it before treating it as landed. Measured on Joomla with
2142
+ the `#__modules_menu` read-back failing after a committed write:
2143
+ `changed: ["title","ordering","assignment"]` with
2144
+ `after_read_back: ["title","ordering"]`. Before this field existed those three
2145
+ were indistinguishable, and the only qualification was a `notes` sentence.
2146
+ A DIVERGING read still counts as read back: this list answers "did we look?",
2147
+ never "did we like what we saw?". It is `[]` for every dry run (nothing was
2148
+ written, so nothing could be read back) and always `[]` on WordPress — a
2149
+ plain statement, never a failure signal.
2150
+ **On WORDPRESS there is no read-back, and `after` is what you REQUESTED.**
2151
+ Not an oversight: a widget's instance is one serialised `wp_options` value,
2152
+ so the per-field storage divergence the Joomla read-back exists to catch
2153
+ cannot arise, and the failure that CAN arise, the write not reaching storage,
2154
+ is raised as an ERROR rather than shown as a diverging `after`. So on
2155
+ WordPress do not read `after` as "checked against storage"; read it as "this
2156
+ is what was asked, and it did not fail".
2157
+ **Every value is TYPE-CHECKED, never coerced.** Send real JSON types: the
2158
+ string `"false"` is NOT the boolean `false` (PHP reads a non-empty string as
2159
+ TRUE, so a coerced `"false"` would have PUBLISHED the module), `"7abc"` is
2160
+ not the integer `7`, and an object is not a `title`. A wrong type is
2161
+ REFUSED with a 400 naming the expected one — this tool never rewrites a
2162
+ value it did not understand.
2163
+ **`notes` is the channel for "correctly did nothing, and that is not the
2164
+ end of it".** It is a list of plain sentences, usually empty. The case that
2165
+ motivated it: a Joomla module with `published = 1` whose `publish_up` is in
2166
+ the FUTURE reads `active:false`, so `{active:false}` is a genuine no-op with
2167
+ `changed: []` — and the module still goes live on schedule. `notes` says so
2168
+ instead of letting an empty `changed` read as "already handled".
2169
+ **Read `notes` whenever it is NON-EMPTY — an empty `changed` is not the
2170
+ trigger.** This used to say "read it whenever `changed` comes back empty",
2171
+ and that sent readers past the note that matters most: the one saying a field
2172
+ in a NON-empty `changed` was never read back. A note is attached exactly when
2173
+ something needs saying, so its presence is the signal.
2174
+ **One consequence is REPORTED in `notes`, not refused: the module will render
2175
+ TWICE.** If a Module/Widget element already embeds this module and you move it
2176
+ to a position that renders it on its own — a real theme position or widget
2177
+ area, not `builder-1`..`builder-6` — it appears once through each element and
2178
+ once through the position. `modules_create` refuses that same combination up
2179
+ front (`placement_position_conflict`); this verb has no `placement` argument
2180
+ to refuse it with, and both CMSs allow the move in their own UI, so blocking
2181
+ it here would be this tool inventing a restriction. The note names the
2182
+ elements and both ways back to one appearance. It rides on a `dry_run` too,
2183
+ which is where it is worth most.
2184
+ **`position_verified: false` reports on a check that did NOT happen, and it
2185
+ means here exactly what it means on `modules_create`.** Both verbs ask the
2186
+ same rule the same question, so the same site answers the same way: when the
2187
+ site's position list could not be read from there AND the name is not one of
2188
+ `builder-1` to `builder-6` (those are taken by name and need no list), the
2189
+ position is WRITTEN and the answer carries this field. The move happened; the
2190
+ check behind "an unknown position is refused BY NAME" did not, so a typo is
2191
+ still a typo. The field is ABSENT in the two cases with nothing to report —
2192
+ the value WAS vouched for, or this call wrote no position at all — so read
2193
+ `after.position` to tell those apart. A `dry_run` never carries it, and
2194
+ neither does an update that names no position: it reports on a position THIS
2195
+ call wrote, never on the one the module already had.
2196
+ On WordPress, `position` must be a REGISTERED widget area
2197
+ (`$wp_registered_sidebars` — on a YOOtheme site that is the 13 theme
2198
+ positions plus `builder-1`..`builder-6`); WordPress core does NOT validate
2199
+ this itself, so a typo used to park the widget in a phantom area that
2200
+ renders nowhere while the response reported success. `theme_overlay` MERGES onto the stored overlay: a
2201
+ key you OMIT keeps its current value; only the keys you explicitly include
2202
+ are written (so calling `theme_overlay:{style:"card-alt"}` never resets
2203
+ `visibility` or any other already-set key). The overlay's key list is
2204
+ PLATFORM-SPECIFIC — WordPress `_theme` (17 keys, incl. `showtitle`) vs.
2205
+ Joomla `params.yoo_config` (18 keys, incl. `language_parent_icon`/
2206
+ `language_icon` INSTEAD of `showtitle`) — an unknown or wrong-platform key
2207
+ (or a wrong value type) is REJECTED with a 400 naming every allowed key,
2208
+ never silently dropped, never coerced. Two further writes are REFUSED
2209
+ outright rather than half-kept, because the stored state cannot be
2210
+ expressed by the field you sent: a `title` on a WordPress `block` widget
2211
+ (that type has NO title — its instance is `{content}` only, WordPress never
2212
+ renders one, and storing it would additionally SUPPRESS the preview title
2213
+ derived from the block markup), and an `active:true` on a Joomla module
2214
+ whose publish window would still leave it `scheduled`/`expired`. On Joomla
2215
+ `active` means the `published` column AND the `publish_up`/`publish_down`
2216
+ window — the SAME derivation `local_content_read` uses — so a module
2217
+ scheduled for the future already reads `active:false`, and "deactivating"
2218
+ it is correctly a no-op instead of silently cancelling the schedule — with
2219
+ a `notes` entry so the no-op is not mistaken for "already handled".
2220
+ `access` (Joomla only) is the module's VIEW LEVEL, not a flag: Joomla
2221
+ filters modules by `access IN (authorised view levels)`, and `access:0` is
2222
+ in NOBODY's set — not even a Super User's — so such a module renders for no
2223
+ one while looking published. That is what `local_content_read` reports as
2224
+ `builder_visible:false`; `modules_update({access: 1})` is the repair.
2225
+ `access:0` itself is refused, because 0 is the very state being repaired.
2226
+ Advanced/gateway-only.
2227
+ - **"Build one from scratch" — `modules_create` (W3), then `element_add` —
2228
+ the two-step recipe.** `modules_create` supports exactly FIVE known types:
2229
+ WordPress `custom_html` (title+content), `text` (title+text), `block`
2230
+ (content only, NO title); Joomla `mod_custom` (title+content COLUMN+
2231
+ showtitle), `mod_menu` (title + the params keys menutype/base/startLevel/
2232
+ endLevel/showAllChildren/tag_id/class_sfx/window_open/layout/
2233
+ moduleclass_sfx/cache/cache_time/cachemode). An unknown type is rejected
2234
+ with this exact list. `placement` is REQUIRED (`position`/`module_element`/
2235
+ `position_element`, i.e. how the module reaches a page); a call that omits it
2236
+ creates NOTHING and instead returns `ways`, every way for this platform with
2237
+ its reach and a summary, plus `notes`. `content`/`text` are stored
2238
+ byte-for-byte, same no-sanitization guarantee as `modules_update`.
2239
+ **`position` is ONE argument with ONE meaning on both platforms**: the
2240
+ template position or widget area the module goes into, validated SERVER-side
2241
+ against the positions this site declares or registers, because the set of
2242
+ areas is a property of the SITE and no schema here can know it. **Joomla
2243
+ takes it AT CREATE TIME**, exactly as WordPress does; it stopped being a
2244
+ `modules_update`-only concern on 2026-08-06. The value must agree with
2245
+ `placement` or the call is refused BY NAME rather than guessed:
2246
+ `placement_needs_a_position` for `placement:"position"` OR
2247
+ `placement:"position_element"` with no position (both ways need one and they
2248
+ need different values, so neither is defaulted),
2249
+ `placement_position_conflict` for a `builder-N` position under
2250
+ `placement:"position"` (a builder position renders nothing on its own), for a
2251
+ REAL theme position under `placement:"module_element"` (measured on both
2252
+ platforms: the module then renders TWICE on that page), or for a non-builder
2253
+ position under `placement:"position_element"`; and
2254
+ `placement_position_unknown` for a value this site does not declare at all,
2255
+ or that could not be a position at all because it is longer than 50
2256
+ characters. That 50 is the width of the JOOMLA column that stores a position;
2257
+ WordPress keeps widget areas as option keys with no width of their own, and
2258
+ the bound is enforced on both platforms anyway, because `position` is one
2259
+ argument with one meaning and the narrower platform is what bounds it. That
2260
+ last one is answered by the SAME
2261
+ rule on both platforms, and when the site's list could be read the refusal
2262
+ carries it as `declared_positions` as well as naming it in the message.
2263
+ Under `placement:"module_element"` a Joomla module is created with no position
2264
+ and assigned to ALL pages, so it is immediately visible to `element_add`. On
2265
+ WordPress the same placement REQUIRES a builder area since 2026-08-08: a
2266
+ widget exists only inside a registered area, and this tool asks which one
2267
+ instead of picking `builder-1` for you.
2268
+ **A `position` is not by itself a rendering.** `builder-1`..`builder-6` is
2269
+ classified `never-standalone` on BOTH platforms (that is what
2270
+ `local_content_read(include_usage:true)` reports back), so a `builder-N`
2271
+ position means "it now has a home", not "it now shows up". The
2272
+ live A/B that proved a `builder-N` module produces zero frontend output was
2273
+ measured on Joomla; on WordPress the classifier reports the same value and
2274
+ the same rule is what `next_step` assumes. Either way, step 2 (`element_add`)
2275
+ is what actually puts it on a page. Do not read a freshly created row as
2276
+ visible until an element references it.
2277
+ **`builder_visible` can come back as the STRING `unknown_until_next_request`
2278
+ on a real WordPress create.** WordPress builds `$wp_registered_widgets` once
2279
+ per request, so the widget this very call just wrote is not in it yet: the
2280
+ honest answer is neither `true` nor `false`. It is a timing artefact of the
2281
+ same request, not a fault and not a warning. Read the row again (any later
2282
+ request, e.g. `local_content_read({kind:"modules"})`) to get the real
2283
+ boolean, and never branch on it as if it were `false`.
2284
+ `dry_run` DEFAULTS true, same discipline as `modules_update`: once
2285
+ `placement` is chosen, a bare call previews `{type, position, fields}` and
2286
+ creates nothing. A REAL create
2287
+ (`dry_run:false`) returns the full created `row` (the SAME
2288
+ `local_content_read(kind:'modules')` D2 shape) PLUS `next_step` — the
2289
+ EXACT follow-up call **for the placement you chose**, one shape per choice:
2290
+ | `placement` | `next_step` |
2291
+ |---|---|
2292
+ | `module_element` | `element_add{element_type:"module", props:{widget:"custom_html-3"}}` on WordPress, `props:{module:"122"}` on Joomla |
2293
+ | `position_element` | `element_add{element_type:"module_position", props:{content:"builder-1"}}` |
2294
+ | `position` | Joomla: `modules_update{id, assignment}` to narrow it to chosen pages. WordPress: `local_content_read{kind:"modules", include_usage:true}` — read-only, because WordPress has no per-page control to narrow it with; add `verify_on_urls` to PROVE guest-visible rendering rather than trust `active`/`builder_visible` alone |
2295
+ **Two live-measured details, both baked into `next_step` so you never have
2296
+ to remember them:**
2297
+ 1. **Joomla's `props.module` is a STRING** (`"122"`), never a bare
2298
+ integer — a mismatch here silently fails to resolve, it does not error.
2299
+ 2. **A `module_position` element names the POSITION, not one module** — it
2300
+ renders every published entry sitting there. That element accepts an
2301
+ optional `layout` prop from its own enum (`stack`/`grid`) — leaving it
2302
+ out is safe and defaults to `stack`; set it only if you want `grid`.
2303
+ `next_step` therefore leaves the key out. Never copy the `sidebar` value
2304
+ the element schema reports as that field's default: it is not one of the
2305
+ two accepted values and is rejected with a 422. This does NOT apply to
2306
+ the `module` form.
2307
+ Copy `next_step` verbatim, add `layout_id` + `etag` + `parent_path` when it
2308
+ is an `element_add` (that tool REQUIRES `layout_id` and `etag`, and unlike
2309
+ the fourteen tools that take an `element_path` it names its parent argument
2310
+ `parent_path`), then make the call. A `null` here is an answer, not a gap:
2311
+ the widget is already live site wide and nothing further is needed.
2312
+ **One more field the answer can carry, and it is the only one that reports on
2313
+ something that did NOT happen: `position_verified: false`.** It appears when a
2314
+ position was STORED that the server could not vouch for — the site's position
2315
+ list was not readable from there AND the name is not one of `builder-1` to
2316
+ `builder-6`, which are taken by name and need no list. The write happened; the
2317
+ check behind this tool's "an unknown position is refused BY NAME" promise did
2318
+ not, so the value is in place unexamined and a typo is still a typo. The field
2319
+ is ABSENT in the two cases where there is nothing to report — the stored
2320
+ position WAS vouched for, or no position was stored at all — so absence alone
2321
+ does not mean "checked". Read `preview.position` to tell those two apart: it
2322
+ is empty only in the second. A `dry_run` never carries the field, so a preview
2323
+ cannot warn you that the position it shows will be stored unverified.
2324
+ **The answer also names what will render the module before you asked for
2325
+ it: `element_refs`.** Each entry is `{layout_id, path, via}` and there are
2326
+ exactly two ways to get one. `via: "element"` means a `module` element in
2327
+ some layout ALREADY names the id this call just took — WordPress reuses a
2328
+ deleted widget's number (the allocator is highest-instance-plus-one), so a
2329
+ new widget can inherit a reference nobody meant for it. `via: "position"`
2330
+ means a `module_position` element already renders the position you created
2331
+ into; that needs no reuse at all, happens on both platforms, and is the
2332
+ common one. The field is ALWAYS present, so `[]` is the answer "nothing
2333
+ renders it yet" rather than a build without the concept. A `dry_run` carries
2334
+ it too, and that is where it is worth most, since `dry_run` defaults to true
2335
+ and is the first call you make. On JOOMLA a `dry_run` can report only
2336
+ `via: "position"`: the module id is assigned by the database at insert time,
2337
+ so on a preview there is no id for a layout to reference. That is a plain
2338
+ statement about that arm, not a failure.
2339
+ **One asymmetry to know about that resolution step:** on the plugin's own MCP
2340
+ transport a large answer is trimmed to a response budget, top-level keys are
2341
+ dropped largest first, and `preview` is one of the large ones while this flag
2342
+ is the smallest. So a widget with several KB of content can hand you
2343
+ `position_verified: false` and no `preview` to resolve it with. Every dropped
2344
+ key is named in `omitted_keys`, so check there before reading an absent
2345
+ `preview` as "no position was stored". (The `retry_hint` that comes with a
2346
+ trimmed answer suggests raising `max_chars`; `modules_create` has no such
2347
+ argument, so that hint does not apply here.)
2348
+ Advanced/gateway-only.
2349
+ - **The fifteen typed failures of `modules_update` / `modules_create` /
2350
+ `modules_delete`, and what each one licenses you to do next.** These are their
2351
+ OWN family, separate from the `pages_audit`/`heading_outline` target-error
2352
+ table above. Three of
2353
+ the four `placement_*` codes belong to `modules_create` alone. The fourth,
2354
+ `placement_position_unknown`, is also what `modules_update` answers for a
2355
+ position this site does not declare, so that one mistake reads the same
2356
+ whichever verb you sent it with. All four are the ones a caller meets first,
2357
+ because they are decided before anything is written:
2358
+
2359
+ | `error_code` | HTTP | What it means | What to do |
2360
+ | --- | --- | --- | --- |
2361
+ | `modules.placement_not_chosen` | 400 | `modules_create` only. `placement` was missing or not one of `position` / `module_element` / `position_element`. Nothing was created, not even a preview: the check runs before `dry_run` is honoured. | The error carries BOTH halves of the answer, `ways` and `notes`. Read them, pick a way, resend with `placement` set. |
2362
+ | `modules.placement_needs_a_position` | 400 | `modules_create` only. The way you chose needs a `position` and the call carried none. WHICH ways need one is a platform fact since 2026-08-08: all three on WordPress, where a widget exists only inside a registered area, and two on Joomla, where a module with no position is a native state. They take different values, and none is defaulted. | Resend the SAME `placement` with `position` set. Switching placement to get past this builds something other than what you asked for. |
2363
+ | `modules.placement_position_unknown` | 400 | `modules_create` AND `modules_update`, the one code of this family both verbs send. The `position` named is not one this site declares, or could not be a position at all (longer than 50 characters, the width of the Joomla column that stores it; the bound is enforced on BOTH platforms so one argument keeps one meaning). The SAME code on both platforms and on both verbs. What DIFFERS is the offer inside the message, and with it the advice: `modules_update` has no `placement` argument, so its message names the positions this site declares plus `builder-1` to `builder-6` and its runtime `hint` says exactly that. The advice cell to the right is the `modules_create` one. | The MESSAGE already names the values this call can use, narrowed to the placement you chose, so take the next value from there: `placement: "position"` takes a template position this site declares that is at most 50 characters long, `placement: "position_element"` takes `builder-1` to `builder-6` and nothing else, and `placement: "module_element"` takes no template position at all and only `builder-1` to `builder-6` if you name one. `declared_positions` rides alongside as the raw fact of everything this site declares, which is more than any one placement takes: it can even hold a value no call accepts, because a site may declare a position wider than the column that stores it. So when the two differ the message is the shorter, safer list. A value taken from `declared_positions` under the wrong placement is refused again as `placement_position_conflict`. When the error carries no list, the site's list could not be read and the value has to come from the template itself. |
2364
+ | `modules.placement_position_conflict` | 400 | `modules_create` only. `position` and `placement` are each fine alone and describe DIFFERENT outcomes together: a `builder-N` under `placement:"position"`, a real theme position under `placement:"module_element"`, a non-builder position under `placement:"position_element"`. | Change one of the two and resend. The message names the combination and the call that does what you asked for. |
2365
+ | `modules.invalid_field` | 400 | A field is unknown, unwritable (`settings`/`params`/`content`), wrong-platform, or the wrong JSON TYPE. The message names the allowed keys or the expected type. | Fix the call. Nothing was written; the module is untouched. |
2366
+ | `modules.field_value_invalid` | 400 | The KEY is right and the VALUE is not: a wrong JSON type (the string `"false"` is not the boolean `false`), a value outside a closed set (an unknown widget/module type, an unrecognised `assignment.mode`), or outside a permitted range (`menu_ids` must be positive). The largest of these classes. | Correct the VALUE of the named key and resend. Renaming the key will not help: the key is already right. The message names the expected type or the allowed values. |
2367
+ | `modules.field_not_on_this_type` | 400 | The field name is fine and this tool can write it, but THIS widget/module type has no such field. Nothing to rename. | The message names the types that DO carry it, and where this type keeps the equivalent instead (block markup, `theme_overlay`, or the type-specific params). Pick another type, or put the value where this type keeps it. |
2368
+ | `modules.field_combination_invalid` | 400 | Every field is valid ALONE; together they contradict each other, or one needs a partner the call did not carry. | Change the COMBINATION, not a value: add the missing field or drop one of the pair. The message names which of the two it is and in which direction. |
2369
+ | `modules.position_not_clearable` | 400 | **WordPress only.** You sent `position: ""` to take the widget out of every area, and on WordPress a widget exists only while it sits in one. Joomla accepts the same value, because a module there can genuinely sit in none. So this is about the platform, not about the value being malformed. | Send `active: false` instead: it parks the widget in `wp_inactive_widgets`, and naming a position again brings it back. Nothing was written. |
2370
+ | `modules.blocked_by_cms_state` | 409 | Name, value and combination are all correct. The CMS is in a state that would make the outcome untrue anyway, and this tool deliberately does not write the columns that would change it. | Nothing to fix in the call. The message names the exact field and value standing in the way; change it in the CMS module manager and repeat the call. |
2371
+ | `modules.invalid_id` | 400 | Joomla only, and only for the id-addressed verbs: `moduleId` was not a positive integer. WordPress has no counterpart, because its widget ids are strings such as `custom_html-2`. | Read the id off `local_content_read({kind:"modules"})` rather than constructing it. |
2372
+ | `modules.not_found` | 404 | The store WAS read and this id is genuinely not in it. | The module really is gone. Creating a replacement is legitimate here. |
2373
+ | `modules.element_refs_present` | 409 | `modules_delete` only. The request is fine; the STATE is not. Layout elements still reference this module directly, and the message names each one. | Delete those elements with `element_delete`, or repeat with `acknowledge_element_refs: true` to accept the dangling references. Nothing was removed. |
2374
+ | `modules.write_failed` | 500 | A write was ATTEMPTED and did not land. Every writing verb on both platforms answers this way: the create that stored nothing, the update whose change never reached the column, the delete that removed nothing. Reporting success would have been a positive false statement about the state of the store. | Re-read the module before retrying: the message names what did and did not reach storage. On Joomla the whole call shares one transaction, so it is all or nothing; on WordPress the widget instance and its placement are separate writes, so the message may report a half-applied call. |
2375
+ | `modules.unavailable` | 503 | The widget/module store could not be reached AT ALL. | **Never create a replacement.** Absence of an answer is not absence of the module. Retry later, or tell the user the store is down. |
2376
+
2377
+ The 404-vs-503 split is the load-bearing one: before it existed, an
2378
+ unreachable store answered 404 and an agent dutifully created a duplicate
2379
+ for a module that was there the whole time.
1112
2380
  - **Clone-then-update path drift.** The clone returns a path that's
1113
2381
  correct at the moment of the call. If you fire off many ops in
1114
2382
  parallel, a concurrent edit may shift indices. Refresh via
1115
- `get_etag` + `page_get_schema` (via gateway) between independent batches.
2383
+ `get_etag` + `element_list` between independent batches.
1116
2384
  - **Cloning a bound element keeps the binding.** `element_clone`
1117
2385
  copies the entire element including `props.source`. If the user
1118
2386
  wanted a "data-free" copy, call
1119
2387
  `yootheme_builder_advanced({ tool: "yootheme_builder_element_unbind_source", arguments: { ... } })`
1120
2388
  on the new path afterwards.
1121
- - **Wrong parameter names.** Use `template_id`, `element_path`,
2389
+ - **Wrong parameter names.** Use `layout_id`, `element_path`,
1122
2390
  `etag` (NOT `pageId`, `srcPath`, `ifMatch`).
1123
2391
 
1124
2392
  **Worked example (tool-call snippet):**
@@ -1126,7 +2394,7 @@ CMS-level template duplication (in wp-admin or Joomla administrator).
1126
2394
  ```jsonc
1127
2395
  // Step 5. Clone the section element as a sibling.
1128
2396
  yootheme_builder_element_clone({
1129
- template_id: "home",
2397
+ layout_id: "home",
1130
2398
  element_path: "/children/0/children/2", // the hero section to duplicate
1131
2399
  etag: "abc123"
1132
2400
  })
@@ -1134,11 +2402,11 @@ yootheme_builder_element_clone({
1134
2402
 
1135
2403
  // Step 7. Tweak the clone (replace props entirely, or pass merge: true).
1136
2404
  const current = yootheme_builder_element_get({
1137
- template_id: "home",
2405
+ layout_id: "home",
1138
2406
  element_path: "/children/0/children/3",
1139
2407
  });
1140
2408
  yootheme_builder_element_update_settings({
1141
- template_id: "home",
2409
+ layout_id: "home",
1142
2410
  element_path: "/children/0/children/3",
1143
2411
  props: { ...current.props, style: "secondary" }, // section background = `style`
1144
2412
  etag: "def456"
@@ -1152,7 +2420,7 @@ copy, follow up with
1152
2420
  on the new path. Verify with a direct `element_get_binding` call.
1153
2421
 
1154
2422
  **Success criterion:** After publish,
1155
- `yootheme_builder_advanced({ tool: "yootheme_builder_page_get_schema", arguments: { template_id } })`
2423
+ `yootheme_builder_element_list({ layout_id })`
1156
2424
  shows the new section at the cloned path with the user's tweaks reflected in
1157
2425
  `element_get` on that path.
1158
2426
 
@@ -1165,32 +2433,98 @@ guessing, and without rotating the user's key unnecessarily.
1165
2433
 
1166
2434
  **Canonical tool-call sequence:**
1167
2435
 
1168
- 1. `yootheme_builder_diagnose` is a single probe that hits `/health` (no
1169
- auth) and then `/etag` (Bearer auth). Returns
1170
- `{ plugin_reachable, plugin_version, yootheme_loaded, yootheme_version,
1171
- endpoint_count, bearer_valid, bearer_error?, site_url?, home_url?,
1172
- summary? }`. Call this **before** any other tool when you see
1173
- auth errors. (Takes no arguments. The schema is `{}`.)
2436
+ 1. `yootheme_builder_diagnose` is a single self-diagnostic. Returns
2437
+ `{ platform, plugin_version, site_url, kid, scopes, token_scope,
2438
+ served_areas, capabilities, auth_bound, etag_ok, resolved_user_id,
2439
+ effective_capabilities, capability_ok, capability_warning, library_key,
2440
+ active_site }`. EVERY field is optional: one is absent when the host does
2441
+ not report it. Call this **before** any other tool when you see auth
2442
+ errors. (Takes no arguments beyond the optional `site_id`.)
2443
+
2444
+ `served_areas` and `effective_capabilities` name similar-sounding, DIFFERENT
2445
+ things: `served_areas` lists the tool AREAS this server serves (`pages`,
2446
+ `elements`, `sources`, `inspection`) — a server property, the same list
2447
+ `yootheme_builder_health` carries under the same name. `effective_capabilities`
2448
+ is what THIS caller's resolved CMS user may actually do
2449
+ (`can_create`/`can_edit`/`can_delete`) — see the bullet below. `capabilities`
2450
+ is `served_areas`' RETIRING name, kept one release as a byte-identical alias.
2451
+
2452
+ This list used to be longer, and the extra names never arrived — measured on
2453
+ four live installs. `plugin_error`, `yootheme_loaded`, `yootheme_version`,
2454
+ `endpoint_count`, `bearer_valid`, `bearer_error`, `summary` and `home_url`
2455
+ belonged to an older transport that assembled this report from two HTTP
2456
+ probes; today the plugin answers it in-process. For install facts (YOOtheme
2457
+ version, endpoint list, `home_url`) call `yootheme_builder_health`, which
2458
+ does carry them. For the bearer verdicts, read `auth_bound` and `etag_ok`
2459
+ right here — they are the successors of `bearer_valid` / `bearer_error`.
2460
+
2461
+ The two verdicts to read first are `auth_bound` (the Bearer key was accepted
2462
+ and resolved to a user) and `etag_ok` (a layout state was readable with it).
2463
+ There is no `plugin_reachable` field, and there is nothing to miss: the
2464
+ diagnostic is assembled by the plugin that received your call, so an answer
2465
+ at all IS the reachability answer. An unreachable plugin produces a
2466
+ transport error, never a payload.
2467
+
2468
+ Four of those are easy to skip past and answer questions you would
2469
+ otherwise take several calls to answer:
2470
+
2471
+ - `token_scope` (`read` / `write`). A `read` key fails every write tool
2472
+ with `read_only_token`, and that is a key problem, not a permissions one.
2473
+ - `effective_capabilities` + `capability_ok` + `capability_warning`. The
2474
+ CMS user the key resolves to, and what that user may actually do. A
2475
+ write-scoped key mapped to a user without edit rights still 403s;
2476
+ `capability_ok: false` is the only field that says so before you try.
2477
+ - `active_site` `{ site_id, via, process_scoped }`. Which site this call
2478
+ actually reached, and by which decision: `explicit` (you passed `site_id`
2479
+ — isolated from other clients of this server process), `use_site` (a
2480
+ runtime switch this process is on) or `registry_default` (the default in
2481
+ the sites file). The two inherited paths are kept apart deliberately: a
2482
+ call that reaches the wrong install answers just as confidently as one
2483
+ that reaches the right one. The field rides on EVERY tool call, not only
2484
+ this one. It replaces `process_default_site_id` + `resolved_via`, which
2485
+ this chapter kept naming after they had stopped shipping.
2486
+ - `site_url`. WHICH install answered, as the plugin reports it. `kid` and
2487
+ `auth_bound` cannot show that a perfectly valid key simply belongs to a
2488
+ different install, and that is the commonest cause of the refusal you are
2489
+ triaging. Read it against `active_site.site_id`, which names the registry
2490
+ entry your own config picked: when those two disagree, the disagreement
2491
+ is the finding.
2492
+ - `library_key`. Why the Layout Library tools can or cannot reach
2493
+ `api.yootheme.com`, as `{ configured, state, hint }`. Read `state`
2494
+ before you tell anyone to check their apikey field: see the Layout
2495
+ Library prerequisite section for the five states and who has to act on
2496
+ each. Never contains the key.
1174
2497
  2. **Interpret the result:**
1175
- - `plugin_reachable: false` the WordPress / Joomla install is down
1176
- OR the host plugin is deactivated. Send the user to **wp-admin
1177
- Plugins → activate "YT Builder MCP"** (WordPress) or **Joomla
1178
- administrator → Extensions → Plugins → enable "System - YT Builder
1179
- MCP" and the matching webservices + component entries** (Joomla).
1180
- Do not retry until they confirm.
1181
- - `plugin_reachable: true, bearer_valid: false` → the Bearer key is
1182
- wrong (typo, revoked, or wrong key for this install). The
1183
- `bearer_error` field carries the upstream HTTP status. Send the
1184
- user to:
2498
+ - **The call itself failed** (a transport error, not a payload) → the
2499
+ WordPress / Joomla install is down OR the host plugin is deactivated.
2500
+ Send the user to **wp-admin → Plugins → activate "YT Builder MCP"**
2501
+ (WordPress) or **Joomla administrator → Extensions → Plugins → enable
2502
+ "System - YT Builder MCP" and the matching webservices + component
2503
+ entries** (Joomla). Do not retry until they confirm.
2504
+ - `auth_bound: false` → the Bearer key is wrong (typo, revoked, or wrong
2505
+ key for this install). Check `site_url` first: a key that is valid
2506
+ somewhere else fails here for a reason nothing about the key will show.
2507
+ For the upstream HTTP status call `yootheme_builder_sites_test`, which
2508
+ probes `/etag` itself and answers `bearer_valid` + `summary`; diagnose
2509
+ names no status of its own, because the request it is answering already
2510
+ got through. Then send the user to:
1185
2511
  - **WordPress:** wp-admin → Tools → "YT Builder MCP" → Bearer Keys
1186
2512
  → copy the existing key into their MCP client config, or
1187
2513
  generate a new one.
1188
2514
  - **Joomla:** Components → YT Builder MCP → Bearer Keys → same.
1189
- - `plugin_reachable: true, bearer_valid: true` but the original
2515
+ - `auth_bound: true, etag_ok: true` but the original
1190
2516
  tool returned a 403 → the key works but the scope is too low for
1191
2517
  the tool's required scope (`write` for mutations, `admin` for
1192
2518
  destructive operations). Ask the user to regenerate the key with
1193
2519
  a higher scope and restart the AI client.
2520
+ - `auth_bound: true, capability_ok: false` →
2521
+ the Bearer key's scope IS write, but the CMS user it resolves to
2522
+ (`effective_capabilities.can_edit`, `can_create`, `can_delete`, all
2523
+ false) has no write capability at all, so writes will still 403
2524
+ despite the write-scoped token. `capability_warning` carries the
2525
+ exact remediation sentence. Re-mint the key under a user with edit
2526
+ rights, or grant that user the capability. A write-scoped token
2527
+ alone is not enough.
1194
2528
  3. **Walk the user through key rotation if needed:**
1195
2529
  - WordPress: "wp-admin → Tools → YT Builder MCP → Bearer Keys."
1196
2530
  Joomla: "Components → YT Builder MCP → Bearer Keys."
@@ -1202,7 +2536,8 @@ guessing, and without rotating the user's key unnecessarily.
1202
2536
  - "Restart Claude / Cursor / Zed / Continue / Cline / Roo Code /
1203
2537
  Claude Code / Codex CLI."
1204
2538
  - "Confirm with `yootheme_builder_diagnose` that
1205
- `bearer_valid: true` before retrying the original task."
2539
+ `auth_bound: true` and `etag_ok: true` before retrying the original
2540
+ task."
1206
2541
 
1207
2542
  **Common pitfalls:**
1208
2543
 
@@ -1229,29 +2564,33 @@ guessing, and without rotating the user's key unnecessarily.
1229
2564
  yootheme_builder_diagnose({})
1230
2565
  // Response shape:
1231
2566
  // {
1232
- // plugin_reachable: true,
2567
+ // platform: "wordpress",
1233
2568
  // plugin_version: "1.2.0",
1234
- // yootheme_loaded: true,
1235
- // yootheme_version: "5.0.22",
1236
- // endpoint_count: 16,
1237
- // bearer_valid: false, // ← key is bad
1238
- // bearer_error: "HTTP 401: invalid_token",
1239
- // site_url: "https://example.com",
1240
- // home_url: "https://example.com"
2569
+ // kid: "5cb829a21f4d5e4f",
2570
+ // scopes: ["read", "write"],
2571
+ // token_scope: "write",
2572
+ // auth_bound: false, // ← key is bad
2573
+ // etag_ok: false,
2574
+ // site_url: "https://example.com", // ← check this FIRST: right key, wrong install?
2575
+ // active_site: { site_id: "default", via: "registry_default", process_scoped: true }
1241
2576
  // }
1242
2577
  // → diagnosis: rotate the key. Send user to Tools/Components → YT Builder MCP.
2578
+ // → for the upstream HTTP status: yootheme_builder_sites_test({ site_id: "default" }).
1243
2579
  ```
1244
2580
 
1245
- **Edge case:** `plugin_reachable: true` but `yootheme_loaded: false`
1246
- means the user installed the MCP host plugin but YOOtheme Pro itself
1247
- isn't active. The MCP server still answers, but every tool that
2581
+ **Edge case:** `yootheme_builder_health` answers but reports
2582
+ `yootheme_loaded: false` — the user installed the MCP host plugin but YOOtheme
2583
+ Pro itself isn't active. Read that off **health**, not diagnose: diagnose used
2584
+ to declare the field and never filled it. (Health's WordPress arm reports it;
2585
+ the Joomla health answer is assembled before the builder bootstraps and does
2586
+ not carry it, so its absence there means "not knowable here", never `false`.) The MCP server still answers, but every tool that
1248
2587
  touches the YOOtheme layout returns an empty/error response. Surface
1249
2588
  the mismatch ("YOOtheme Pro is not active on this install") instead
1250
2589
  of retrying. On Joomla this can also surface as a "YOOtheme Pro
1251
2590
  required" admin notice in the component dashboard.
1252
2591
 
1253
2592
  **Success criterion:** A subsequent `yootheme_builder_diagnose`
1254
- returns `plugin_reachable: true` AND `bearer_valid: true`. The
2593
+ returns `auth_bound: true` AND `etag_ok: true`. The
1255
2594
  original tool now returns a non-auth response.
1256
2595
 
1257
2596
  ---
@@ -1276,22 +2615,28 @@ an instance with a sensible default props payload.
1276
2615
  `element_type` (`type_name` is a DEPRECATED alias, kept only for
1277
2616
  1.0.x back-compatibility).
1278
2617
  4. `yootheme_builder_pages_list({ fields: ["id", "label"] })` and
1279
- `yootheme_builder_page_get_layout({ template_id, flat: false })`
2618
+ `yootheme_builder_page_get_layout({ layout_id, flat: false })`
1280
2619
  locate the `parent_path` (JSON-Pointer) where the new element
1281
- should land.
2620
+ should land. A content/leaf type is only accepted inside a `column`
2621
+ (never directly in a `section` or a `row`) — if the layout has none yet,
2622
+ call `yootheme_builder_scaffold_section` first and use its
2623
+ `column_paths` as `parent_path` instead of hunting for one by hand.
1282
2624
  5. `yootheme_builder_get_etag()`: fetch the optimistic-lock etag.
1283
- 6. `yootheme_builder_element_add({ template_id, parent_path: "<path>", element_type: "<picked-name>", props: { ... }, etag })`
1284
- validates `props` against the live type schema. On a type with a
1285
- known schema, an unknown prop key is HARD-REJECTED (not silently
1286
- dropped): the error always lists `valid_prop_keys`, plus a
1287
- `did_you_mean` suggestion when a close field exists (e.g. `contnet`
1288
- to `content`). Read those from the error and retry; do not re-guess.
1289
- 7. (Optional) `yootheme_builder_element_update_settings({ template_id, element_path: "<new-path>", props: { ... }, etag })`
2625
+ 6. `yootheme_builder_element_add({ layout_id, parent_path: "<path>", element_type: "<picked-name>", props: { ... }, etag })`
2626
+ checks `props` against the live type schema. On a type with a known
2627
+ schema, a prop key the type does not declare is ACCEPTED and stored —
2628
+ the answer carries a `warnings` entry with `code: "unknown_prop_keys"`
2629
+ naming the key, plus a `did_you_mean` suggestion when a close field
2630
+ exists (e.g. `contnet` to `content`). Read that and correct; do not
2631
+ re-guess. The VALUE side is stricter: an invalid enum member, an
2632
+ emptied required field, or a `<p>`-wrapped headline `content` is
2633
+ REFUSED with 422 before anything is written.
2634
+ 7. (Optional) `yootheme_builder_element_update_settings({ layout_id, element_path: "<new-path>", props: { ... }, etag })`
1290
2635
  iterates on the props. **Note: this REPLACES `props` entirely by
1291
2636
  default; pass `merge: true` for a server-side deep-merge.** When
1292
2637
  replacing, include every key you want to keep.
1293
- 8. `yootheme_builder_page_save({ template_id, etag })`
1294
- then `yootheme_builder_page_publish({ template_id, etag })`.
2638
+ 8. `yootheme_builder_page_save({ layout_id, etag })`
2639
+ then `yootheme_builder_page_publish({ layout_id, etag })`.
1295
2640
 
1296
2641
  **Common pitfalls:**
1297
2642
 
@@ -1300,14 +2645,22 @@ an instance with a sensible default props payload.
1300
2645
  still works as a DEPRECATED alias, but prefer `element_type` to match
1301
2646
  every other tool in this domain. The server's Zod schema rejects any
1302
2647
  other key.
1303
- - **Unknown prop key = hard reject, not a silent drop.** On a type with a
1304
- known schema, a prop key that is not a real field is REJECTED before the
1305
- write. The error ALWAYS lists `valid_prop_keys` (your guaranteed anchor),
1306
- plus a `did_you_mean` suggestion only when a close field exists. The
1307
- reserved binding keys `source` / `source_extended` / `source_node` are
1308
- rejected too (bind a source with `yootheme_builder_element_bind_source`,
1309
- never via `props`). Correct from the error instead of retrying blind.
1310
- - **Wrong parameter names on `element_add`.** Use `template_id`
2648
+ - **Unknown prop key = a warning, not a rejection and not silence
2649
+ either.** On a type with a known schema, a prop key the schema does not
2650
+ declare is STORED and reported: `warnings: [{ code: "unknown_prop_keys",
2651
+ message }]`, with a `did_you_mean` suggestion inside the message when a
2652
+ close field exists. It is a warning rather than a refusal because an
2653
+ undeclared key is not always wrong — YOOtheme itself stores props a type
2654
+ does not declare (a `column`'s `order_first` is written by the ROW control
2655
+ and is read at render), and a layout authored on a newer YOOtheme carries
2656
+ props the installed build has not declared yet. Refusing them would refuse
2657
+ layouts YOOtheme wrote. So: read the warning, and either fix the typo with
2658
+ `unset: ["<key>"]` — valid WITHOUT a `props` body, in which case only those keys go and every other prop stays — or keep the key knowingly. To bind a source, use
2659
+ `yootheme_builder_element_bind_source` rather than writing `source` /
2660
+ `source_extended` through `props` — those carriers are not filtered out of
2661
+ a props write, so a hand-written one silently competes with the bind
2662
+ tool's.
2663
+ - **Wrong parameter names on `element_add`.** Use `layout_id`
1311
2664
  (not `pageId`), `parent_path` (not `parentPath`), `element_type`
1312
2665
  (not `type` / `name`), `props` (not `settings`), `etag` (not
1313
2666
  `ifMatch`).
@@ -1349,13 +2702,13 @@ yootheme_builder_element_type_get_schema({ element_type: "headline" })
1349
2702
  // <p> block — see "Typography & heading styles"). If you omit title_element it
1350
2703
  // DEFAULTS to h1, so set it explicitly on every headline to avoid multiple h1s.
1351
2704
  yootheme_builder_element_add({
1352
- template_id: "home",
1353
- parent_path: "/children/0/children/2", // row inside section
2705
+ layout_id: "home",
2706
+ parent_path: "/children/0/children/2/children/0", // a column inside a row inside a section
1354
2707
  element_type: "headline",
1355
2708
  props: { content: "Welcome", title_element: "h1" },
1356
2709
  etag: "abc123"
1357
2710
  })
1358
- // Response: { rel_path: "/children/0/children/2/children/0", etag: "def456" }
2711
+ // Response: { rel_path: "/children/0/children/2/children/0/children/0", etag: "def456" }
1359
2712
  ```
1360
2713
 
1361
2714
  **Edge case:** A child theme can override a built-in element's
@@ -1387,37 +2740,179 @@ exposes. Never analyze findings by hand and never bulk-apply a fix.
1387
2740
  write-approval prompt). Omit `checks`/`types` to run every category
1388
2741
  against every scanned template.
1389
2742
  2. Read the response's `findings` array. Each finding carries a `code`,
1390
- `category`, `severity`, a `count`, up to 30 affected `pages`, and
2743
+ `category`, `severity`, an `affected_pages` total, up to 30 affected
2744
+ `pages`, and
1391
2745
  (where a safe automatic fix exists) a `fix_hint` naming the exact
1392
2746
  write tool and args template to run. `duplicate-layout` and
1393
2747
  `title-collision` never carry a `fix_hint` — they need a human
1394
2748
  decision (rename, merge, or intentionally keep as-is), not an
1395
2749
  automatic patch.
1396
- 3. `yootheme_builder_template_summary({ template_id })` drills into one
1397
- page a finding names. It carries the same `heading_levels`,
1398
- `images_missing_alt`, and `disabled_elements` counters the audit
1399
- used, so you can see exactly which nodes are affected before
1400
- writing.
2750
+ 3. `yootheme_builder_template_summary({ layout_id, include_nodes: true })`
2751
+ drills into one page a finding names. It carries the same
2752
+ `heading_levels`, `images_missing_alt`, and `disabled_elements` counters
2753
+ the audit used, plus (with `include_nodes:true` opt-in, omit it for
2754
+ the smaller default) a per-node `content_nodes` breakdown (`path`,
2755
+ `type`, `status`, `effective_visible`, `hidden_on_mobile`, `headings`,
2756
+ `alts`), so you can see exactly WHICH node is affected, whether it
2757
+ is disabled or just hidden on mobile, in one call instead of pulling
2758
+ the raw `page_get_layout`. Page a large `content_nodes` list with
2759
+ `limit`/`cursor`; drill into several findings' pages in ONE call with
2760
+ `layout_ids:[...]` instead of `layout_id` (returns `summaries` keyed
2761
+ by id + `not_found`). `heading_levels` rides alongside a
2762
+ `heading_levels_note` sibling stating exactly which sources feed the
2763
+ census: every element whose title/meta channel resolves to a heading
2764
+ level, PLUS real `<h1>`-`<h6>` tags found inside `html`/`text`/`code`
2765
+ element markup. It does NOT count `module`/widget output — a WordPress
2766
+ widget or Joomla module has no content field this counter can parse.
2767
+ Since T1.4 that content is still readable, just not counted here:
2768
+ `yootheme_builder_element_get` on the `module`/`module_position` element
2769
+ resolves its referenced widget/module via a `resolved` field (see
2770
+ Workflow 3) — this counter is unaffected either way.
1401
2771
  4. Fix that ONE page via the write tool named in `fix_hint.tool`, with
1402
2772
  `fix_hint.args_template` as a starting point (`element_update_settings`,
1403
2773
  `element_add`, etc), ETag-locked like every other write in this
1404
2774
  server.
1405
- 5. `yootheme_builder_page_publish({ template_id, etag })` republishes.
2775
+ 5. `yootheme_builder_page_publish({ layout_id, etag })` republishes.
1406
2776
  The response's `quick_check` block (default on) re-checks THIS page
1407
2777
  with the same per-page codes, so you can confirm the fix landed
1408
2778
  without a second full sweep.
1409
2779
 
2780
+ **Disabling instead of editing.** Sometimes the right remediation is to
2781
+ exclude an element entirely rather than edit its content (a stray leftover
2782
+ section, a duplicate block). `yootheme_builder_element_set_status({
2783
+ layout_id, element_path, status: false, etag })` writes `props.status:
2784
+ 'disabled'` — the SAME field YOOtheme's own native "Disable element"
2785
+ checkbox writes, so the element is excluded from BOTH the live frontend
2786
+ render AND the NEXT `pages_audit`/`template_summary` (both read the same
2787
+ `props.status`/`disabled_elements`/`content_nodes.status` fields, so the fix
2788
+ is visible without a DB round-trip). Pass `status: true` to re-enable it,
2789
+ which restores the YOOtheme default (no status field means active) rather
2790
+ than persisting a fabricated `props.status`.
2791
+
2792
+ **Need a real document outline instead of findings?** `pages_audit` tells you
2793
+ WHAT is wrong (a missing h1, a skipped level); it never shows you the whole
2794
+ heading tree in one look. `yootheme_builder_heading_outline({ layout_id })`
2795
+ (read-only, reached via `yootheme_builder_advanced_read`) is a companion
2796
+ STRUCTURE view for ONE page: every H1-H6 heading in true document order, each
2797
+ carrying its TEXT and the element that carries it (`element_type`/`path` —
2798
+ `headline`, or the `html`/`text`/`code` element for a raw-HTML heading), plus
2799
+ the alt-status of every image on the page (`alt: null` for missing, plus
2800
+ `is_generic_alt` for a lazy "Logo"/"Bild"-style alt). Use it BEFORE editing a
2801
+ page's heading hierarchy by hand, or to double-check a `heading-skip` finding
2802
+ in context rather than as an isolated hit:
2803
+ `yootheme_builder_advanced_read({ tool: "yootheme_builder_heading_outline", arguments: { layout_id: "page:4816" } })`
2804
+ returns `{outline, images, h1_count, has_level_skip, images_missing_alt}` —
2805
+ `has_level_skip` mirrors the `heading-skip` check's EXACT rule (a level more
2806
+ than one above the highest seen so far, including a document that opens below
2807
+ h1). Pass `mode: "rendered"` to read the ACTUAL rendered DOM instead of the
2808
+ saved layout tree (the same trade-off `pages_audit`'s own `mode` makes: sees
2809
+ module/widget output, but `element_type` degrades to `""` since there is no
2810
+ builder node to point at).
2811
+
1410
2812
  **Common pitfalls:**
1411
2813
 
1412
2814
  - **Promising a full SEO audit.** `pages_audit` covers Builder structure
1413
2815
  and CMS basics only: heading hierarchy, empty/thin pages, missing
1414
2816
  image alt text, duplicate layouts, title collisions, non-pretty URL
1415
- fallbacks, stale content, and dead in-page anchors. Every response
1416
- carries a fixed disclaimer field:
1417
- `"Meta title/description/OG live in your CMS SEO plugin not covered here."`
1418
- It does not read or evaluate Yoast/RankMath/any SEO-plugin meta
1419
- title, meta description, or Open Graph tags. Say this scope boundary
1420
- out loud and point the user at their SEO plugin for that layer.
2817
+ fallbacks, stale content, and dead in-page anchors. Every response carries
2818
+ a `disclaimer` field naming what is out of scope, and that field says
2819
+ itself that its list is **not exhaustive**pass that on rather than
2820
+ presenting it as the complete set of blind spots. It does not read or
2821
+ evaluate Yoast/RankMath/any SEO-plugin meta title, meta description, or
2822
+ Open Graph tags, and it does not see whether the page may be INDEXED at
2823
+ all. Say this scope boundary out loud and point the user at their SEO
2824
+ plugin for that layer.
2825
+ - **Answering "we don't show up on Google" from this tool alone.** The first
2826
+ suspect is not a heading level — it is a `noindex`, which `pages_audit` has
2827
+ no check for in any category. Staging sites often carry a site-wide
2828
+ `<meta name="robots" content="noindex, nofollow">`, and until that is lifted
2829
+ every other SEO finding is beside the point. Settle it first:
2830
+ `yootheme_builder_fetch_public_url({ url, expect: "noindex" })` —
2831
+ `expect_found: true` means the page asks search engines to stay away.
2832
+ - **Assuming builder props are the only content scanned.** Headings and
2833
+ image alt text are also detected inside raw HTML: an `html`/`text`
2834
+ element's markup content, not just builder props like
2835
+ `title`/`image_alt`. A sticky-bar `<h1>` or an alt-less `<img>` buried
2836
+ in raw markup is caught, not silently missed, unless the whole
2837
+ `content` prop is dynamically bound (a bound value is a stale editor
2838
+ preview and is skipped). Since T-A1, `code` joined `html`/`text` as a raw-HTML
2839
+ carrier, and anchor text + heading length/order are ALSO detected the same
2840
+ way: an empty/generic `<a>` link, a heading level skip, or an over-long
2841
+ heading buried in raw markup is caught by `generic-alt-text`/
2842
+ `anchor-text-quality`/`heading-skip`/`long-heading`, not just by
2843
+ `missing-h1`/`multiple-h1`/`no-headings`/`images-missing-alt`/`dead-anchor`.
2844
+ `module`/`module_position` (WordPress "Widget"/"Widget Area", Joomla
2845
+ "Module"/"Module Position") are NOT scanned - verified live that neither
2846
+ carries a markup-shaped `content` field on either platform; their actual
2847
+ content lives in an external WP widget instance / Joomla `#__modules` row,
2848
+ outside the layout JSON entirely. Since W1/T1.8 that content IS readable,
2849
+ just not through THIS scanner: `include_modules:true` on
2850
+ `yootheme_builder_pages_audit`/`yootheme_builder_page_get_layout` lists
2851
+ every widget/module placed on this page (`modules_by_page`/
2852
+ `modules_on_page`), and `yootheme_builder_advanced_read({ tool:
2853
+ "yootheme_builder_local_content_read", arguments: { kind: "modules" } })`
2854
+ returns each one's actual `title`/`settings`/`theme_overlay` plus a
2855
+ `resolved` companion field on `element_get` for one `module`/
2856
+ `module_position` element (see Workflow 3) — this scanner's blind spot is
2857
+ unaffected either way.
2858
+ - **`mode:"rendered"` sees what the builder tree structurally cannot** (T-A2,
2859
+ 2026-07-24) — pass `mode:"rendered"` (default is `"builder"`, the tree read
2860
+ above) to instead render each page first (the SAME engine
2861
+ `yootheme_builder_render_preview` uses) and audit the ACTUAL rendered DOM.
2862
+ This is the one way to catch a `module`/widget's own runtime output — a
2863
+ newsletter-signup widget that itself renders an `<h1>`, dynamically-bound
2864
+ content, anything a plugin injects only at render time — none of which the
2865
+ builder-JSON walk above can see AT ALL. Only a curated subset of checks runs
2866
+ in this mode (`no-headings`, `missing-h1`, `multiple-h1`, `heading-skip`,
2867
+ `images-missing-alt`, `generic-alt-text`, `dead-anchor`, `anchor-text-quality`,
2868
+ `long-heading`, plus the three `design-*` markup codes — every check whose
2869
+ evidence is honestly derivable from a flat rendered document);
2870
+ `thin-content`/`duplicate-layout`/`empty-page`/`heading-hidden-on-mobile`/
2871
+ `non-pretty-url`/`stale-content` and both `header-contrast-*` codes still
2872
+ need the builder tree and do not run here (`header_transparent*` are section
2873
+ PROPS — they are gone by render time, so a rendered sweep can never produce
2874
+ those two findings). **Locators carry NO builder element
2875
+ path in this mode** — `breadcrumb`/`element_type` are empty strings BY
2876
+ DESIGN (there is no builder node to point at, not a resolution failure) —
2877
+ locate the hit by its `content_snippet` (the actual rendered text) or the
2878
+ page's `builder_url`/`frontend_url` instead. A site-wide rendered sweep is
2879
+ capped (rendering is expensive — each page runs the full YOOtheme Builder
2880
+ pipeline, not a JSON walk): the response always carries
2881
+ `render_scan: {attempted, rendered, capped}`, even when the cap never
2882
+ engaged, so you can always see whether every page was actually rendered.
2883
+ When the cap DID engage, `render_scan.skipped_by_cap` names the templates
2884
+ it skipped (`{id, label}`), so a code found on the scanned subset is never
2885
+ silently generalised to the rest — re-run narrowed with `types:[…]` to
2886
+ reach them. That list is itself bounded at 30 entries and says so with
2887
+ `skipped_by_cap_not_listed` when it had to stop.
2888
+ A page whose render failed is excluded from findings (never reported as a
2889
+ false "no headings") and listed in `render_errors` instead — check both
2890
+ fields before trusting a clean rendered-mode result. Each `render_errors`
2891
+ row carries `error_class` (the throwable's own class), and for an
2892
+ engine-level failure (`TypeError` and friends — a component executed during
2893
+ the render did not get something its own code requires) also a `reason`
2894
+ saying the layout is not the place to look and that the template is
2895
+ UNCHECKED rather than clean.
2896
+ - **A Joomla page carrying `module`/`module_position` is NOT a render
2897
+ failure** (render-fallback follow-up, 2026-07-30) — it renders via a
2898
+ tolerant fallback (the module/module-position subtree excluded, everything
2899
+ else rendered normally) rather than falling into `render_errors`, because
2900
+ Joomla's admin/REST context structurally cannot probe those two types (a
2901
+ CMS-runtime gap, not a broken layout). When this happens, the response
2902
+ carries `render_fallback: {pages: [{id, label, dropped_count}], suppressed_checks}`
2903
+ — `dropped_count` is how many module(-position) subtrees were excluded from
2904
+ THAT page's render, and `suppressed_checks` names which of `no-headings`,
2905
+ `missing-h1`, `heading-skip` were skipped on those pages specifically (the
2906
+ only three whose absence-based logic a hidden widget heading could
2907
+ fabricate — every other check, e.g. `multiple-h1`, only ever reports
2908
+ content that DID survive the fallback, so it keeps running). Same idea on
2909
+ `yootheme_builder_heading_outline` in `mode:"rendered"`: the response
2910
+ carries `render_fallback_used` (bool) + `render_dropped_count` (int),
2911
+ always present in that mode — treat `h1_count`/`has_level_skip`/the outline
2912
+ as possibly incomplete when `render_fallback_used` is true (content inside
2913
+ the excluded widget is invisible to the outline too). `WordPress renders
2914
+ these types successfully today, so this never engages there — WordPress
2915
+ pages never carry `render_fallback` / never set `render_fallback_used`.
1421
2916
  - **Confusing the CONTENT count with `pages_list.elements_count`.**
1422
2917
  `pages_list.elements_count` counts every node, including
1423
2918
  `section`/`row`/`column`/`fragment` layout wrappers. `pages_audit`
@@ -1538,7 +3033,7 @@ What changes in the response:
1538
3033
  tool returns `target_not_scannable` (HTTP 422). Tell the user the page could
1539
3034
  not be audited and why; then audit it via `template_summary`, or pick a page
1540
3035
  `pages_list` returns. Never present it as a clean result.
1541
- - **`resolved_target`** — `{ template_id, post_id, type, label, frontend_url,
3036
+ - **`resolved_target`** — `{ layout_id, post_id, type, label, frontend_url,
1542
3037
  context, resolved_via, matched_by }`. This is the echo of WHICH page a fuzzy
1543
3038
  address landed on. Always report it: `special: "home"` and
1544
3039
  `title: "SunWest"` are guesses until the server says what they resolved to.
@@ -1548,36 +3043,29 @@ What changes in the response:
1548
3043
  - **Richer per-hit evidence.** Single mode raises the per-page hit cap from 5 to
1549
3044
  **100 hits per check** and adds a `content_snippet` to every locator (the site
1550
3045
  sweep omits snippets to keep a whole-site payload small). The text leg also
1551
- gains a `Where to fix:` block with the locator lines; the site sweep keeps its
1552
- compact 5-column summary table, with the locators in `structuredContent` only.
1553
- - **The summary table's count column counts HITS, and says so.** In a site sweep
1554
- that column is headed `COUNT` and holds the number of affected **pages**. A
1555
- single-page sweep scanned exactly one page, so that number would be `1` for
1556
- every finding and tell you nothing there the column is headed **`HITS`** and
1557
- holds the number of individual occurrences **on that page** (19 alt-less
1558
- images `19`, not `1`). It is the uncapped total: `hits_truncated` shortens
1559
- the listed locators, never this figure. Report the `HITS` number as the size
1560
- of the problem. `structuredContent.findings[].count` is unaffected by the
1561
- column it stays the affected-PAGE count in every scope, so read the table
1562
- (or the page entry's own `hits`) when you want the per-page hit total.
1563
- - **`hits` is the unambiguous field; `count` means two different things.** On
1564
- this wire `count` is level-dependent: on a FINDING it counts affected **pages**
1565
- (so it is `1` for every finding of a single-page audit), and on a **page entry**
1566
- it counts hits on that page. One name, two meanings — and that ambiguity has
1567
- already produced a live misread, an agent reporting "one image is affected"
1568
- when nineteen were. So the host emits **`hits`** next to a page entry's `count`
1569
- whenever that count really is a hit tally: `findings[].pages[].hits`. Prefer it.
1570
- It is an alias, never an invention a page entry with no hit total (the PAGE
1571
- ITSELF is the hit: `thin-content`, `empty-page`, `duplicate-layout`) carries no
1572
- `hits` key at all, and a host plugin older than the alias sends only `count`.
1573
- Fall back to `pages[].count` in both cases, never to `findings[].count`.
1574
- - **On the plugin-served transport there is no summary table at all.** The
1575
- `HITS`/`COUNT` column is rendered by the npm server. When the MCP server is the
1576
- CMS plugin itself, the response is the JSON payload — so nothing labels the
1577
- columns for you and the guidance above is the only thing standing between you
1578
- and the misread. Read `findings[].pages[].hits` (falling back to
1579
- `pages[].count`) and **never report `findings[].count` as the size of a
1580
- single-page problem**: in single-target mode it is structurally `1`.
3046
+ keeps the per-hit locators under `findings[].pages[].locators` in both scopes.
3047
+ Both legs of the answer are the same JSON payload — there is no rendered
3048
+ summary table and no `Where to fix:` block on either, in either scope, so
3049
+ nothing labels a column for you and the two counters below are the whole
3050
+ story.
3051
+ - **Two counters, two levels, and neither is called `count`.**
3052
+ `findings[].affected_pages` is how many PAGES that finding matched. In
3053
+ single-target mode it is structurally `1` for every finding, so it is never
3054
+ the size of the problem there. `findings[].pages[].occurrences_here` is how
3055
+ many occurrences sit on THAT page — 19 alt-less images ⇒ `19`. It is the
3056
+ uncapped total: `hits_truncated` shortens the listed locators, never this
3057
+ figure. Report `occurrences_here` as the size of a single-page problem, and
3058
+ never `affected_pages` an agent once reported "one image is affected"
3059
+ where nineteen were.
3060
+ - **A page entry with no `occurrences_here` is not a gap.** Some checks make the
3061
+ PAGE ITSELF the hit `thin-content`, `empty-page`, `duplicate-layout` — and
3062
+ those entries carry no occurrence total at all, because there is nothing on
3063
+ the page to count more than once.
3064
+ - **What `occurrences_here` counts, the response says once.** The top-level
3065
+ `occurrences_unit` states it in words: matching ELEMENTS in the saved layout
3066
+ tree, a FLOOR when a bound row repeats or content is conditional, since the
3067
+ saved tree enumerates neither. Pass that caveat on rather than presenting the
3068
+ number as what a visitor's browser renders.
1581
3069
 
1582
3070
  Two request shapes are refused up front, with a typed error rather than a
1583
3071
  round-trip: more than one address key → `too_many_targets` (pick one; use
@@ -1587,6 +3075,88 @@ exactly one page, so there is nothing for a type filter to narrow). A `title`
1587
3075
  that matches several pages returns `title_ambiguous` with a candidate list —
1588
3076
  show it, let the user choose.
1589
3077
 
3078
+ ### Design-Consistency-Check (`checks: ["design"]`)
3079
+
3080
+ A fifth audit category, alongside `seo`/`structure`/`content`/`links`, in the
3081
+ DEFAULT-ALL set (no opt-in flag needed — just call `pages_audit` normally, or
3082
+ narrow to it with `checks: ["design"]`). It flags markup that breaks OUT of
3083
+ the site's UIkit design system — most commonly a **plugin's own output**
3084
+ (Formidable, ACF, a shortcode-rendered form) carrying none of YOOtheme's
3085
+ `uk-*` classes, e.g. a form submit button with no `uk-button`. Native
3086
+ YOOtheme builder elements (grid_item, panel, button, …) are never checked —
3087
+ the theme always renders them with the right `uk-*` class; there is nothing
3088
+ to flag there.
3089
+
3090
+ Three finding codes, all `warning` severity, none carrying a `fix_hint` (see
3091
+ below for why):
3092
+
3093
+ - **`design-missing-uikit-class`** — a contract-bearing element
3094
+ (button/select/textarea/input/`a[role=button]`/form/label-in-a-form) with
3095
+ no class at all, or one that already carries a DIFFERENT real `uk-*` class
3096
+ (on-system, just incomplete) — its expected class is simply absent.
3097
+ - **`design-foreign-class`** — the SAME element universe, but it carries
3098
+ class(es) and NONE of them are real UIkit classes — positive evidence a
3099
+ DIFFERENT framework (Bootstrap's `btn btn-primary`, a plugin's own
3100
+ `form-control`) is actively styling it instead of UIkit.
3101
+ - **`design-inline-style`** — ANY element (no tag restriction) with a
3102
+ non-empty `style=` attribute inside embedded content — bypasses the theme's
3103
+ design system entirely.
3104
+
3105
+ A FOURTH and FIFTH code share the `design` category but are not about markup at
3106
+ all — they read section props, not embedded HTML, so unlike the three above they
3107
+ are **builder-mode only** and never run in `mode: "rendered"` (the props do not
3108
+ exist in a rendered document):
3109
+
3110
+ - **`header-contrast-risk`** (`warning`) — the first section pulls the
3111
+ navigation over itself in a colour the theme's own `--uk-inverse` declaration
3112
+ contradicts, with no image or video hiding the background that declaration
3113
+ describes. Unlike the three above it DOES carry a `fix_hint`.
3114
+ - **`header-contrast-unverifiable`** (`info`) — the same transparent header over
3115
+ a hero medium, whatever the colour: nothing there is provable from the props,
3116
+ so no colour is recommended and readability is only verifiable by looking.
3117
+
3118
+ See their remediation-catalog entries below.
3119
+
3120
+ **Run the three markup codes in `mode: "rendered"` for the highest-value
3121
+ result.** A Formidable/ACF form's markup exists ONLY at render time — it is
3122
+ invisible to a builder-tree walk unless it happens to be typed static markup
3123
+ inside an `html`/`text`/`code` element. `mode: "rendered"` renders each page
3124
+ first (the SAME engine `render_preview` uses) and audits the ACTUAL output, so
3125
+ a plugin shortcode's real markup is exactly what gets checked. (The two
3126
+ `header-contrast-*` codes do NOT run in that mode — use the default
3127
+ `mode: "builder"` when you want them.)
3128
+
3129
+ ```jsonc
3130
+ yootheme_builder_advanced_read({
3131
+ tool: "yootheme_builder_pages_audit",
3132
+ arguments: { mode: "rendered", checks: ["design"], target: { special: "contact" } }
3133
+ })
3134
+ ```
3135
+
3136
+ **No `fix_hint` on any of the three codes — this is deliberate, not a gap.**
3137
+ The offending markup lives inside an opaque `props.content` string (an
3138
+ html/text/code element) or a plugin's own rendered output — there is no
3139
+ structured prop `element_update_settings` could merge a class into without
3140
+ string-surgery on customer HTML, which this server's read-only-analysis
3141
+ policy forbids on the write path. Report the locator's `content_snippet`
3142
+ (the offending element's OWN markup, e.g. `<button class="btn btn-primary">`
3143
+ — unlike every other finding's snippet, this one deliberately keeps the
3144
+ class/style attribute instead of stripping it, since that attribute IS the
3145
+ evidence) and tell the user to add the class by hand, or fix it in the
3146
+ plugin's own settings/template.
3147
+
3148
+ **Design-consistency fixes: ground the exact class via Context7 when it is
3149
+ available.** A `design-missing-uikit-class` or `design-foreign-class` finding
3150
+ asks you to name a real UIkit class by hand; recalling one purely from
3151
+ training data risks a subtly wrong suggestion, since class names can drift
3152
+ across UIkit versions. When the user's client has Context7 (or an equivalent
3153
+ documentation-grounding MCP) configured, query it (library id
3154
+ `/websites/getuikit`) to confirm the exact class before naming it in the fix.
3155
+ Without Context7, degrade cleanly: keep working from the tool's own output
3156
+ (the locator's `content_snippet`) plus the class names already catalogued
3157
+ above, and never invent a class name you have not confirmed one way or the
3158
+ other.
3159
+
1590
3160
  ### Remediation catalog — what each finding means and how to fix it
1591
3161
 
1592
3162
  The audit reports; YOU explain. Every finding below carries its fixed
@@ -1607,6 +3177,87 @@ on the CONTENT count (`elements_content`), so wrapper-only pages — three neste
1607
3177
  columns around nothing — are correctly flagged rather than passing as "not
1608
3178
  empty".
1609
3179
 
3180
+ #### `dangling-source` — structure · critical
3181
+
3182
+ **Why it matters.** The page is not empty and nothing errored: an element IS
3183
+ there, bound to a source name that no longer exists. YOOtheme resolves the
3184
+ binding to nothing and renders nothing, so the gap looks identical to a layout
3185
+ problem while the cause sits in the source list. Without this finding the only
3186
+ way to spot it is to compare every binding on the site against
3187
+ `sources_list` by hand.
3188
+ **How to fix.** Compare each reported `source_name` against
3189
+ `yootheme_builder_sources_list`. Renamed elsewhere → repair the reference with
3190
+ `yootheme_builder_repair_source_references({from, to})` (that is the shipped
3191
+ `fix_hint`; `to` must be a currently registered name). Genuinely deleted →
3192
+ unbind the element instead, or rebuild the binding against a source that
3193
+ exists.
3194
+ **Severity.** Critical: the element renders nothing at all, and the page ships
3195
+ looking finished.
3196
+
3197
+ #### `unknown-source-fields` — structure · critical
3198
+
3199
+ **Why it matters.** The value-side sibling of `dangling-source`: the bound
3200
+ source EXISTS, but one or more `field_mappings` values name a source field the
3201
+ source does not emit, so that mapped slot binds to nothing and renders blank.
3202
+ Neither `empty-page` (an element IS there) nor `dangling-source` (the source
3203
+ name IS known) catches this — it falls through both rasters, and the same
3204
+ diagnosis is what `element_get_binding`/`inspect_multi_items_binding` already
3205
+ surface for a single element inspected by hand.
3206
+ **How to fix.** Compare each reported `unknown_source_field_values` entry
3207
+ against the `unknown_source_field_values_available` list that rides alongside
3208
+ it, then `element_bind_source` with a corrected `field_mappings` value (that is
3209
+ the shipped `fix_hint`).
3210
+ **Severity.** Critical: the mapped value renders nothing at all, and — unlike a
3211
+ typo'd element prop, which is refused before it writes — this one persists
3212
+ silently.
3213
+
3214
+ #### `container-missing-item-child` — structure · warning
3215
+
3216
+ **Why it matters.** A multi-item container (`grid`, `button`, `slideshow`,
3217
+ `accordion`, …) repeats its `*_item` child once per source row. With no such
3218
+ child there is nothing to repeat, so the container renders nothing — again with
3219
+ no error anywhere. `element_add` already says this at write time; this finding
3220
+ catches the states that write never saw, such as the child being deleted later
3221
+ or a library insert that dropped it.
3222
+ **How to fix.** `element_add` the type named in each locator's
3223
+ `expected_child_type` under the reported container path (that is the shipped
3224
+ `fix_hint`). Read its required props from `element_type_get_schema`, whose
3225
+ `minimal_layout` shows exactly this container-plus-child shape. If the container
3226
+ is meant to be bound to a source, bind the CHILD, never the container.
3227
+ **Severity.** Warning, not critical: the element genuinely renders nothing, but
3228
+ building the shell first and its child second is the order `minimal_layout`
3229
+ itself teaches — a page caught mid-build is half-finished, not broken.
3230
+
3231
+ #### `binding-renders-nothing` — structure · warning (publish/save only)
3232
+
3233
+ **Why it matters.** A bind that persisted is not a bind that delivers. The
3234
+ element is there, the source name is valid, the field mappings are in place, the
3235
+ save succeeded — and the page renders zero rows. Nothing on that path says so,
3236
+ which is why a customer once bound, published, looked, re-bound with `limit`,
3237
+ published, looked again, and only then ran `render_preview` and read
3238
+ `rendered_item_wrapper_count: 0`. This finding is that sentence, arriving two
3239
+ cycles earlier.
3240
+ **How to fix.** Each locator names the element AND its `source_name`. Usual
3241
+ causes in order: the source needs a context this template does not have (an
3242
+ archive source on a single page has no term to scope to — check with
3243
+ `source_get_schema` whether it takes a term/id argument), a filter or
3244
+ `query_arguments` narrowed it to nothing, or the source genuinely holds no
3245
+ entries yet. To check ONE binding as you make it rather than at publish, pass
3246
+ `probe: true` to `element_bind_source`.
3247
+ **Severity.** Warning: a source with no rows yet is a legitimate state (an empty
3248
+ category), and the page is not broken — it is empty where content was expected.
3249
+
3250
+ > **Scope, and it is narrower than every other `per-page` code.** This check
3251
+ > needs a ROW COUNT, and nothing in this product can produce one without
3252
+ > rendering. It therefore reads the render report that `page_save` / `page_publish`
3253
+ > has ALREADY computed for its own render-precheck — so it rides `quick_check`
3254
+ > and **never appears in `pages_audit`**, not even with `checks: ["structure"]`.
3255
+ > Rendering every page of a site to run one check would turn a sweep into
3256
+ > minutes. It also stays silent when the render threw (the precheck owns that
3257
+ > verdict), when the render used its module fallback (a partial render cannot
3258
+ > settle a zero), and when nothing on the page was supposed to iterate at all —
3259
+ > a `headline` bound to one field renders content and zero rows by construction.
3260
+
1610
3261
  #### `no-headings` — structure · critical
1611
3262
 
1612
3263
  **Why it matters.** With no heading of any level the page has no machine-readable
@@ -1644,6 +3295,25 @@ second, independent heading channel), which is exactly what the raw path cannot.
1644
3295
  N times through an iterating container or a Multi-Items binding, so the fix is
1645
3296
  one edit on the item, not N edits; the locator's `×repeat` shows the multiplier.
1646
3297
 
3298
+ #### `heading-hidden-on-mobile` - structure · warning
3299
+
3300
+ **Why it matters.** Google indexes mobile-first: a heading that renders on
3301
+ desktop but is hidden at the base (phone) UIkit breakpoint is effectively
3302
+ invisible for ranking, even though it exists in the markup and a desktop
3303
+ review would never catch it.
3304
+ **How to fix.** No `fix_hint`. Hiding a heading on mobile is sometimes
3305
+ intentional (a duplicate mobile-only title elsewhere). Check the node's
3306
+ `props.visibility` (own or an ancestor's) and either remove the
3307
+ `hidden@`/`visible@` class or confirm an equivalent heading is visible on
3308
+ mobile some other way.
3309
+ **Severity.** Warning. Strict subset of ONLY the headline-TYPE portion of the
3310
+ pool `heading-skip` reads (since T-A1, `heading-skip` ALSO covers raw-HTML
3311
+ h1-h6 tags inside `html`/`text`/`code` elements - this check does not; a
3312
+ raw-HTML heading's mobile-visibility is not tracked): `hidden@<bp>` hides FROM
3313
+ that breakpoint upward (mobile stays visible), `visible@<bp>` is the mirror
3314
+ (hidden below it, mobile included), a bare `hidden` hides everywhere. Only the
3315
+ last two ever fire this check.
3316
+
1647
3317
  #### `heading-skip` — structure · info
1648
3318
 
1649
3319
  **Why it matters.** Jumping h1 → h3 breaks the document outline: assistive tech
@@ -1652,9 +3322,12 @@ reports a missing level, and the hierarchy stops describing the real structure.
1652
3322
  the jumped-to heading (`element_update_settings`, `props.title_element`) or add
1653
3323
  the missing intermediate level. The evidence lists each skip as
1654
3324
  `{ path, from, to }`.
1655
- **Severity.** Info: it is a real hierarchy defect but the page remains usable,
1656
- and the check is deliberately `headline`-only, so container-started hierarchies
1657
- are invisible to it. Do not present it as complete heading-hierarchy coverage.
3325
+ **Severity.** Info: it is a real hierarchy defect but the page remains usable.
3326
+ Reads BOTH `headline` elements AND raw-HTML h1-h6 tags inside `html`/`text`/
3327
+ `code` elements' `content` (interleaved in one true document-order sequence,
3328
+ since T-A1) - but container-STARTED hierarchies (e.g. a page whose only
3329
+ headings are 3× h3 grid-item titles with no h1/h2 anywhere) remain invisible
3330
+ to it. Do not present it as complete heading-hierarchy coverage.
1658
3331
 
1659
3332
  #### `images-missing-alt` — content · warning
1660
3333
 
@@ -1750,7 +3423,12 @@ prop is `title`. For an icon-only or image-only link, the right fix is
1750
3423
  `props.link_aria_label`, not invented visible text. Only link-bearing elements
1751
3424
  whose visible-text prop is known are checked — `button`, `button_item`,
1752
3425
  `grid_item`, `panel`, `headline`, `list_item`, `nav_item`, `subnav_item` — so
1753
- this is an allowlist, not a sweep over every element carrying a `link`.
3426
+ this is an allowlist, not a sweep over every element carrying a `link`. Since
3427
+ T-A1, a raw `<a href>` tag inside an `html`/`text`/`code` element's `content` is
3428
+ ALSO checked - that path judges every anchor with a set `href` (any
3429
+ destination, not just in-page), exempting an empty one only when it carries an
3430
+ `aria-label` or a nested `<img alt>` (the raw-HTML mirror of the allowlist's
3431
+ icon/image/aria-label exemption).
1754
3432
  **Severity.** Warning. The locator's `reason` says which rule fired: `empty` or
1755
3433
  `generic` (an **exact match** against the DE+EN generic list — `mehr`, `hier`,
1756
3434
  `weiterlesen`, `click here`, `read more`, `learn more`, …). Exact match only, so
@@ -1779,7 +3457,9 @@ the image actually shows ("SunWest Büro in Manresa").
1779
3457
  the EMPTY case). Only an **exact match** against the generic list (`image`,
1780
3458
  `photo`, `picture`, `logo`, `icon`, `bild`, `foto`, `grafik`) fires, so
1781
3459
  `"ACME Logo"` is already a good alt text and is never flagged. Bound alt/image
1782
- props are exempt.
3460
+ props are exempt. Since T-A1, a raw `<img alt="...">` inside an `html`/`text`/
3461
+ `code` element's `content` is checked the SAME way, judged against the
3462
+ identical generic list.
1783
3463
 
1784
3464
  #### `long-heading` — structure · info
1785
3465
 
@@ -1790,8 +3470,9 @@ keyword focus, get truncated in search results and wrap badly on mobile.
1790
3470
  detail into the body text below it. The locator carries the measured `length`.
1791
3471
  **Severity.** Info — a long heading is a quality signal, never a defect. The
1792
3472
  threshold is **70 characters**, measured multibyte-safely over the
1793
- markup-stripped text, on unbound static text only. Scope is deliberately narrow:
1794
- `headline` plus the standalone title carriers (`alert`, `overlay`, `panel`).
3473
+ markup-stripped text, on unbound static text only. Scope: `headline` plus the
3474
+ standalone title carriers (`alert`, `overlay`, `panel`), PLUS (since T-A1) every
3475
+ h1-h6 tag's own text inside an `html`/`text`/`code` element's raw `content`.
1795
3476
  Multi-Items containers keep their title text on the paired `*_item` children and
1796
3477
  are not measured — do not claim full heading-length coverage.
1797
3478
 
@@ -1799,21 +3480,172 @@ are not measured — do not claim full heading-length coverage.
1799
3480
  checks, so they also ride in the `quick_check` block on `page_publish` /
1800
3481
  `page_save` alongside the other per-page codes.
1801
3482
 
3483
+ #### `design-missing-uikit-class` — design · warning
3484
+
3485
+ **Why it matters.** A contract-bearing element (a form's submit button, its
3486
+ text inputs, its labels) with no `uk-*` class at all renders unstyled — it
3487
+ visually breaks out of the site's design system, most visibly on a
3488
+ plugin-rendered form (Formidable, ACF) dropped onto an otherwise polished
3489
+ page.
3490
+ **How to fix.** No `fix_hint` — see "Design-Consistency-Check" above for why.
3491
+ Add the missing class by hand (`uk-button` on a submit button, `uk-input` on
3492
+ a text input, `uk-form-label` on a label inside a form, `uk-form-stacked` or
3493
+ `uk-form-horizontal` on the form itself) — via the plugin's own class/CSS
3494
+ settings when it is a plugin form, or by editing the raw markup when it is a
3495
+ custom html/text/code element.
3496
+ **Severity.** Warning — a visual/consistency defect, not a functional one.
3497
+ Fires when the element carries NO class at all, or already carries a
3498
+ DIFFERENT real UIkit class (on-system, just incomplete) — never confuse this
3499
+ with `design-foreign-class` below.
3500
+
3501
+ #### `design-foreign-class` — design · warning
3502
+
3503
+ **Why it matters.** The SAME contract-bearing universe as
3504
+ `design-missing-uikit-class`, but here the element DOES carry class(es) and
3505
+ NONE of them are real UIkit classes — positive evidence that a DIFFERENT
3506
+ framework (Bootstrap's `btn btn-primary`, a plugin's own `form-control`) is
3507
+ actively styling it instead of UIkit. This is a REPLACEMENT problem, not a
3508
+ silence: the fix is a class swap, not an addition.
3509
+ **How to fix.** No `fix_hint`. Replace the foreign class with UIkit's
3510
+ equivalent (`btn btn-primary` → `uk-button uk-button-primary`), typically in
3511
+ the plugin's own template/class settings rather than in YOOtheme.
3512
+ **Severity.** Warning. The locator's `content_snippet` shows the element's
3513
+ own markup WITH its class attribute — the exact evidence, never tag-stripped
3514
+ like every other snippet in this audit.
3515
+
3516
+ #### `design-inline-style` — design · warning
3517
+
3518
+ **Why it matters.** A `style=` attribute inside embedded content (a
3519
+ `<div style="color:red">` in an html/text/code element, or in a plugin's
3520
+ rendered output) hard-codes styling that bypasses the theme's design system
3521
+ entirely — it cannot be themed, cannot respond to a style/color-mode change,
3522
+ and is invisible to any CSS audit that only looks at stylesheets.
3523
+ **How to fix.** No `fix_hint`. Move the styling into a UIkit class or the
3524
+ theme's own CSS, or remove it if it was accidental (a rich-text editor
3525
+ sometimes injects one). ANY tag can carry this finding — there is no
3526
+ tag restriction, unlike the two class-based checks above.
3527
+ **Severity.** Warning.
3528
+
3529
+ All three are `appliesTo: per-page` but — unlike `anchor-text-quality` /
3530
+ `generic-alt-text` / `long-heading` above — they deliberately do NOT ride the
3531
+ `quick_check` block on `page_publish` / `page_save`: `quick_check` is a
3532
+ publish-time advisory scoped to what a single-template pass can see without a
3533
+ live vocabulary, and design detection needs the site's actual compiled UIkit
3534
+ CSS. Run `pages_audit` (ideally `mode: "rendered"`) to see these three markup
3535
+ findings. The two `header-contrast-*` codes below skip `quick_check` for the
3536
+ same reason but are **builder-mode only** — `mode: "rendered"` never emits them.
3537
+
3538
+ #### `header-contrast-risk` — design · warning
3539
+
3540
+ **Why it matters.** The page's FIRST section pulls the navigation over itself
3541
+ (`header_transparent`) and forces a `header_transparent_text_color` the site's
3542
+ own theme does not declare for that section style. The forced colour wins —
3543
+ `.uk-inverse-light` / `.uk-inverse-dark` carry `!important` and beat the
3544
+ style's own declaration — so this is not a taste question: it is white
3545
+ navigation on a white hero, or dark navigation on a dark one. Nothing in the
3546
+ builder warns about it. See "Transparent header (the exception — never a
3547
+ default)" for the whole trap family.
3548
+
3549
+ **One class only, and it is decidable.** The page entry carries `risk_class`,
3550
+ `confidence`, `text_color`, `recommended_text_color`, `style` and
3551
+ `media_backdrop`:
3552
+
3553
+ - **`risk_class: "B"`, `confidence: "high"`** — no image or video covers the
3554
+ section background, so the contradiction is decidable from the layout alone.
3555
+ `recommended_text_color` is the theme's OWN declared inverse for that style
3556
+ (READ from the compiled `theme.css` — not a guess, not a computed contrast
3557
+ ratio).
3558
+
3559
+ **`pages_audit` never emits this code over a hero image or video.** A medium
3560
+ covers exactly the background `--uk-inverse` describes, so there is nothing to
3561
+ contradict and nothing to recommend; that state is the
3562
+ `header-contrast-unverifiable` info finding below, at one severity whatever the
3563
+ colour. A transparent header you do not see reported here is therefore *not*
3564
+ endorsed — look for the info finding.
3565
+
3566
+ **How to fix.** `fix_hint` → `header_transparency_set` with `mode: "auto"` and
3567
+ `dry_run: true`. The dry-run reports the current state, the theme-declared
3568
+ inverse, `risk_class`, `confidence` and exactly what would change; repeat with
3569
+ `dry_run: false` + an `etag` to write it. The tool resolves the FIRST section
3570
+ itself — you do not pass an `element_path`, and the repair therefore cannot
3571
+ land on a section whose props YOOtheme never reads. `mode: "auto"` writes the
3572
+ theme's own declared value, so no colour is ever guessed. Turning the
3573
+ transparent header off entirely is always the safe direction and is never
3574
+ refused (`transparent: false`).
3575
+
3576
+ **Severity.** Warning. It never blocks a write, and it is per-page but does NOT
3577
+ ride `quick_check` (same reason as the three design codes above: the verdict
3578
+ needs the site's compiled `theme.css`, which the publish-time pass does not
3579
+ read). **When that CSS cannot be read, or the theme declares nothing for that
3580
+ style, there is no finding at all** — an unreadable theme yields silence, never
3581
+ a guess. It is also **builder-mode-only**: `header_transparent*` are section
3582
+ props, so `mode: "rendered"` never emits this code — a clean rendered audit is
3583
+ NOT evidence that the header is fine.
3584
+
3585
+ **What it does NOT report.** A dead prop (a colour set while
3586
+ `header_transparent` is off) and props on a non-first section are not contrast
3587
+ risks and are not reported here — see the transparent-header section for those
3588
+ two traps.
3589
+
3590
+ #### `header-contrast-unverifiable` — design · info
3591
+
3592
+ **Why it matters.** The page's FIRST section pulls the navigation over itself
3593
+ AND carries a hero image or video —
3594
+ **whatever `header_transparent_text_color` holds**.
3595
+ Nothing here is provable: the theme's `--uk-inverse` declaration
3596
+ describes the section's BACKGROUND COLOUR, and the medium hides exactly that
3597
+ background, so "is the navigation readable?" cannot be answered from the layout
3598
+ at all. Which part of a `cover` image ends up under the header depends on the
3599
+ viewport.
3600
+
3601
+ `risk_class: "C"`, `confidence: "low"`, `recommended_text_color: null`.
3602
+
3603
+ **`null`, not `""` — this code recommends no colour at all.**
3604
+ `""` is not the humble answer: it is the theme's default colour, a concrete
3605
+ rendered value, so writing it back over a colour someone set deliberately (or
3606
+ measured in a browser) is a change, not an abstention. Over a dark hero photo
3607
+ on a `--uk-inverse: dark` style, `""` renders dark navigation on a dark image.
3608
+
3609
+ **One severity for the whole media case, deliberately.** An earlier revision
3610
+ graded it — `""` info, any other colour a warning — and that punished the
3611
+ correct action: the colour a browser measurement finds readable over a dark
3612
+ hero is usually the one the theme does NOT declare, so an eyes-verified page
3613
+ scored a warning whose remedy was to revert it. Behind a medium there is no
3614
+ baseline to deviate from, so there is nothing to grade.
3615
+
3616
+ **How to fix.** No `fix_hint`, because there is nothing to write. **Look at
3617
+ it** — render the page (see "Look at what you built") at the customer's
3618
+ viewport, unscrolled AND after scrolling, and read the navigation. If it is
3619
+ unreadable, set the colour that IS readable over that medium with
3620
+ `header_transparency_set({ mode: "explicit", text_color: … })` —
3621
+ a value you have seen, never one
3622
+ you inferred. `mode: "auto"` deliberately writes no colour here, so it cannot
3623
+ help you: it has not seen the page either. If it reads fine, say so and move on.
3624
+
3625
+ **The finding stays at `info` afterwards, and that is correct.** A transparent
3626
+ header over a medium is never provable from props, so the honest end state is
3627
+ "no warning, still only verifiable by eye" — not "clean". Report it as verified
3628
+ and move on; do not go looking for a colour that makes it disappear.
3629
+
3630
+ **Severity.** Info — an open question, not a defect. Same fail-open rule as the
3631
+ warning above: no readable `theme.css`, no finding. Same builder-mode-only
3632
+ restriction, same absence from `quick_check`.
3633
+
1802
3634
  ### The report contract — how to present an audit
1803
3635
 
1804
3636
  The raw response is a machine payload. What you hand the user follows this
1805
3637
  shape every time, so two audits of two sites read the same way:
1806
3638
 
1807
3639
  1. **Lead with the scope.** When `scope: "single"`, name the page the audit
1808
- actually ran on from `resolved_target` — label, `template_id`, and
3640
+ actually ran on from `resolved_target` — label, `layout_id`, and
1809
3641
  `matched_by` when the address was fuzzy ("`special: "home"` resolved to
1810
3642
  *Startseite* (`page:4816`)"). When the response has no `scope`, it was a site
1811
3643
  sweep: lead with `scanned.templates` and the `summary` totals.
1812
3644
  2. **Order by severity: critical → warning → info.** The server already sorts
1813
3645
  that way; never re-rank findings by how easy they look to fix.
1814
3646
  3. **Group page → category → finding.** One block per affected page, its
1815
- findings grouped by `category` (`structure`, `content`, `seo`, `links`), so
1816
- the user can fix one page in one pass instead of hopping between pages per
3647
+ findings grouped by `category` (`structure`, `content`, `seo`, `links`,
3648
+ `design`), so the user can fix one page in one pass instead of hopping between pages per
1817
3649
  code.
1818
3650
  4. **Render every hit from its locator**, in this shape:
1819
3651
 
@@ -1832,7 +3664,13 @@ shape every time, so two audits of two sites read the same way:
1832
3664
  `element_type`) plus the `breadcrumb` that locates it in the builder. A **bare
1833
3665
  path list is NOT acceptable output** — `/children/0/children/2` tells the user
1834
3666
  nothing about which box on their page to click. Add the page's `builder_url`
1835
- as the "open in builder" link when the response carries one.
3667
+ as the "open in builder" link and its `frontend_url` as the "view live" link,
3668
+ whenever the response carries them — every finding page carries both today
3669
+ (previously only the nine element-located checks carried `builder_url`, and
3670
+ only `non-pretty-url` carried `frontend_url`). A theme/archive/internal
3671
+ template with no public route or no resolvable admin base legitimately omits
3672
+ one or both — that is not a bug, and it is not the agent's job to look the
3673
+ URL up separately via pages_list/pages_resolve when it is missing here.
1836
3674
  6. **State what did and did not run.** On a site sweep, say the site-wide checks
1837
3675
  ran (`duplicate-layout`, `title-collision` compare pages against each other).
1838
3676
  On a single-page audit, say they did not — reuse the response's own `note`
@@ -1847,7 +3685,7 @@ shape every time, so two audits of two sites read the same way:
1847
3685
  first (every field of `alt_paths` / `anchors` / … also rides on the locator
1848
3686
  for the same hit, so nothing is lost), then the locator detail — halved
1849
3687
  before it is dropped — then the remaining raw evidence, then whole evidence
1850
- pages (`pages_truncated`), so page ids and labels outlive the prose. Both
3688
+ pages (`finding_pages_truncated`), so page ids and labels outlive the prose. Both
1851
3689
  transports shed in exactly that order; it is pinned by a contract test that
1852
3690
  derives the order from each implementation. Say so, and name the two ways
1853
3691
  back: raise `max_chars` (it bounds both legs on this tool), or
@@ -1859,9 +3697,10 @@ shape every time, so two audits of two sites read the same way:
1859
3697
  even for that floor, the plugin-served transport refuses with
1860
3698
  `max_chars_too_small` and tells you the minimum to ask for — it never returns
1861
3699
  a half-written payload.
1862
- 8. **Repeat the scope boundary.** Close with the response's `disclaimer` — meta
1863
- title/description/OG live in the CMS SEO plugin and are not covered here — so
1864
- nobody mistakes a clean audit for a clean SEO plugin.
3700
+ 8. **Repeat the scope boundary.** Close with the response's `disclaimer`
3701
+ verbatim, INCLUDING its own statement that the list is not exhaustive, so
3702
+ nobody mistakes a clean audit for a clean SEO setup. On a search-visibility
3703
+ question, check indexability first — see the pitfall above.
1865
3704
 
1866
3705
  Beyond that the prose is yours: group, summarise and prioritise however serves
1867
3706
  the user. Rules 2, 4, 5 and 6 are the parts that must not vary.
@@ -1878,7 +3717,7 @@ WARNING · structure · multiple-h1 — page renders 2 h1s [open in bui
1878
3717
  • Willkommen bei SunWest (Hero ▸ Headline) · headline
1879
3718
  → keep this one as the page's h1
1880
3719
  • Unsere Standorte (Karten ▸ Panel "Standorte") · panel-title · "Unsere Standorte"
1881
- → Fix: element_update_settings({ template_id: "page:4816",
3720
+ → Fix: element_update_settings({ layout_id: "page:4816",
1882
3721
  element_path: "/children/1/children/0", props: { title_element: "h2" } })
1883
3722
  Why: exactly one h1 anchors the page's topic; several dilute it.
1884
3723
 
@@ -1887,7 +3726,9 @@ WARNING · content · images-missing-alt — 1 image without alt text
1887
3726
  → Fix: element_update_settings({ …, props: { image_alt: "<what it shows>" } })
1888
3727
  Why: an empty alt makes the image invisible to screen readers and image search.
1889
3728
 
1890
- Meta title/description/OG live in your CMS SEO plugin not covered here.
3729
+ Not covered here, and not an exhaustive list: your CMS or SEO plugin owns meta
3730
+ title/description/OG, and whether search engines may index the page at all —
3731
+ check that with yootheme_builder_fetch_public_url({url, expect:"noindex"}).
1891
3732
  ```
1892
3733
 
1893
3734
  **Worked example (tool-call snippet):**
@@ -1899,62 +3740,80 @@ yootheme_builder_advanced_read({
1899
3740
  arguments: { checks: ["seo", "structure"] }
1900
3741
  })
1901
3742
  // A finding: { code: "missing-h1", category: "structure", severity: "warning",
1902
- // count: 3, pages: [{ id: "page:42", label: "About" }, ...],
3743
+ // affected_pages: 3, pages: [{ id: "page:42", label: "About" }, ...],
1903
3744
  // fix_hint: { tool: "element_update_settings",
1904
3745
  // args_template: { props: { title_element: "h1" } }, note: "..." } }
1905
3746
 
1906
3747
  // Step 3. Drill into the flagged page.
1907
- yootheme_builder_template_summary({ template_id: "page:42" })
3748
+ yootheme_builder_template_summary({ layout_id: "page:42" })
1908
3749
  // heading_levels: { h1: 0, h2: 2, h3: 5, div: 1 }, images_missing_alt: 2
1909
3750
 
1910
3751
  // Step 4. Fix that ONE page via the write tool the fix_hint named.
1911
3752
  yootheme_builder_element_update_settings({
1912
- template_id: "page:42",
3753
+ layout_id: "page:42",
1913
3754
  element_path: "/children/0/children/0",
1914
3755
  props: { title_element: "h1" },
1915
3756
  etag: "abc123"
1916
3757
  })
1917
3758
 
1918
3759
  // Step 5. Republish; quick_check re-confirms this page is clean.
1919
- yootheme_builder_page_publish({ template_id: "page:42", etag: "def456" })
3760
+ yootheme_builder_page_publish({ layout_id: "page:42", etag: "def456" })
1920
3761
  // Response: { ..., quick_check: { clean: true } }
1921
3762
  ```
1922
3763
 
1923
3764
  **Success criterion:** A re-run of `pages_audit` (or the `quick_check`
1924
3765
  block on the next publish) no longer lists the fixed finding for that
1925
- page, and the response's `summary.critical`/`warning`/`info` totals go
1926
- down.
3766
+ page, and the response's `summary.critical_finding_kinds` /
3767
+ `warning_finding_kinds` / `info_finding_kinds` totals go down. Those three
3768
+ count finding KINDS, not pages — `summary.affected_pages_summed_across_findings`
3769
+ is the page-level tally beside them.
1927
3770
 
1928
3771
  ---
1929
3772
 
1930
3773
  ## When something doesn't fit one of these 6 workflows
1931
3774
 
1932
3775
  - **Move an element** (intra-template reorder/reparent): use
1933
- `yootheme_builder_element_move({ template_id, element_path,
3776
+ `yootheme_builder_element_move({ layout_id, element_path,
1934
3777
  to_parent_path, to_index, etag })`. Reorders or reparents without
1935
3778
  re-creating.
1936
3779
  - **Delete an element**: use `yootheme_builder_element_delete({
1937
- template_id, element_path, etag, confirm: true })`.
3780
+ layout_id, element_path, etag, confirm: true })`.
1938
3781
  Elicitation-aware. Confirms via the AI client prompt before
1939
3782
  destroying state when `confirm` is omitted. On hosts without
1940
3783
  elicitation, it returns a preview-with-confirm-required response;
1941
3784
  call again with `confirm: true`.
1942
3785
  - **Unbind a source**: call through the gateway:
1943
- `yootheme_builder_advanced({ tool: "yootheme_builder_element_unbind_source", arguments: { template_id, element_path, etag, confirm: true } })`.
3786
+ `yootheme_builder_advanced({ tool: "yootheme_builder_element_unbind_source", arguments: { layout_id, element_path, etag, confirm: true } })`.
1944
3787
  Same elicitation flow as delete.
1945
3788
  - **Orient in a large template before editing** (cheapest first read):
1946
- `yootheme_builder_page_get_layout({ template_id, mode: "outline" })`
3789
+ `yootheme_builder_page_get_layout({ layout_id, mode: "outline" })`
1947
3790
  returns a token-efficient INDENTED text tree — one line per node,
1948
- `label ▸ type (bound: …) [sN]`. Each line carries a short handle
1949
- `[sN]` AND the node's real JSON-Pointer `path`. **The handle is
1950
- PER-READ (state-scoped): it is stable only at the etag you just read.
1951
- After ANY mutation (clone/move/delete/bind), re-read `mode:"outline"`
1952
- to get fresh handles.** Use the `path` (not the handle) as the write
1953
- address for `element_*` tools the handle is only a readable alias.
1954
- For binding-focused work prefer `mode:"bindings_only"` instead.
3791
+ `label ▸ type (bound: …) [sN]`. **The handle `[sN]` is a PER-READ
3792
+ (state-scoped) reading alias only, never a write address it is
3793
+ stable only at the etag you just read, and re-reads after ANY
3794
+ mutation (clone/move/delete/bind) get fresh handles.** The structured
3795
+ leg carries a SUMMARY (which sections are named, how many nodes each
3796
+ holds, the etag), not a path per node for the actual write address
3797
+ (a RELATIVE `element_path`) call `yootheme_builder_element_list`,
3798
+ scoped to one named section with `root_path` from the outline's
3799
+ `named_sections` if you already know which chunk you want. For
3800
+ binding-focused work prefer `mode:"bindings_only"` instead — and read it as
3801
+ an inventory that checks exactly ONE thing. It compares every binding's
3802
+ SOURCE against the sources this site registers: a source that is gone gets
3803
+ `unresolved: true` on that binding and is named once in
3804
+ `unresolved_sources`, with an `unresolved_sources_note` beside it saying what
3805
+ to repair. That list is present but EMPTY when the comparison ran and found
3806
+ nothing, and is replaced
3807
+ by `unresolved_sources_not_checked` when the registered-source list could not
3808
+ be read at all — in that state an unmarked binding means nothing, so do not
3809
+ read it as a clean bill. What it does NOT check is the field VALUES inside a
3810
+ source that does resolve: a `props` value naming a field that does not exist
3811
+ looks exactly like one that does and will render blank. `element_get_binding`
3812
+ names those dead VALUES on one element; `pages_audit`'s `dangling-source`
3813
+ finding reports the same dead sources across the whole site, with paths.
1955
3814
  - **Flat schema inspection** (e.g. enumerate every element path +
1956
- type without fetching the whole nested tree): call through the
1957
- gateway: `yootheme_builder_advanced({ tool: "yootheme_builder_page_get_schema", arguments: { template_id } })`.
3815
+ type without fetching the whole nested tree): call the L1 tool directly:
3816
+ `yootheme_builder_element_list({ layout_id })`.
1958
3817
  - **Etag-only fetch** (e.g. polling for concurrent edits): use
1959
3818
  `yootheme_builder_get_etag()` (takes no arguments) is cheaper than
1960
3819
  fetching the full layout.
@@ -1965,13 +3824,26 @@ down.
1965
3824
  `frontend_url_template` (with placeholders the user fills in).
1966
3825
  - **Find out which site / install you are connected to**: call
1967
3826
  `yootheme_builder_health` (Bearer-authenticated payload includes
1968
- `site_url` + `home_url`) or `yootheme_builder_diagnose`.
3827
+ `site_url` + `home_url`), or `yootheme_builder_diagnose` for `site_url`
3828
+ alone. `active_site.site_id`, which rides on every answer, names the registry
3829
+ entry your own config picked — a different question from which install the
3830
+ plugin believes it is.
3831
+ - **Find WHERE `implode` directives sit on the site** (rows rendering as one
3832
+ comma-joined string, and you do not know which page): call through the
3833
+ read gateway
3834
+ `yootheme_builder_advanced_read({ tool: "yootheme_builder_layout_query", arguments: { has_implode_directives: true, max_paths: 50 } })`
3835
+ — no `layout_id`, so the sweep covers every page layout, and `paths[]`
3836
+ gives you the `layout_id` + `path` pairs to act on. The detection is the
3837
+ same one `inspect_multi_items_binding` reports as `has_implode_directives`,
3838
+ so opening a hit confirms it. Every count in that answer describes the
3839
+ matching subset, not the site.
1969
3840
  - **Strip legacy `implode` directives** from an element binding (audit-clean
1970
3841
  source props that pre-date the wrapper-source refactor): call through the
1971
3842
  gateway:
1972
- `yootheme_builder_advanced({ tool: "yootheme_builder_clean_implode_directives", arguments: { template_id, element_path, etag } })`.
3843
+ `yootheme_builder_advanced({ tool: "yootheme_builder_clean_implode_directives", arguments: { layout_id, element_path, etag } })`.
1973
3844
  Returns the audit log + a fresh ETag; idempotent (`cleaned_count: 0` when
1974
- there is nothing to remove).
3845
+ there is nothing to remove). It also removes EMPTY `implode` stubs, which
3846
+ the finder above does not report — an empty stub joins nothing.
1975
3847
  - **Start from a pre-designed layout or element preset instead of
1976
3848
  hand-building** (the user wants a "Home"/"Contact"/"Services" page, or a
1977
3849
  ready-made Accordion/Alert preset, not a section built element-by-element):
@@ -1998,14 +3870,14 @@ layout root, then child 1 of that); a three-level path is
1998
3870
  into the Nth child (0-based). This is the form `element_list` emits as
1999
3871
  `rel_path`, and the form every worked example in this skill uses.
2000
3872
 
2001
- - **Fully-qualified:** `/templates/<template_id>/layout/children/0/...` is the
3873
+ - **Fully-qualified:** `/templates/<layout_id>/layout/children/0/...` is the
2002
3874
  same address with the template prefix. Read/write tools return the
2003
3875
  fully-qualified pointer as `path`; you can pass it straight back.
2004
3876
 
2005
- **What the tools EMIT:** `element_list` / `page_get_schema` return both `path`
3877
+ **What the tools EMIT:** `element_list` returns both `path`
2006
3878
  (fully-qualified) and `rel_path` (the canonical `/children/0/...` form); every
2007
3879
  write-tool response (`element_add`, `element_update_settings`, `element_clone`,
2008
- `element_move`, `element_bind_source`, `page_dynamize`, `scaffold_section`) and
3880
+ `element_move`, `element_bind_source`, `page_generate_dynamic`, `scaffold_section`) and
2009
3881
  `element_get` return the fully-qualified `path` plus the canonical `rel_path`.
2010
3882
  Copy either straight back into the next call.
2011
3883
 
@@ -2025,6 +3897,58 @@ hand you.
2025
3897
  layout node itself, which is not an addressable element. Use `""` for
2026
3898
  "the top".
2027
3899
 
3900
+ **What is NOT an element address.** The grammar above is exhaustive: an
3901
+ element is the layout root plus zero or more `/children/<index>` steps. These
3902
+ tools refuse anything else rather than writing into it — `element_add`,
3903
+ `element_delete`, `element_move`, `element_clone`, `element_update_settings`,
3904
+ `element_set_status`, `element_bind_source`, `element_unbind_source`,
3905
+ `element_set_source`, `clean_implode_directives`, `page_generate_dynamic`,
3906
+ `library_insert` (both `element_path` and `sublayout.element_path`) and
3907
+ `library_save_as`. In particular these resolve to real JSON but are **not**
3908
+ elements:
3909
+
3910
+ - `…/children` — the LIST of a node's children, not a child. Drop the
3911
+ trailing `/children` to name the element that should contain the child.
3912
+ - `…/props` — an element's settings. Pass the element's own path; the tool
3913
+ writes into `props` for you. A props map that happens to carry a `type` key
3914
+ (`props: {"type":"primary"}` is an ordinary element prop) is still props.
3915
+ - `…/type` — a scalar member of an element, not the element. Any member other
3916
+ than `children` ends the element grammar; pass the element's own path.
3917
+ - `/templates/<id>` — the template record. Its element tree starts one
3918
+ segment further down, at `/templates/<id>/layout`.
3919
+ - `…/children/007` — a leading-zero or gapped reference-token. Array positions
3920
+ are canonical RFC-6901 §4 indices; anything else names no position.
3921
+
3922
+ The layout root itself (`parent_path: ""`, or `/templates/<id>/layout`) is a
3923
+ valid **parent** for `element_add` / `element_move`, and never a thing to
3924
+ `element_delete`, `element_move` or `element_clone` — deleting it takes the
3925
+ whole page with it. Each refusal names the offending argument and what to
3926
+ pass instead; nothing is changed. (`page_generate_dynamic` is the one exception
3927
+ on the root: it COPIES rather than mutates, so `element_path` may name the
3928
+ whole page.)
3929
+
3930
+ **`children` must be a JSON array.** `pages_create`, `page_save` and
3931
+ `element_add` refuse a `children` sent as an object with named keys — `{"0":…,"5":…}` — with
3932
+ `layout_children_not_a_list`. The Builder walks `children` by position, so a
3933
+ keyed object there renders in insertion order, makes every `/children/<index>`
3934
+ path address a different node than it reads, and can overwrite a sibling on the
3935
+ next append. A `{"0":…,"1":…}` object with contiguous keys is accepted: it
3936
+ re-encodes as a JSON array, which is what lands on disk either way.
3937
+
3938
+ Only the layout root must declare `children`. An empty section, row or
3939
+ column may omit the key — that is how the builder itself stores them
3940
+ (YOOtheme's save-transforms remove an empty `children`). A `children` value
3941
+ that is present must be a JSON array, never an object.
3942
+
3943
+ **Theme templates in `library_insert` / `library_save_as`.** Those two tools
3944
+ resolve their path against the template as stored, and a THEME template
3945
+ (a `pages_list` id with no `page:` / `article:` prefix) stores its tree one
3946
+ segment down. `element_path: ""` still means "the top of this template" and
3947
+ works on both kinds; a deeper path on a theme template starts at
3948
+ `/layout/children/<index>`. Pass a `page:<id>` / `article:<id>` path there and
3949
+ the refusal says so. (The `element_*` tools are unaffected — their `rel_path`
3950
+ is layout-relative for every template kind.)
3951
+
2028
3952
  **Reading the index.** In `/children/0/children/2/children/1`: child 0 of the layout,
2029
3953
  then child 2 of THAT, then child 1 of THAT. Indices are positional and shift
2030
3954
  when siblings are added or removed; re-read after a structural mutation before
@@ -2032,10 +3956,12 @@ reusing a path (see the ETag handshake below).
2032
3956
 
2033
3957
  **Don't hand-build deep paths.** Enumerate with
2034
3958
  `yootheme_builder_element_list` (flat `{path, rel_path, element_type}`
2035
- rows) or orient with `yootheme_builder_page_get_layout({ template_id,
2036
- mode: "outline" })` (indented `label type [sN]` tree, each line
2037
- carrying the real `path`). Copy the `path` from a row — never count
2038
- `/children/` segments by eye on a big template.
3959
+ rows) copy `path` from a row there, never count `/children/`
3960
+ segments by eye on a big template. `yootheme_builder_page_get_layout({
3961
+ layout_id, mode: "outline" })` only ORIENTS (an indented
3962
+ `label type [sN]` tree plus a per-section node-count summary); scope
3963
+ `element_list` to one named chunk with `root_path` from the outline's
3964
+ `named_sections` when you already know which one you want.
2039
3965
 
2040
3966
  ## ETag handshake and self-correcting errors
2041
3967
 
@@ -2053,9 +3979,12 @@ carrying the real `path`). Copy the `path` from a row — never count
2053
3979
  carrying `current_etag` — read it and retry.
2054
3980
 
2055
3981
  So a write never leaves you guessing: on a lock error the response hands
2056
- you the etag to use next. On success, `changed_paths` tells you exactly
2057
- which addresses moved, so you can re-target without re-reading the whole
2058
- tree.
3982
+ you the etag to use next. On a `412` specifically, take that etag as
3983
+ permission to retry only AFTER re-reading the site-wide hash does not
3984
+ cover per-page layouts, so the conflict may well be your own page under
3985
+ someone else's edit, and the error cannot tell the two apart. On success,
3986
+ `changed_paths` tells you exactly which addresses moved, so you can
3987
+ re-target without re-reading the whole tree.
2059
3988
 
2060
3989
  **Self-correcting path errors.** When a path-addressed read cannot find
2061
3990
  the element at the path you sent, the `element_not_found` error does NOT
@@ -2087,50 +4016,555 @@ to re-read the entire layout after a wrong-path error.
2087
4016
  > `{given, nearest_path, nearest_path_label, available_paths}` envelope is
2088
4017
  > byte-identical across WordPress and Joomla.
2089
4018
 
4019
+ ## Response budgets — how a read tool shortens an oversized answer
4020
+
4021
+ Every high-volume read tool takes `max_chars`. When a response does not fit,
4022
+ it is **shortened by dropping whole entries, never by cutting the JSON**, and
4023
+ it tells you so. Read the verdict before you report a result as complete
4024
+ (one read caps a text FIELD instead — last paragraph, where these flags are
4025
+ absent):
4026
+
4027
+ - `truncated: true` — this response is not the whole answer.
4028
+ - `omitted_count: N` — exactly how many entries were left out.
4029
+ - `next_cursor` — present whenever the resume point is derivable; pass it back
4030
+ as `cursor` to read the entries that were dropped. It is **omitted rather
4031
+ than guessed**: no cursor means the tool could not derive one, not that
4032
+ nothing is missing. Use `omitted_count` in that case. A cursor that could not
4033
+ be rewound past the dropped entries is **removed, never left stale**, so a
4034
+ `next_cursor` you receive is always safe to follow.
4035
+ - `omitted_keys: [...]` — on a single-object response, which fields were shed
4036
+ (the small identifying ones like `layout_id` / `etag` are kept last).
4037
+ - `retry_hint` — the concrete lever: a higher `max_chars`, a narrower
4038
+ `fields[]` projection, or a smaller `limit`.
4039
+
4040
+ The payload always parses — in this shape the text leg is reduced entry by
4041
+ entry rather than cut mid-string. If you ever receive a response where
4042
+ **neither** leg parses, that is a bug — report it rather than working around it.
4043
+
4044
+ Here both legs carry the **same shortened payload**: reading `structuredContent`
4045
+ gives you exactly what the text leg already said, no more, so it is not a way
4046
+ around the cap. Decide whether an answer is whole from `truncated` /
4047
+ `omitted_count`, never from which leg you happened to read — and **absence of
4048
+ `truncated` means the answer is complete**, so do not hedge a full response with
4049
+ "there may be more".
4050
+
4051
+ **`page_get_layout(mode:"outline")` shortens differently, and none of the three
4052
+ rules above holds for it.** The cap bounds ONLY the outline text (both legs
4053
+ carry that same string); the summary beside it in `structuredContent` —
4054
+ `named_sections`, `total_elements`, `etag` — is never shortened. So here a
4055
+ bounded text leg DOES sit beside a complete structured one, the text is
4056
+ cut mid-string, not entry by entry, and the cut is announced by a trailing
4057
+ `--- TRUNCATED (N chars total, showing first M) ---` line, with **no
4058
+ `truncated` field and no `omitted_count`**. Read that marker; a missing flag
4059
+ means nothing here.
4060
+
4061
+ Two levers beat raising `max_chars`, because they reduce what is produced
4062
+ rather than what survives: ask for fewer fields (`fields[]`) and fewer rows
4063
+ (`limit` + `cursor`). Reach for `max_chars` last.
4064
+
4065
+ Every default is derived from measured response sizes, not chosen, and the same
4066
+ default applies on both transports — the proxy and the plugin's own `/mcp` are
4067
+ two paths to one tool (see `docs/mcp-response-budgets.md`). A derived class is
4068
+ applied **raise-only**: measurement may lift a default, never lower it, so no
4069
+ upgrade silently returns less than the version before it.
4070
+
4071
+ ### Ask for the fields you need — `element_type_get_schema`
4072
+
4073
+ A big element type is the sharpest case: `grid` declares **154** field
4074
+ descriptors. Do not read all of them and do not raise the cap — **name what you
4075
+ want**:
4076
+
4077
+ - `field_names: ["image_expand","grid_masonry"]` — exact names, case-insensitive.
4078
+ - `name_contains: "height"` — substring of the field's **name OR label**, so it
4079
+ also finds `image_expand`, whose label is "Height".
4080
+ - Both together **UNION** — each argument names what you want; adding one never
4081
+ removes what the other selected.
4082
+
4083
+ A selected descriptor comes back **COMPLETE** — its `enable` condition, `enum`
4084
+ and `default` are all there, which is exactly what a truncated read destroyed.
4085
+ Every response carries `field_count` (what the type declares) and
4086
+ `returned_count` (what you received), so a narrowed answer is never silent and
4087
+ you always know the size of what you did not ask for. A pattern that matches
4088
+ nothing returns an **empty list plus the counts, not an error**.
4089
+
4090
+ `field_names`/`name_contains` pick WHICH descriptors; `fields[]` picks the
4091
+ sub-keys of each one. They compose — selection runs first, so
4092
+ `name_contains:"height", fields:["name","enable"]` is a legitimate call.
4093
+
4094
+ ### Read a field's MEANING, not just its `label` — `text` and `description`
4095
+
4096
+ A descriptor carries YOOtheme's own two meaning-bearing keys, and on a
4097
+ **checkbox** they are not optional reading:
4098
+
4099
+ - **A checkbox's value is a boolean (`true`/`false`); its MEANING is in `text`,
4100
+ because `label` on a checkbox is the GROUP heading, not the option.** `grid`'s
4101
+ equal-height option is
4102
+ `{"name":"image_expand","type":"checkbox","label":"Height","text":"Expand image","enable":"!grid_masonry"}`.
4103
+ Read `label` alone and it says "Height: true/false" — which is not what the
4104
+ option does. The caption rendered next to the box is `text`: "Expand image".
4105
+ Same shape for `image_loading` ("Loading" / "Load image eagerly"), `panel_link`
4106
+ ("Link" / "Link panel") and `panel_match` ("Height" / "Match panel heights").
4107
+ - **`description` is the Builder's own tooltip** for the field, e.g.
4108
+ `grid.panel_content_width` → "Set an optional content width which doesn't
4109
+ affect the image if there is just one column."
4110
+
4111
+ Both keys are **absent when YOOtheme declares none**, so a descriptor without
4112
+ `text` means "this field has no caption", never "the caption was dropped".
4113
+
4114
+ ### When the field says nothing, the GROUP does — `group` and `groups[]`
4115
+
4116
+ **YOOtheme parks the semantics of a whole class of fields on the enclosing
4117
+ fieldset GROUP, not on the field.** So a descriptor with no `label` and no
4118
+ `description` is not undocumented — you are reading at the wrong level.
4119
+ `group` names the nearest enclosing group (the heading the Builder renders beside
4120
+ the field), and the schema's **root `groups[]`** states that group's own sentence
4121
+ once. Join the two on the group name:
4122
+
4123
+ `{"name":"image_width","type":"number","placeholder":"auto","enable":"show_image || show_video","group":"Width/Height"}`
4124
+
4125
+ `"groups":[{"name":"Width/Height","description":"Setting just one value preserves the original proportions. The image will be resized and cropped automatically, and where possible, high resolution images will be auto-generated."}]`
4126
+
4127
+ Read the field alone and `image_width` looks like a plain number. The rule you
4128
+ actually needed — **set only ONE of width/height and the proportions are kept;
4129
+ set BOTH and the image is CROPPED** — lives on the group shared by `image_width`
4130
+ and `image_height`. Same shape for the `lightbox_image_*` pair.
4131
+
4132
+ - The sentence is the **group's**, never re-labelled as the field's, so a field
4133
+ may carry its own `description` while its group carries another at once —
4134
+ they are two different statements. It sits at the root because it is shared:
4135
+ measured over nine element types, 32 fields carried only 5 distinct sentences,
4136
+ one of them 20 times.
4137
+ - `groups[]` **narrows with your selection** — a `field_names` /`name_contains`
4138
+ read carries only the groups its descriptors name, never the whole type's.
4139
+ - `placeholder` is `attrs.placeholder`, which for several fields IS the
4140
+ documented fallback: `grid.image_width` → `"auto"`, `countdown.label_days` →
4141
+ `"Days"`. An empty value renders the placeholder, it does not render empty.
4142
+
4143
+ ### What an enum VALUE does not say — `enum_labels`
4144
+
4145
+ `enum` carries the values you write. `enum_labels` carries the Builder's label for
4146
+ a value **where the value does not already carry it**:
4147
+
4148
+ `{"name":"grid_default","type":"select","label":"Phone Portrait","enum":["1","2","3","4","5","6","auto"],"default":"1","enum_labels":{"1":"1 Column","2":"2 Columns","3":"3 Columns","4":"4 Columns","5":"5 Columns","6":"6 Columns"}}`
4149
+
4150
+ Without it, `["1"…"6"]` is six digits with nothing saying they are **column
4151
+ counts**; `grid_column_gap`'s `"collapse"` is a word with nothing saying the
4152
+ Builder calls it **None**.
4153
+
4154
+ - **A value with no entry is not unlabelled.** Its label is the value itself, up
4155
+ to case and punctuation — `"small"` is "Small", `"arrow-right"` is
4156
+ "Arrow Right". Only genuine renamings are listed, which is why the key is
4157
+ affordable: measured over nine element types, 611 of 2043 label/value pairs
4158
+ (29.9 %) are genuine renamings and the other 1432 are silent.
4159
+ - **`""` is a real choice, not a gap.** YOOtheme uses the empty string as a
4160
+ meaningful selection — `panel.video_autoplay` declares `'On' => ''`,
4161
+ `switcher_item.item_element` declares `'div' => ''` — and since an empty value
4162
+ carries no characters at all, its label is always in `enum_labels`
4163
+ (`{"":"On"}`). So `""` in an `enum` is a selection to make.
4164
+ - **A missing entry can also mean "not decidable".** When two options collapse
4165
+ onto the same value — `'On' => ''` beside `'No' => false`, because `false`
4166
+ stringifies to `""` — neither label is emitted for that value rather than one
4167
+ guessed. The same value in a DIFFERENT field may legitimately mean something
4168
+ else: in `grid`, `""` carries twelve different labels across its fields, and
4169
+ `"1-2"` is "50%" on one field and "Halves" on another. Read `enum_labels` per
4170
+ field; never carry one field's label to another.
4171
+
4172
+ ### A prop that is declared and does nothing — `no_effect`
4173
+
4174
+ A few props are declared in YOOtheme's config, ship a label and a tooltip, and
4175
+ are **read by no consumer**. For those the descriptor drops `text`/`description`
4176
+ — forwarding a promise nothing keeps is worse than silence — and carries
4177
+ `no_effect` with what was actually read instead:
4178
+
4179
+ `{"name":"lightbox_image_orientation","type":"checkbox","label":"Image Orientation","no_effect":"No effect in YOOtheme Pro 5.0.37: declared in grid/element.php and gallery/element.php only, read by no template. Writing it changes nothing.","enable":"lightbox","group":"Lightbox"}`
4180
+
4181
+ **A descriptor with `no_effect` is not a prop to set** — the write succeeds and
4182
+ nothing changes. The other one today is `panel-slider.show_hover_video`, dead
4183
+ because `panel-slider_item` lists `hover_image` twice and `hover_video` never.
4184
+ The prop stays writable on purpose: a stored layout may already carry the key,
4185
+ and rejecting it would break a round-trip. `enable` and `label` survive too —
4186
+ a Builder-UI condition and a UI name are not behaviour claims.
4187
+
4188
+ ### Which build a claim was read on — `claims_verified_on` and `no_effect_unverified`
4189
+
4190
+ That evidence was read in **one** YOOtheme tree. A site running a different
4191
+ build is a site nobody has read, so the response says which is which:
4192
+
4193
+ `"claims_verified_on":{"build":"5.0.37","here":"4.5.33","status":"differs","keys":["rule_notes","no_effect_unverified"]}`
4194
+
4195
+ - **`status:"match"`** — the claims were read on the build you are running.
4196
+ Take them as fact.
4197
+ - **`status:"differs"`** — read on `build`, not re-checked on `here`.
4198
+ - **`status:"unknown"`** — the site's YOOtheme version could not be resolved
4199
+ (`here` is `null`). It never means "probably the same".
4200
+
4201
+ `here` is resolved per host — from YOOtheme's runtime on WordPress, from the
4202
+ installed `yootheme` template manifest on Joomla — and on both it is the SAME
4203
+ value `yootheme_builder_health` reports as `yootheme_version`. So the two must
4204
+ agree: **if health names a build while this record says `unknown`, that is a
4205
+ resolver bug, not a fact about your site.** Check health first.
4206
+
4207
+ Off a `match`, `no_effect` **is not emitted at all**. The finding ships under
4208
+ **`no_effect_unverified`** instead, naming both builds and ending in `UNKNOWN`,
4209
+ and `text`/`description` **stay** — those are YOOtheme's own words read from
4210
+ *your* build's live registry, so they are true for you, and only a verified
4211
+ deadness earns their removal. Every `rule_notes` entry is likewise prefixed
4212
+ `[Read in YOOtheme Pro <build>. … — unverified here.]`.
4213
+
4214
+ **`differs` does not mean the claim is false on your build** — it means nobody
4215
+ looked. Treat `no_effect_unverified` as a strong reason to check before relying
4216
+ on the prop, not as permission to assume it works.
4217
+
4218
+ **The record scopes exactly the keys it lists in `keys` — nothing else in the
4219
+ response.** It is emitted when, and only when, that list is non-empty, so scope
4220
+ and trigger are one thing and cannot drift apart. What can appear there:
4221
+
4222
+ | Key | Why it is build-scoped |
4223
+ |---|---|
4224
+ | `renders_only_if`, `blanked_by_parent`, `media_autoswap` | render gates read in one tree |
4225
+ | `rule_notes` | the shared render-rule texts, all read in that same tree |
4226
+ | `no_effect` / `no_effect_unverified` | whichever of the two actually shipped |
4227
+ | `value_hint` | **only** when the hint is one of the render-template reads: a value-FORMAT trap (`countdown.date`, `map_item.location`, `map.width`/`height`, `popover_item.position_x`/`_y`, `table.table_order`) or `image_expand` |
4228
+
4229
+ Everything else is **outside** it, whatever its own provenance — including
4230
+ `binding_contract`, whose `child_type` comes from a container/item table read on
4231
+ a *different* YOOtheme build, and `groups`, `enum_labels`, `semantic_role` and
4232
+ the descriptors themselves, which come from your own build's live registry.
4233
+ `placeholder_fallback` is outside for that second reason: it is derived from your
4234
+ own config, so it is true here by construction.
4235
+
4236
+ A `value_hint` that is **not** named in `keys` carries no single-build scope — the
4237
+ image/icon shape hints, the grid-layout string and the type scale state a
4238
+ convention rather than a render reading, and `title_element`'s per-type defaults
4239
+ were verified against **both** the 4.5.33 and the YT5 element sources. Do not read
4240
+ `keys` as "these are the only claims in the response"; read it as "these are the
4241
+ claims this record vouches for."
4242
+
4243
+ ### Read what no field text can say — `value_hint`
4244
+
4245
+ `text` and `description` are YOOtheme's own words. `value_hint` is ours, and it
4246
+ carries only what lives in the element's **render template** — a rule no field
4247
+ definition can state. The equal-height option is the sharpest case, and it is
4248
+ **per element**:
4249
+
4250
+ - **A `grid` is ALREADY column-matched with no option set** — its container always
4251
+ renders `uk-grid-match` (sole exception: `grid_masonry`). Switching
4252
+ `image_expand` on to equalise the **columns** solves a solved problem: what it
4253
+ expands is the **IMAGE**. The media element takes `flex:1`, and because every
4254
+ `img` renders with `object-fit:cover`, the added height **crops** the picture.
4255
+ - **The order matters and the wrong order fails SILENTLY.** `image_expand` needs
4256
+ its gate set FIRST: `panel_match` on `panel-slider`, `height_expand` on
4257
+ `panel`, `grid_masonry` OFF on `grid` and `gallery`. Otherwise the element's
4258
+ template **blanks the value at RENDER** — the write succeeds and the value stays
4259
+ stored, so there is no error anywhere. **A field's `enable` condition does not
4260
+ protect you**: it is a Builder-UI condition only — no render path reads it, so it
4261
+ never guards an API write. Read the field's own `value_hint` for that element's exact condition
4262
+ (most gates have an `image_align: left`/`right` escape; `gallery` has none).
4263
+ - On the two sliders the render can also **FORCE it on**. With `slider_width`
4264
+ unset, `panel-slider` forces `panel_match` and `image_expand` to true, and
4265
+ `overlay-slider` forces `image_expand` to true — as it also does whenever
4266
+ `height_expand`, `slider_min_height` or `height_viewport` is set. There an
4267
+ `image_expand: false` write is accepted and stays stored, then replaced by true
4268
+ at **RENDER**: on those settings the option cannot be switched off at all.
4269
+ - **The fixed-size alternative:** set **BOTH** `image_width` and `image_height` —
4270
+ both numeric crops server-side to exactly that box, one alone only resizes
4271
+ proportionally.
4272
+
4273
+ `name_contains: "expand"` returns that one descriptor with the whole rule set.
4274
+
4275
+ ### Will this element render at all? — `renders_only_if`, `placeholder_fallback` and `blanked_by_parent`
4276
+
4277
+ The schema root carries three arrays that decide whether a write is visible **at
4278
+ all**. They are element-level, so they are present on **every** read of that type
4279
+ — a `field_names` selection does not narrow them away.
4280
+
4281
+ **`renders_only_if` — an element with PRESENT-but-EMPTY required props is DELETED at render.**
4282
+ Not hidden, not empty: removed from the tree, with no error anywhere. It is a
4283
+ list of groups, and **ALL groups need at least one non-empty member**:
4284
+
4285
+ `"renders_only_if":[["title","meta","content","image","video","icon"]]` ← `grid_item`
4286
+
4287
+ `"renders_only_if":[["title"],["content","image","link"]]` ← `accordion_item`, which needs a title **and** one of the three
4288
+
4289
+ > **Read `placeholder_fallback` before concluding "nothing renders".** For props
4290
+ > you simply never wrote, the answer on **27 of the 28 gated element types** is
4291
+ > not deletion — see the next subsection. `renders_only_if` alone over-states it.
4292
+
4293
+ - **`link` is almost never a keeper.** It is absent from the gate of `grid_item`,
4294
+ `gallery_item`, `list_item`, `table_item`, `description_list_item` and `panel`,
4295
+ so a card carrying **only a link plus link text renders nothing at all**.
4296
+ `social_item` is the one element where a bare `link` is the whole gate — which
4297
+ is why this is a per-element read, never a blanket rule.
4298
+ - **The emptied parent goes too.** A container whose children all dropped is
4299
+ itself dropped, cascading column → row → section. So one unsatisfied gate on a
4300
+ repeated item can blank a whole section.
4301
+ - A member may be a `&&`-joined conjunction, and a `parent.` prefix addresses the
4302
+ **container's** prop: `switcher_item`'s second group lists
4303
+ `"title&&parent.show_title"`, i.e. with the container's `show_title` off a title
4304
+ alone does not satisfy it — meta, content or image is then required.
4305
+ - Media-only elements are the mirror image: `overlay`, `overlay-slider_item`,
4306
+ `slideshow_item` and `gallery_item` need an image or video and drop a text-only
4307
+ item.
4308
+
4309
+ **`placeholder_fallback` — an UNSET prop does not delete the element; it publishes
4310
+ YOOtheme's placeholder to the live page.** This is the difference between
4311
+ *absent* and *present-but-empty*, and absent is the normal shape of an MCP write:
4312
+
4313
+ `"placeholder_fallback":{"suppressed_by":["title","meta","content","image","video","icon","hover_image","hover_video"],"fills":["title","content"]}` ← `grid_item`
4314
+
4315
+ - **`fills` is what your visitors see.** YOOtheme merges the element type's own
4316
+ placeholder props *before* the gate is evaluated, on the **front end** as well
4317
+ as in the customizer, so the gate is satisfied by content you never wrote:
4318
+ lorem ipsum, the literal string `"Title"`, `"Headline"`, the icon `"star"`, a
4319
+ demo date, or `assets/images/element-image-placeholder.png`. On `overlay`, for
4320
+ instance, YOOtheme injects **both** its placeholder image **and** the literal
4321
+ text `"Title"` into the published page.
4322
+ - **`suppressed_by` is your off switch.** Write **any** key in that list —
4323
+ including to `""` — and the merge is skipped entirely for the whole element.
4324
+ Only then does `renders_only_if` delete. It is all-or-nothing: one written key
4325
+ suppresses every fill, not just its own.
4326
+ - **So there are three outcomes, not two.** Leave the props unset → placeholder
4327
+ content ships. Write one gate prop with real content → your content ships.
4328
+ Write a gate prop as `""` → the element is deleted. If you want an element
4329
+ gone, delete it; do not rely on leaving props unset.
4330
+ - **`social_item` is the one gated type with no placeholder at all**, so no key
4331
+ is present and an absent `link` really does drop the node. Absence of
4332
+ `placeholder_fallback` therefore means the gate is literal.
4333
+
4334
+ **`blanked_by_parent` — a container's `show_*` flag is NOT CSS visibility, and it
4335
+ is NOT a write either.** Both halves matter, and a reader who takes only one acts
4336
+ wrongly. It **empties the child's prop in the render transform**, so the value is
4337
+ gone from the **rendered HTML** while **the stored layout still holds it,
4338
+ unchanged** — flip the flag back and the render returns. The emptied value then
4339
+ feeds the child's own `renders_only_if`, in the same function, so clearing one
4340
+ flag can delete the **entire item**, not just the field you meant to hide:
4341
+
4342
+ `"blanked_by_parent":{"show_title":["title"],"show_image":["image","icon"],"show_link":["link"], …}`
4343
+
4344
+ - **MEASURED, YOOtheme Pro 5.0.37** — a two-item `grid`, written with
4345
+ `element_update_settings` + `page_publish`, read back with `page_get_layout`
4346
+ and from the published page. With `show_title: false` on the grid: rendered
4347
+ `.el-title` **2 → 0**, neither title string anywhere in the section HTML, and
4348
+ the grid's stored children subtree **byte-identical** before and after (equal
4349
+ sha256, 699 B, `diff` exit 0) — the whole-layout diff was **one added line**,
4350
+ `"show_title": false`, on the GRID node and nothing on any child. Setting it
4351
+ back to `true` restored the byte-identical baseline render. So **re-writing the
4352
+ props is never the fix; flipping the flag back is.**
4353
+ - **The whole item really does go — and takes its container.** In the state where
4354
+ the blanking left only `link` (which is not in `grid_item`'s gate), `.el-item`
4355
+ went **2 → 0** and the `.uk-grid` container with it (**2 → 1**; section HTML
4356
+ 2221 → 609 chars) — while the read-back still carried `link` and `link_text` in
4357
+ full. Nothing rendered; nothing lost.
4358
+ - **A source-bound item behaves the same — measured, not assumed.** With
4359
+ `grid_item[0]` bound to a native source and `grid_item[1]` left literal,
4360
+ `show_title: false` blanked **both** (`.el-title` 4 → 0), and the read-back
4361
+ still showed the binding intact (`source.props.title`) on the bound item and
4362
+ the literal string on the static one. For a bound prop the surviving stored
4363
+ value is the **binding**, and it survives exactly as a literal does. Measured
4364
+ on the REST write path and the published page only — the customizer's own
4365
+ tick-and-save path was not measured, and nothing here is claimed about it.
4366
+ - **Intersect the two arrays to see the blast radius.** Where a blanked prop also
4367
+ appears in the gate, the item can vanish — true for nine of the thirteen item
4368
+ types that blank. In `nav_item`, `subnav_item`, `slideshow_item` and `map_item`
4369
+ it cannot: their gate is `content` / `content` / `image`+`video` / `location`,
4370
+ which no `show_*` blanks.
4371
+ - **`show_image` also wipes `icon`** on `grid_item`, `panel-slider_item`,
4372
+ `list_item`, `nav_item` and `subnav_item` — the array shows it, so read the
4373
+ values rather than assuming one flag clears one prop.
4374
+ - **Some flags blank from the TEMPLATE, not the transform** — `map_item`'s five
4375
+ and `switcher_item`'s `show_title`, which is why `switcher_item` carries seven
4376
+ flags and not the six its transform loop enumerates. These empty the value for
4377
+ **rendering** but do not feed the element's own gate, so do **not** intersect
4378
+ them with `renders_only_if`: `switcher_item`'s gate reads `title` raw in its
4379
+ first group and consults `show_title` itself in the second
4380
+ (`"title&&parent.show_title"`), and `map_item`'s gate is `location`, which no
4381
+ flag touches.
4382
+ - `table_item`, `social_item` and `button_item` have no blanking at all. They
4383
+ still have gates.
4384
+ - On `description_list_item`, `link` renders **only** as a wrapper around
4385
+ `content`, so losing `content` loses the link too — by a different route than
4386
+ the flag.
4387
+
4388
+ ### `""` means INHERIT, not off — `empty_means_inherit`
4389
+
4390
+ A descriptor with `"empty_means_inherit":true` merges over its **container's**
4391
+ value. Because the empty string is falsy, writing it — which is what the
4392
+ Builder's **"None"** option stores — **re-inherits the parent** instead of
4393
+ switching the feature off. Item overrides are therefore **add-only**: a single
4394
+ item cannot be forced back to plain inside a styled container. **To make one item
4395
+ plain you must restyle the container, not the item.**
4396
+
4397
+ It is on **43 fields across 12 element types**, so read the flag rather than
4398
+ guessing the family: the styling props (`panel_style`, `*_text_color`, `media_*`)
4399
+ on `grid_item`, `gallery_item`, `panel-slider_item`, `overlay-slider_item` and
4400
+ `slideshow_item`, but also `link_text` on **ten** item types and
4401
+ `link_aria_label` on **eight** — **eleven** types carry one or the other,
4402
+ including `accordion_item`, `switcher_item` and `table_item` (`link_text` only)
4403
+ and `social_item` (`link_aria_label` only) — plus `list_item`'s `icon` and
4404
+ `image_svg_color` and `popover_item`'s `drop_position`. An empty `link_text` on a
4405
+ `table_item` shows the TABLE's link text, not no text.
4406
+
4407
+ ### Which media actually renders — `media_autoswap`
4408
+
4409
+ Image/video collisions are resolved **per element, and YOOtheme is not
4410
+ consistent about it**. Read the key; there is no default to assume.
4411
+
4412
+ `"media_autoswap":{"precedence":["video","image","icon"],"sniffed":["image","video","hover_image","hover_video"]}`
4413
+
4414
+ - **`precedence`** — the first non-empty prop renders; the losers are simply not
4415
+ drawn. All seven builder media elements (`grid_item`, `panel`,
4416
+ `panel-slider_item`, `gallery_item`, `overlay`, `overlay-slider_item`,
4417
+ `slideshow_item`) are **video-first**, so setting both image and video shows the
4418
+ video.
4419
+ - **`sniffed`** — these props are re-classified by URL sniffing and the value is
4420
+ **MOVED** between `image` and `video`, nulling the other. **The field you wrote
4421
+ may not be the field that renders**: an `.mp4` in `image` ends up in `video`.
4422
+ - **`clears_on_conflict`** — `section` and `column` **invert** the precedence
4423
+ (image-first) **and** are destructive: with an image set, `video` is set to
4424
+ `false`, so anything derived from it downstream sees no video. These two are the
4425
+ only elements that do this.
4426
+
4427
+ ### `rules` + `rule_notes` — the rules that need a sentence
4428
+
4429
+ The four keys above are arrays you can evaluate. Nine more rules have no array
4430
+ form, so they ship as prose — **stated once per response and referenced by id**,
4431
+ never repeated per prop:
4432
+
4433
+ ```json
4434
+ { "name": "slideshow",
4435
+ "rules": ["expand_model", "positional", "enable_is_ui_only"],
4436
+ "rule_notes": { "expand_model": "…", "positional": "…", "enable_is_ui_only": "…" },
4437
+ "fields": [ { "name": "slideshow_max_height", "enable": "!height_viewport",
4438
+ "rules": ["slideshow_max_height_vs_render"] } ] }
4439
+ ```
4440
+
4441
+ - `rules` on the **element** = rules about the container's own template. They
4442
+ survive a `field_names` / `name_contains` selection, because they are true of
4443
+ the element whichever descriptors you asked for.
4444
+ - `rules` on a **field** = rules about that prop. Their notes are narrowed away
4445
+ when the field is not returned, so a two-descriptor read pays for two.
4446
+ - `rule_notes` resolves every id the response still references, and only those.
4447
+
4448
+ **`enable` and `show` are Builder-UI conditions only — they are not enforced.**
4449
+ In YOOtheme 5.0.37 the word `enable` appears **0×** in the theme's
4450
+ `packages/builder/src`, so no PHP render path reads either key and neither one
4451
+ guards an API write. They are still projected verbatim (they are the best
4452
+ statement of intent available), but they are wrong in **both** directions: a
4453
+ value the UI would refuse to let you edit is still stored and can still take
4454
+ effect, and a value whose `enable` was satisfied can still be dropped or
4455
+ overridden at render. Every prop where the render is known to disagree carries
4456
+ its own rule id — `slideshow_max_height_vs_render`, `thumbnav_ui_locked`,
4457
+ `nav_grid_needs_two`, `overlay_slider_two_positions`,
4458
+ `panel_slider_show_hover_video_dead` — plus the five-way `image_expand` split,
4459
+ which is in that field's `value_hint`.
4460
+
4461
+ That last one is the shape to expect from the rest: on `panel-slider`,
4462
+ `show_hover_video` is a **dead switch**. Its three sibling containers (`grid`,
4463
+ `gallery`, `overlay-slider`) blank the item's `hover_video` when it is off;
4464
+ `panel-slider`'s item lists `hover_image` twice and `hover_video` not at all, so
4465
+ the hover video keeps rendering. Prop, label and default are identical to the
4466
+ three that work — nothing in the descriptor can reveal it. Clear the item's
4467
+ `hover_video` prop instead.
4468
+
4469
+ Four rules to know before you write anything:
4470
+
4471
+ - **Equal height is not one switch** (`expand_model`). A `grid` already matches
4472
+ its column heights with nothing set; a `row` **never** does; a `column` matches
4473
+ only under a specific prop combination, and setting a row height **removes**
4474
+ matching from every column. `image_expand` expands the image, not the layout —
4475
+ the content-expand lever is a `*_margin_auto` checkbox.
4476
+ - **A link toggle silently strips HTML** (`link_striptags`). With `panel_link` /
4477
+ `overlay_link` / `card_link` on and a `link` present, YOOtheme's own renderer
4478
+ runs `striptags()` over `title`, `meta` **and** `content`. The stored value
4479
+ keeps the markup, so the loss is invisible in a read-back. This is YOOtheme's
4480
+ renderer, not this server's, and it cannot be switched off.
4481
+ **`list_item` is worse and gets its own id** (`link_striptags_ungated`): there
4482
+ is **no checkbox** to turn off — as soon as the item has a `link`, `content` is
4483
+ stripped, on both the with-image and the without-image render branch. To keep
4484
+ markup there, leave `link` empty or move the markup to an element that is not
4485
+ link-wrapped.
4486
+ - **A lightbox or modal drops the four link attributes**
4487
+ (`modal_drops_link_attrs`). `link_target`, `link_download`,
4488
+ `link_rel_nofollow` and `link_rel_noreferrer` are emitted only in the
4489
+ plain-link render branch. With `lightbox` on and a link present, an earlier
4490
+ branch wins and all four vanish — on `image`, and on `grid` and `gallery`
4491
+ (whose item templates have the identical shape; the four props are declared on
4492
+ the container). `button_item` does the same for a `dialog` with
4493
+ `dialog_layout` `modal`/`offcanvas` and no `link`. Open-in-new-tab, download
4494
+ and rel hints cannot be combined with a lightbox.
4495
+ - **An emptied container disappears with it** (`collapse_cascade`). A container
4496
+ that HAD children and ends up with none is removed — the mark is keyed on the
4497
+ element type's own `container` flag, and **21 element types** declare it in
4498
+ 5.0.37, so `grid`, `gallery`, `slideshow` and the rest go exactly the way a
4499
+ `column` does. Items dropped by their own render gates therefore cascade
4500
+ upwards and a page can lose a whole section with no error anywhere. Only
4501
+ `column` offers `prevent_collapse`.
4502
+
4503
+ A field's **value format** is stated on the field itself, in `value_hint` — a
4504
+ value that does not parse is usually removed or defaulted silently, never
4505
+ rejected. `countdown.date` must be `strtotime()`-parseable or the element
4506
+ disappears; `map_item.location` must be numeric `"lat,lng"` or the marker is
4507
+ skipped (no geocoding); an empty `map.height` becomes 300; a non-numeric
4508
+ `popover_item.position_x`/`_y` becomes 50 %.
4509
+
2090
4510
  ## Dynamize an existing section (designed layout → live data, one call)
2091
4511
 
2092
4512
  **Goal:** Take a section you (or the customer) already designed by hand
2093
4513
  and make it data-driven — repeating over a source's items — WITHOUT
2094
4514
  rebuilding it node by node.
2095
4515
 
2096
- Use `yootheme_builder_page_dynamize`. It copies the referenced layout
2097
- byte-identically and swaps ONLY what you map, so the design (spacing,
2098
- styles, the `#parent` bindings) survives untouched.
4516
+ Use `yootheme_builder_page_generate_dynamic`. It copies the referenced
4517
+ layout byte-identically and swaps ONLY what you map, so the design
4518
+ (spacing, styles, the `#parent` bindings) survives untouched. It always
4519
+ creates a NEW, unpublished, unlinked page from the reference — the
4520
+ reference itself is never modified.
2099
4521
 
2100
4522
  **Canonical tool-call sequence (snake_case):**
2101
4523
 
2102
4524
  1. `yootheme_builder_get_etag()`: current lock.
2103
- 2. `yootheme_builder_template_summary({ template_id })` (or
2104
- `yootheme_builder_page_get_layout({ template_id, mode: "outline" })`):
4525
+ 2. `yootheme_builder_template_summary({ layout_id })` (or
4526
+ `yootheme_builder_page_get_layout({ layout_id, mode: "outline" })`):
2105
4527
  find the REFERENCE you will dynamize — a `section_name` (a named
2106
4528
  section from the summary) or an `element_path`.
2107
4529
  3. `yootheme_builder_sources_list()`: get the `list_source_name` to bind
2108
4530
  (the published source that yields the items).
2109
- 4. `yootheme_builder_page_dynamize({ template_id, section_name: "<name>", list_source_name: "<source>", leaf_map: { "<original-leaf-field>": "<flat-flow-field>" }, etag })`.
4531
+ 4. `yootheme_builder_page_generate_dynamic({ layout_id, output: "new_page", section_name: "<name>", list_source_name: "<source>", leaf_map: { "<original-leaf-field>": "<flat-flow-field>" }, etag })`.
2110
4532
 
2111
4533
  **How the mapping works:**
2112
4534
 
2113
4535
  - `leaf_map` keys are the ORIGINAL leaf field-names in the designed
2114
4536
  section; values are the flat flow field they should now read. The
2115
- `#parent` binding and any tuned props are kept.
4537
+ `#parent` binding and any tuned props are kept. A KEPT (unmapped) leaf
4538
+ whose original name is not a field the swapped source emits surfaces in
4539
+ the response's `summary.unmapped_kept_fields_missing_in_target` — map
4540
+ it or accept the empty render.
2116
4541
  - A section with SEVERAL source blocks dynamizes in ONE call via
2117
4542
  `source_map` — an array of `{ match, source_name, leaf_map }`, one
2118
4543
  entry per block.
2119
4544
  - For sub-query panels, key a `leaf_map` entry by
2120
4545
  `"<subquery>.<leaf>"`.
2121
- - `page_dynamize` requires `page.title`. It returns the page plus a
2122
- `summary`, and (since v1.4) `changed_paths` + `new_etag` like every
2123
- other write.
4546
+ - `output` is REQUIRED "new_page" is the only value available today; a
4547
+ future "in_place" value (editing the reference directly) is not yet
4548
+ available.
4549
+ - `page_generate_dynamic` requires `page.title`. It returns the page plus
4550
+ a `summary`, and `changed_paths` + `new_etag` like every other write.
4551
+ - With `dry_run:true`, a `would_change:false` answer carries a `reason` —
4552
+ most commonly "no bound exemplar element under the reference": the
4553
+ reference has nothing to swap because none of its leaves are bound yet.
4554
+ Bind one element first (`element_bind_source`), then retry.
2124
4555
 
2125
4556
  **Common pitfalls:**
2126
4557
 
2127
4558
  - **Binding Multi-Items on the container.** Bind the source + Multi-Items
2128
4559
  on the `*_item` CHILD, never on the grid/list container — see the
2129
- Multi-Items renderer truth table above. `page_dynamize` follows that
2130
- rule for you when you hand it the right reference.
4560
+ Multi-Items renderer truth table above. `page_generate_dynamic` follows
4561
+ that rule for you when you hand it the right reference.
2131
4562
  - **Hand-editing the JSON instead.** Don't reconstruct the section with
2132
4563
  `element_add` calls to "make it dynamic" — that loses the design.
2133
- `page_dynamize` is the supported one-call path.
4564
+ `page_generate_dynamic` is the supported one-call path.
4565
+ - **Expecting the reference page to change.** It never does — the result
4566
+ is always a NEW page. Publish the new page or copy its layout onto the
4567
+ reference yourself if you actually want an in-place swap.
2134
4568
  - **Reusing a stale etag.** Feed `new_etag` from the response into your
2135
4569
  next write.
2136
4570
 
@@ -2170,12 +4604,71 @@ apikey). See "WootsUp AI Layouts" below.
2170
4604
  Browse, get, AND insert against the remote catalog (`source: "catalog"`,
2171
4605
  the default) all depend on a YOOtheme Pro Library API key configured on the
2172
4606
  connected site (the customer's own theme `apikey`, set once in the YOOtheme
2173
- Customizer not something this MCP server manages). If it's missing,
2174
- `library_browse` does not error it returns `{ items: [], total: 0 }`.
2175
- Treat `total: 0` as "ask the customer to check the theme apikey field in the
2176
- Customizer", not a network/auth failure to retry blindly. **This apikey
2177
- requirement applies ONLY to the remote catalog** the local AI-Layouts
2178
- store (next subsection) needs no apikey at all.
4607
+ Customizer, not something this MCP server manages). If it's missing,
4608
+ `library_browse` does not error: it returns `{ items: [], total: 0 }` with
4609
+ `api_key_configured: false` and a `detail_note`. Read that boolean rather
4610
+ than guessing from the count. **This apikey requirement applies ONLY to the
4611
+ remote catalog.** The local AI-Layouts store (next subsection) needs no apikey
4612
+ at all.
4613
+
4614
+ **Do NOT tell the customer to check the apikey field until you know that is the
4615
+ problem.** `api_key_configured: false` means no key could be READ, and the
4616
+ reader answers that for five different situations. One of them is OUR lookup
4617
+ missing the row rather than anything the customer did, and it needs the
4618
+ opposite action. Run `yootheme_builder_diagnose` and read `library_key.state`:
4619
+
4620
+ | `state` | who has to act |
4621
+ |---|---|
4622
+ | `key_present` | nobody. The key is readable, so an empty catalog is a different problem. |
4623
+ | `update_site_without_key` (Joomla) | the customer, in the YOOtheme Customizer theme settings. |
4624
+ | `theme_config_empty` (WordPress) | the customer, same place. |
4625
+ | `theme_config_unavailable` (WordPress) | nobody yet: YOOtheme is not loaded at all. Check the theme is active, then run `yootheme_builder_health`. |
4626
+ | `update_site_not_found` (Joomla) | **nobody, in the key field.** No `pkg_yootheme` update site matched, so the Customizer would show that field empty too. Report the state instead of sending the customer to a field that cannot fix it. |
4627
+
4628
+ `library_key.hint` carries the same verdict as a sentence you can pass on, and
4629
+ `library_key.configured` is the boolean. The key itself is never in the payload.
4630
+
4631
+ **A REFUSED catalog fetch is NOT an empty catalog.** When the site HAS a key
4632
+ and the YOOtheme Pro Library rejects it (measured: HTTP 401 "Invalid API Key"),
4633
+ `library_browse` and `library_get` answer `error_code:
4634
+ "library_upstream_unavailable"` (HTTP 503) carrying `upstream_status` and
4635
+ `api_key_configured`. Do NOT respond to it by dropping filters, trying another
4636
+ id, or calling `library_get` to "verify" the id: nothing you send repairs a key
4637
+ the provider refuses, and `library_get` is refused by the same upstream. Only
4638
+ the site owner can take it up with YOOtheme. Meanwhile the keyless `source: "ai"`
4639
+ store and `page_get_layout` still work. A successful `total: 0` is a different
4640
+ thing entirely: the catalog was read and matched nothing, and that is now the
4641
+ only thing an empty page can mean.
4642
+
4643
+ **Browse can succeed while get and insert fail, and that is normal, not a
4644
+ bug.** The LIST endpoint the browse call uses answers even with NO key at all;
4645
+ the DETAIL endpoint (`/v1/library/layout/<id>`) that `library_get` and
4646
+ `library_insert` need CHECKS the key. Measured on two independent installs:
4647
+ `api.yootheme.com` refused those installs' configured key on every
4648
+ key-checking endpoint, the detail endpoint included, while browsing kept
4649
+ working normally. So "I can browse hundreds of layouts but every `library_get`
4650
+ fails" is a REFUSED KEY, not a bad id and not a broken plugin.
4651
+
4652
+ **Say what was measured, and stop there.** The refusal answers
4653
+ `library_upstream_unavailable` (HTTP 503) with `upstream_status: 401` and
4654
+ `api_key_configured: true`, and the honest report is exactly that: *the
4655
+ YOOtheme Pro Library refused this site's API key, so the call could not be
4656
+ made.* Then stop re-trying ids, because no id repairs a refused key.
4657
+
4658
+ **Do NOT tell the customer WHY it was refused.** You cannot see why. A 401 is
4659
+ the same 401 for a plan that does not cover the endpoint, an expired key, a
4660
+ key that was replaced, a domain or host restriction, and a change on
4661
+ YOOtheme's side. If the customer asks what to check, offer those as
4662
+ POSSIBILITIES and point them at their YOOtheme account; never present one as
4663
+ the diagnosis. Telling a paying customer their plan is the reason is a claim
4664
+ about their account that we have not established and cannot withdraw once the
4665
+ assistant has said it. Never phrase it as a defect in their site either.
4666
+
4667
+ **`library_item_unavailable` (404) is a DIFFERENT state.** It means the item
4668
+ could not be fetched at all: no Library API key is configured on the site, or
4669
+ the id is not in the catalog. A site whose key is configured and refused gets
4670
+ the 503 above, never this 404. Reading them as the same thing sends a customer
4671
+ who HAS a key off to configure one.
2179
4672
 
2180
4673
  ### WootsUp AI Layouts — your OWN local library (no apikey, save → reuse)
2181
4674
 
@@ -2449,8 +4942,16 @@ at fetch time, never persisted raw):
2449
4942
  the rest of the insert.
2450
4943
 
2451
4944
  The insert response's `images_resolved: { downloaded, placeholders }`
2452
- tells you exactly what happened — check it rather than assuming every
2453
- image round-tripped.
4945
+ tells you HOW MANY of each — check it rather than assuming every image
4946
+ round-tripped. It does not tell you WHICH, and a count on its own is not
4947
+ actionable: `image_import_failures` is. That field appears only when a
4948
+ curated insert actually failed to fetch something, and carries one entry
4949
+ per lost image with the `prop_path` inside the fetched item (e.g.
4950
+ `children[0].props.image`), the `exception_class`, and a `message`. Read it
4951
+ before reporting the insert as finished: a failure is often transient, so a
4952
+ second `library_insert` — or a searched-and-imported photo bound into that
4953
+ exact prop path — usually fixes it. Absent means nothing failed; on
4954
+ `images_mode: "as_saved"` or `"placeholder"` the curate step never ran.
2454
4955
 
2455
4956
  ### Fill image slots with real on-topic photos (search → import → bind)
2456
4957
 
@@ -2458,9 +4959,13 @@ The professionalism signal is on-brand IMAGERY (see "Make it the CUSTOMER's bran
2458
4959
 
2459
4960
  **The flow (three tools):**
2460
4961
  1. `yootheme_builder_image_search({ source, query, orientation })` returns candidate rows, each with a `full_url` (the image to import) plus dimensions and author. `source`: `unsplash` or `pexels` for real photography (the primary rich sources); `proimages` for YOOtheme's own curated decorative and background assets. Use `orientation: "landscape"` for heroes and banners, `"portrait"` for tall cards.
4962
+ A search that could NOT be carried out is an ERROR, not an empty shelf: it comes back with `error_code: "image_search_unavailable"` (HTTP 503). Do NOT respond to it by broadening the query. It means the provider refused the request or never answered, most often an HTTP 401 on the site's YOOtheme Pro Library API key (`upstream_status: 401`), which only the site owner can resolve. When that happens, stop searching, keep honest placeholders, and tell the user what `error` said. A SUCCESSFUL `total: 0` is a different thing entirely and DOES mean the query matched nothing; `api_key_configured` tells you whether the key gate was even open.
4963
+ **`api_key_configured: true` plus a 401 is a real, ordinary state: the site HAS a key and the upstream turned it down for this call.** That is the whole of what the two fields establish, and it is not a defect in the plugin or in the site. A YOOtheme Pro Library API key can be perfectly well configured and still be REFUSED. Measured on two independent installs: with such a key, EVERY key-checking endpoint at `api.yootheme.com` refused it, including `/v1/library/layout/<id>`, the DETAIL endpoint `library_get` and `library_insert` depend on. The LIST endpoint behind `library_browse` answers even WITHOUT a key, which is why browsing keeps working and only the detail reads and searches fail. So the honest reading of "browse fine, get and search refused" is: **the upstream refuses this site's key** for those calls. Report exactly that, and stop. **What you must NOT do is name the reason.** Whether it is the subscription behind the key, an expired or replaced key, a domain restriction, or something changed at YOOtheme, is not visible from here, and all of them look like the same 401. If the customer wants to act on it, list those as POSSIBILITIES to check in their YOOtheme account, never as a finding. Telling a paying customer their subscription is short when it is not is not a mistake you get to take back. Do NOT report it as a defect, do NOT retry with broader queries, and do NOT tell them the plugin is broken. Image search works on WordPress and Joomla alike.
2461
4964
  2. JUDGE the candidates by FIT and TONE, exactly like you choose a section, and never grab the first result. Search deliberately by the brief's INDUSTRY plus TONE: a coffee roaster searches `"coffee roasting"` / `"espresso pour"` / `"cafe interior"` (warm, editorial); a law firm searches `"modern office architecture"` (clean, restrained). A generic `query: "business"` returns generic stock that reads as filler.
2462
- 3. `yootheme_builder_image_import({ url: "<full_url>" })` sideloads the chosen photo into the site's OWN Media Library and returns `{ local_url }`. Only Unsplash, Pexels, and YOOtheme image hosts can be imported (a security allowlist); other URLs are rejected.
2463
- 4. Bind it: `yootheme_builder_element_update_settings({ template_id, element_path, props: { image: "<local_url>" }, merge: true })`.
4965
+ 3. `yootheme_builder_image_import({ url: "<full_url>" })` sideloads the chosen photo into the site's OWN Media Library and returns `{ local_url }`. The argument is also accepted under the name `full_url`, so a row from step 1 can be passed on unchanged — give ONE of the two, never both with different values. Only Unsplash, Pexels, and YOOtheme image hosts can be imported (a security allowlist); other URLs are rejected.
4966
+ **`local_url` has a PLATFORM-SPECIFIC shape, and both are correct.** WordPress returns a root-relative path WITH a leading slash (`/wp-content/uploads/2026/07/hero.jpg`); Joomla returns a JPATH_ROOT-relative path WITHOUT one (`images/yootheme/hero.jpg`, the same shape YOOtheme's own image dialog stores). Bind whatever came back VERBATIM. Adding a slash on Joomla or stripping one on WordPress produces a path the layout cannot resolve, and the element then renders empty with no error anywhere. Importing the same photo twice returns the FIRST attachment instead of creating a duplicate, as long as the earlier copy is still in the site's current upload folder (on WordPress that is `wp-content/uploads/YYYY/MM/`, so a copy imported in an earlier month is not matched and a second attachment is created).
4967
+ A failure never throws: it comes back as `{ imported: false, reason, failure }`. Read `failure` before deciding what to do next. `failure: "rejected"` means THIS url did not import (blocked host, upstream error, the CMS refused the file type), so picking a different candidate is the right move. `failure: "unavailable"` means the SITE cannot import any image right now (no media runtime, no HTTP transport), so stop importing, keep the placeholder, and tell the user what the `reason` said. Trying twenty more photos against an `unavailable` site only wastes the user's time.
4968
+ 4. Bind it: `yootheme_builder_element_update_settings({ layout_id, element_path, props: { image: "<local_url>" }, merge: true })`.
2464
4969
 
2465
4970
  **Real photo vs honest placeholder:**
2466
4971
  - A real, ON-TOPIC photo beats a placeholder every time; it is the single biggest lift from "template" to "finished". Fill the hero and feature-section image slots with searched photos that match the brief.
@@ -2482,7 +4987,9 @@ The most common way an AI-built page falls short of a real Pro layout: it defaul
2482
4987
  1. Compose from the real Library sections FIRST. The section corpus (`library_browse({kind:"section"})`) is about 2000 individually-designed, already-rich sections across every demo. Selecting well among them for fit, tone, and variety, and preserving their design, is the primary path to a varied Pro-quality page. Do NOT strip their rich features (below).
2483
4988
  2. When you hand-build a section (no corpus section fits, or you build from native elements), reach into the palette below and pick the rich pattern that fits. Never the bare-minimum version. Vary your choices across the page.
2484
4989
 
2485
- **The polish features Pro sections use constantly (these ARE what "rich" means): preserve them when composing, apply them when hand-building. The `panel` props named below are VERIFIED against the LIVE YOOtheme Pro 5.x `panel` schema (`element_type_get_schema("panel")` → 81 fields). `element_type_get_schema` is the source of truth — always confirm the exact key AND its enum values there before writing; a guessed key is hard-rejected and a guessed VALUE silently no-ops.**
4990
+ **The polish features Pro sections use constantly (these ARE what "rich" means): preserve them when composing, apply them when hand-building. The `panel` props named below are VERIFIED against the LIVE YOOtheme Pro 5.x `panel` schema (`element_type_get_schema("panel")` → 81 fields). `element_type_get_schema` is the source of truth — always confirm the exact key AND its enum values there before writing; a guessed key is stored and reported back under `warnings` (`code:
4991
+ "unknown_prop_keys"`) rather than refused, and a guessed VALUE either
4992
+ no-ops silently or, on an enum field, is refused with 422.**
2486
4993
  - **Card styling — THE single biggest "flat vs rich" lever: `panel_style` on a `panel`.** A bare `panel` (or a plain column of text) is what makes a section look flat; a `panel_style` turns it into a real styled CARD. Verified enum: `card-default` / `card-primary` / `card-secondary` / `card-hover` (hover-lift card) / `card-overlay` / `tile-default` / `tile-muted` / `tile-primary` / `tile-secondary`. A feature / team / pricing "card" is a `panel` WITH a `panel_style`, not three bare text columns.
2487
4994
  - **Hover** (on a `panel`; the image-hover props require a `link` + `panel_link`/`image_link`, per the schema `enable` rules): `hover_image` (swap the image on hover), `image_transition: scale-up`|`scale-down` (image zoom on hover), `title_hover_style: reset`|`heading`. `panel_style: card-hover` also gives a hover lift on its own.
2488
4995
  - **Depth**: `image_box_shadow` (`small`…`xlarge`) and `image_box_decoration` (`default`/`primary`/`secondary`/`shadow`/`mask`) — these apply to a panel image only when NO `panel_style` is set (they are mutually gated: use a `panel_style` OR the box-shadow/decoration, not both).
@@ -2516,7 +5023,7 @@ ones are legal depends on `kind`:
2516
5023
  | `target` shape | Accepts `kind: "layout"` | Accepts `kind: "element"` (preset) |
2517
5024
  |---|---|---|
2518
5025
  | `{ new_page: { title, status? } }` | ✓ creates a page, layout's sections become its content | ✗ rejected — a preset is not a page |
2519
- | `{ template_id, element_path? }` | ✓ ONLY the layout ROOT is section-accepting (`element_path: ""` or omitted) | ✓ ONLY a **Column** — every other node type is rejected |
5026
+ | `{ layout_id, element_path? }` | ✓ ONLY the layout ROOT is section-accepting (`element_path: ""` or omitted) | ✓ ONLY a **Column** — every other node type is rejected |
2520
5027
  | `{ sublayout: { template_id, element_path } }` | ✓ the fetched layout's sections are stripped to bare ROWS first (a sublayout/fragment accepts rows, not sections) | ✗ rejected — a sublayout is not a Column |
2521
5028
 
2522
5029
  **A preset (`kind: "element"`) MUST target a Column. There is no
@@ -2531,12 +5038,13 @@ first, then point `element_path` at ONE of the returned `column_paths`:
2531
5038
  yootheme_builder_library_insert({
2532
5039
  id: "8cf55920",
2533
5040
  kind: "element",
2534
- target: { template_id: "home", element_path: "" }
5041
+ target: { template_id: "home", element_path: "" },
5042
+ etag: "<etag>"
2535
5043
  })
2536
5044
  // → 422 target_not_a_column: 'target at "/" is a "layout"'.
2537
5045
 
2538
5046
  // Fix: scaffold a section->row->column hull, then target the new Column.
2539
- yootheme_builder_scaffold_section({ template_id: "home", columns: 1, etag: "<etag>" })
5047
+ yootheme_builder_scaffold_section({ layout_id: "home", columns: 1, etag: "<etag>" })
2540
5048
  // Response includes column_paths: ["/children/4/children/0/children/0"]
2541
5049
 
2542
5050
  yootheme_builder_library_insert({
@@ -2555,6 +5063,63 @@ separate strip step. Point `element_path` at the actual sublayout/
2555
5063
  fragment node itself, not its parent — pointing at the wrong level is a
2556
5064
  `422 target_not_a_sublayout`.
2557
5065
 
5066
+ **The table above is about the TARGET. The ITEM is checked too.** The
5067
+ composed result — the preset, or the layout's sections, or the stripped
5068
+ rows — goes through the SAME layout grammar `page_save` runs, before any
5069
+ image is imported. Two refusals come out of that, and neither is about
5070
+ where you pointed:
5071
+
5072
+ - `422 layout_invalid_hierarchy` — what would be stored is a shape the
5073
+ Page Builder never builds. The common case is a spine node saved as a
5074
+ preset: a Row or a Column put into your own library with
5075
+ `library_save_as` comes back classified as an element, and a Row inside
5076
+ a Column is refused. So is a whole layout spliced into a Column, and so
5077
+ is a **sublayout fragment at a page or layout root** — 67 of the shipped
5078
+ catalog layouts are fragments (the dropdown, search and dialog groups),
5079
+ served under `kind: "layout"` like any other layout. **The one
5080
+ destination that accepts them is a `sublayout` target — never a
5081
+ Column.** The Column branch of the target table only ever opens for
5082
+ `kind: "element"` presets; pointed at a Column, one of these fails
5083
+ before the hierarchy check even runs, with
5084
+ `422 target_not_section_accepting`, and that answer's own hint suggests
5085
+ the layout root or a `new_page` target — both refuse it too, for the
5086
+ same `layout_invalid_hierarchy` reason as the root case above. For this
5087
+ one item type, skip that suggestion and go straight to
5088
+ `target: { sublayout: { template_id, element_path } }`.
5089
+ - `422 library_item_kind_mismatch` — the item carries a different `kind`
5090
+ than the call declared. The declared kind alone decides which branch
5091
+ composes the item, so a layout handed over as `kind: "element"` would go
5092
+ in through the preset branch. The answer names both kinds; retry with
5093
+ the one the item carries. Items from your own library carry their own
5094
+ classification; a catalog id is looked up per kind, so there a wrong
5095
+ `kind` is already a `404 library_item_unavailable`.
5096
+
5097
+ Both are raised BEFORE the write and before any image import, so a
5098
+ refused insert stores nothing and imports nothing.
5099
+
5100
+ ### `insert_mode` — and the confirm gate that guards its default
5101
+
5102
+ An `existing`/`sublayout` target already has children whenever you insert
5103
+ into a page you (or a customer) already built. `insert_mode` decides what
5104
+ happens to them, mirroring the three choices the Library dialog itself
5105
+ offers:
5106
+
5107
+ | `insert_mode` | effect |
5108
+ |---|---|
5109
+ | `"replace"` (default) | DROPS the target's existing children; the composed content becomes the whole list |
5110
+ | `"top"` | prepends the composed content before the existing children |
5111
+ | `"bottom"` | appends the composed content after the existing children |
5112
+
5113
+ **`"replace"` on a non-empty target answers with a PREVIEW instead of
5114
+ writing, unless you pass `confirm: true`.** The preview reports
5115
+ `preview: true`, `applied: false`, `requires_confirmation: true`, and —
5116
+ so you know exactly what would be lost — `would_remove_count` (how many
5117
+ children) and `would_remove_types` (their `type`s, in order). Nothing is
5118
+ written on that call; re-send the SAME request with `confirm: true` to
5119
+ replace anyway, or switch to `insert_mode: "top"` / `"bottom"` to keep
5120
+ the existing content instead. An ALREADY-EMPTY target never needs
5121
+ `confirm`, whatever mode you pick — there is nothing there to lose.
5122
+
2558
5123
  ### Adapting a Library insert is STATIC content editing, not dynamizing
2559
5124
 
2560
5125
  **Every Library layout and preset is authored STATIC** — no source
@@ -2568,7 +5133,7 @@ section.
2568
5133
  an archive-style listing) is a deliberate, separate follow-up call, not
2569
5134
  something insert does for you.** Use the existing dynamize workflow
2570
5135
  documented in "Dynamize an existing section" just above this one
2571
- (`yootheme_builder_page_dynamize`) to re-target a Library-inserted
5136
+ (`yootheme_builder_page_generate_dynamic`) to re-target a Library-inserted
2572
5137
  section's leaf fields onto a Source, or bind a Multi-Items container
2573
5138
  directly with `yootheme_builder_element_bind_source` (Workflow 2) if the
2574
5139
  inserted item already has a grid/list container you want to re-point at
@@ -2590,7 +5155,7 @@ live data instead of its demo items.
2590
5155
  - **Stuffing placeholder copy into a field you have nothing for**, to
2591
5156
  defeat collapsing. Leave it empty; collapsing is the intended behavior.
2592
5157
  - **Expecting `library_insert` to make anything dynamic.** It composes
2593
- STATIC content only — dynamizing is the separate `page_dynamize` /
5158
+ STATIC content only — dynamizing is the separate `page_generate_dynamic` /
2594
5159
  `element_bind_source` call.
2595
5160
 
2596
5161
  **Worked example (browse → get → insert → adapt):**
@@ -2620,7 +5185,7 @@ yootheme_builder_library_insert({
2620
5185
  images: "placeholder",
2621
5186
  etag: "<etag>"
2622
5187
  })
2623
- // Response: { template_id: "home", inserted_path: "/children/3", element_count: 12,
5188
+ // Response: { layout_id: "home", inserted_path: "/children/3", element_count: 12,
2624
5189
  // images_mode: "placeholder", images_resolved: { downloaded: 0, placeholders: 6 },
2625
5190
  // new_etag: "<fresh>", frontend_url: null }
2626
5191
 
@@ -2628,7 +5193,7 @@ yootheme_builder_library_insert({
2628
5193
  // inserted section back first (page_get_layout / element_get) to find the
2629
5194
  // real headline's element_path, then edit CONTENT only — style untouched.
2630
5195
  yootheme_builder_element_update_settings({
2631
- template_id: "home",
5196
+ layout_id: "home",
2632
5197
  element_path: "/children/3/children/0/children/0",
2633
5198
  props: { content: "<customer's real headline>" },
2634
5199
  merge: true,
@@ -2705,58 +5270,69 @@ perform.
2705
5270
 
2706
5271
  <!-- TOOL-CATALOG:BEGIN -->
2707
5272
 
2708
- **49 catalogued tools** plus the `yootheme_builder_advanced` (write) + `yootheme_builder_advanced_read` (read-only) gateways = **51 reachable via `tools/list`** (37 L1 + 2 L3 + 2 gateways = 41 advertised; the gateways route to 10 additional advanced tools, bringing the total to 51 callable). Generated by `scripts/extract-tools.mjs` from the compiled `buildAllTools()` registry. Do not hand-edit this section; re-run `npm run build && node scripts/extract-tools.mjs` after changing tool definitions.
5273
+ **60 catalogued tools** plus the `yootheme_builder_advanced` (write) + `yootheme_builder_advanced_read` (read-only) gateways = **62 callable** end to end, of which **49 are advertised in `tools/list`** (45 L1 + 2 L3 + 2 gateways); the remaining 13 advanced tools are reachable only through the gateways. Generated by `scripts/extract-tools.mjs` from the compiled `buildAllTools()` registry. Do not hand-edit this section; re-run `npm run build && node scripts/extract-tools.mjs` after changing tool definitions.
2709
5274
 
2710
5275
  | Tool | Kind | Input keys | Description |
2711
5276
  | --- | --- | --- | --- |
2712
- | `yootheme_builder_clean_implode_directives` | destructive | `confirm`, `element_path`, `etag`, `site_id`, `template_id` | Use when a Multi-Items element renders rows as ONE comma-joined string instead of N repeated items — typically stale `implode` directives left by a plugin upgrade (detect via yootheme_builder_inspect_multi_items_binding). Strips `props.source.props.*.implode` directives from an element binding. Returns audit log + new ETag. Idempotent (cleaned_count: 0 when nothing to remove) but destructive: the binding can't be restored without re-mapping. Ask the user to confirm, then call with `confirm: true`. Requires ETag. Operates on the default site unless site_id is provided. |
2713
- | `yootheme_builder_diagnose` | read+idempotent | `site_id` | Full diagnostic: /health + authenticated /etag probe. Returns site_url, home_url, plugin reachability, Bearer validity in one call. First call when you need to know where the site lives. For per-template URLs see pages_list. Operates on the default site unless site_id is provided. |
2714
- | `yootheme_builder_element_add` | mutating | `children`, `element_type`, `etag`, `index`, `name`, `parent_path`, `position`, `props`, `site_id`, `template_id`, `type_name` | Add a new element to a template. Provide `parent_path` (or "" for root), `element_type` (e.g. "headline", "text", "grid"), and optional `props` / `children`. Content/leaf elements may only be added inside a `column`; only `section` is valid at the root — call yootheme_builder_scaffold_section first for a valid column path. Appends by default; pass `index` (0 = first) to insert. Inserting renumbers later siblingsre-read paths via element_list after any structural write. Returns the new element's JSON-Pointer path. Requires ETag. Operates on the default site unless site_id is provided. |
2715
- | `yootheme_builder_element_bind_source` | idempotent | `arguments`, `bindingLevel`, `directives`, `element_path`, `etag`, `field`, `field_mappings`, `query_arguments`, `relation`, `site_id`, `source_id`, `source_name`, `template_id` | Binds a Builder source to an element (canonical shape: `source` top-level). Pass `field_mappings` in the SAME call; bindingLevel "item" binds a container's first *_item child. Leaf under an iterating *_item → CONSOLIDATE onto item slots (409 slot_collision if taken/not a slot); leaf under a #parent fragment → INHERIT (`__node_item__`); standalone renders empty. List on a grid_item with children → iterator_is_leaf_renderer. Map: bind `map_item`, map `location` to a "lat,lng" field or no markers. Requires ETag. Call directly. Operates on the default site unless site_id is provided. |
2716
- | `yootheme_builder_element_clone` | mutating | `element_path`, `etag`, `site_id`, `template_id` | Clone an element as a sibling (same parent, immediately after the source). Returns the new element's path. Cloning renumbers later siblings re-read paths via element_list after any structural write. Requires ETag. Operates on the default site unless site_id is provided. |
2717
- | `yootheme_builder_element_delete` | destructive | `confirm`, `element_path`, `etag`, `site_id`, `template_id` | PERMANENTLY delete an element + children. Cannot be undone. Pass `confirm: true` (boolean) AND a fresh ETag. Deleting renumbers later siblings re-read paths via element_list after any structural write. If `preview:true` returns despite confirm, check `details.received_confirm` strings/numbers accepted, but boolean is canonical. Operates on the default site unless site_id is provided. |
2718
- | `yootheme_builder_element_get` | read+idempotent | `element_path`, `fields`, `max_chars`, `site_id`, `template_id` | Get the full element object at a specific JSON-Pointer path, including props and a `children_count` (NOT the children themselves — use yootheme_builder_element_list with root_path to enumerate children). `fields[]` narrows the returned object; `max_chars` bounds the text output. Address by template_id only resolve a URL/post first via pages_resolve. Operates on the default site unless site_id is provided. |
2719
- | `yootheme_builder_element_get_binding` | read+idempotent | `element_path`, `site_id`, `template_id` | Read the source binding attached to an element. Always returns {template_id, element_path, binding, has_binding, field_mapping_count, warning?}: `binding` is the raw source binding (bound source name, field-mappings, query args/directives); `has_binding` is false and `field_mapping_count` 0 when the element is not bound. Operates on the default site unless site_id is provided. |
2720
- | `yootheme_builder_element_list` | read+idempotent | `cursor`, `depth`, `fields`, `limit`, `max_chars`, `root_path`, `site_id`, `template_id` | List elements in a template as a flat array with JSON-Pointer paths + types + `label` (the builder-assigned name; "" when unset — not that the type lacks one). Scope with `root_path`/`depth` for a subtree, paginate with `limit`/`cursor` for large templates. `fields[]` narrows each row; `max_chars` bounds the text output. template_id only resolve a URL/post first via pages_resolve. Operates on the default site unless site_id is provided. |
2721
- | `yootheme_builder_element_move` | idempotent | `element_path`, `etag`, `site_id`, `template_id`, `to_index`, `to_parent_path` | Move an element to a new parent + index in the tree. Useful for reordering or reparenting (e.g. moving a card from one grid column to another). Moving renumbers later siblings — re-read paths via element_list after any structural write. Requires ETag. Operates on the default site unless site_id is provided. |
2722
- | `yootheme_builder_element_set_source` | idempotent | `element_path`, `etag`, `site_id`, `source`, `source_extended`, `template_id` | Surgically SET a precomposed `source` (+ optional `source_extended.props`) on ONE element at a pointer, ETag-locked (blast-radius 0). Use this — not element_bind_source — when you have the exact source shape OR need `source_extended.props`, the native Multi-Items item-mapping carrier bind_source cannot write. E.g. FAQ accordion scoped to term: query.name="categoryFaqs.taxonomyCategoryFaq", field={name:"faq"}, source_extended.props→#node. Element-prop keys the type does not render echo back as `unknown_prop_keys` (non-fatal). ETag req. Operates on the default site unless site_id is provided. |
2723
- | `yootheme_builder_element_type_get_schema` | read+idempotent | `element_type`, `fields`, `max_chars`, `site_id`, `type_name` | Fetch a type's prop schema before element_add/element_update_settings: unknown prop keys are hard-rejected (error lists valid_prop_keys + did_you_mean). Returns each field as {name,type,label?,enum?,value_hint?,group?}, field_count, and a binding_contract for container/item types. Read value_hint+enum first some defaults are SEO-fatal (headline.title_element→h1); checkbox = boolean true/false; group:"runtime-accepted" = honoured at render but hidden in the builder UI. Bound size with fields[]/max_chars. Operates on the default site unless site_id is provided. |
2724
- | `yootheme_builder_element_types_list` | read+idempotent | `fields`, `site_id` | List element types registered on this site (built-ins + YOOessentials/uEssentials extras). Names feed `element_type` of element_add. Pass `fields[]` to narrow each row. Keywords: "slider"/"carousel" `slideshow`/`panel-slider`/`overlay-slider`. Prop keys differ per element (grid_item `title` vs headline `content`) use element_type_get_schema before element_add/bind. Container rows carry `requires_child_type` (gridgrid_item); `has_children`/`has_children_support` are aliases (same value). Operates on the default site unless site_id is provided. |
2725
- | `yootheme_builder_element_unbind_source` | destructive | `confirm`, `element_path`, `etag`, `site_id`, `template_id` | Remove the source binding from an element. Clears the element's canonical top-level `source` key AND the legacy `props.source`. Destructive in the sense that it may break dynamic-content rendering always ask the user to confirm. Requires ETag. Operates on the default site unless site_id is provided. |
2726
- | `yootheme_builder_element_update_settings` | idempotent | `element_path`, `etag`, `merge`, `name`, `props`, `site_id`, `template_id`, `unset` | Update `props` on an element. Default replaces all props; pass `merge:true` for server-side deep-merge (only request keys overwritten, others surviveavoids read-modify-write races). Requires ETag. Pass `unset:["key",…]` to reset props to their YOOtheme default by removing them. Operates on the default site unless site_id is provided. |
2727
- | `yootheme_builder_get_etag` | read+idempotent | `site_id` | Get the current ETag (state revision) for the YOOtheme builder. Returns a sha256+revision string for optimistic locking. This ETag is SITE-WIDE: it covers the ENTIRE builder state, so ANY write to ANY template (by any client) bumps it a 412 means the site state advanced, not necessarily that YOUR template changed. It is the SAME token the read tools (page_get_layout, element_list) return in their `etag`. Pass it back as `etag` on any write tool; on 412, re-read and retry. Operates on the default site unless site_id is provided. |
2728
- | `yootheme_builder_health` | read+idempotent | `site_id` | Check plugin installed/reachable. Returns plugin version, YT Pro version, REST endpoints. Authenticated payload adds site_url + home_url for deep-linking. See yootheme_builder_diagnose for Bearer-validity + connectivity summary. Operates on the default site unless site_id is provided. |
2729
- | `yootheme_builder_image_import` | idempotent | `site_id`, `url` | Sideload a photo into the site's own Media Library. Accepts ANY public image URLit does NOT require a Library API key, so it still works when image_search is unavailable (typically pass a `full_url` from yootheme_builder_image_search). Returns `{imported:true, local_url}` where local_url is a SITE-RELATIVE path (e.g. /wp-content/uploads/...). Bind it via element_update_settings({props:{image: local_url}, merge:true}). A caught import failure returns `{imported:false, reason}`, never a 500. Operates on the default site unless site_id is provided. |
2730
- | `yootheme_builder_image_search` | read+idempotent | `color`, `orientation`, `per_page`, `query`, `site_id`, `source` | Search real on-topic photos (Unsplash/Pexels) or YOOtheme Pro Images to fill layout image slots with REAL, on-brand photography instead of bare placeholders. Returns candidate rows with `full_url` (pass straight to yootheme_builder_image_import) plus dimensions/author/orientation. `total:0` usually means no Library API key is configured (the same theme apikey library_browse/insert use). Operates on the default site unless site_id is provided. |
2731
- | `yootheme_builder_inspect_multi_items_binding` | read+idempotent | `element_path`, `site_id`, `template_id` | Reports Multi-Items binding state: container/item pair (grid↔grid_item, slideshow↔slideshow_item, map↔map_item, …), the binding level (none\|container\|item), and a recommended_fix when it sits on the container. For map/map_item, a bound item without a `location` ("lat,lng") field is flagged as a blocking map_location_warning (renders 0 markers). A List on a grid_item with nested children blocking iterator_is_leaf_renderer (children never render). If rows render as ONE comma-joined string, see clean_implode_directives. Operates on the default site unless site_id is provided. |
2732
- | `yootheme_builder_inspect_sublayout` | read+idempotent | `element_path`, `site_id`, `template_id` | Inspects a panel/grid_item `< SUBLAYOUT`: lists the `fragment` subtree bindable leaves (with JSON-Pointer paths to bind on NOT the container) and the render-time inner grid. Flags `ungated_grid_prop` (blocking): grid props set but the gate (title_align=left / image_align=left\|right) unsatisfied silent no-op. For container↔item binding see inspect_multi_items_binding. Operates on the default site unless site_id is provided. |
2733
- | `yootheme_builder_layout_query` | read+idempotent | `cursor`, `fields`, `has_binding`, `limit`, `max_chars`, `max_depth`, `name_matches`, `site_id`, `template_id`, `type`, `under_section` | Find elements in a template by a structured filter (no expression language). AND-combine any of: `type` (one type or a list), `name_matches` (case-insensitive substring of the element name), `has_binding` (bound vs unbound), `max_depth` (cap descent 0 = top-level), `under_section` (only nodes inside a named section). template_id only resolve a URL/post first via pages_resolve. Returns matching rows {path, element_type, label, has_binding}; paginate with `limit`/`cursor`, narrow rows with `fields[]`, bound text with `max_chars`. Operates on the default site unless site_id is provided. |
2734
- | `yootheme_builder_library_browse` | read+idempotent | `cursor`, `element_type`, `has_effects`, `kind`, `limit`, `purpose`, `query`, `role`, `site_id`, `source`, `topic`, `type`, `website` | Browse the YOOtheme Pro Library catalog: pre-designed page LAYOUTS, element PRESETS, or purpose-classified SECTIONS (hero/features/cta/...). Filter with `kind` ("layout" default \| "element" \| "section"), `topic`, `type`/`role`, `element_type`, `purpose`/`has_effects` (sections only), `website`, `query`. Returns `{items, total, next_cursor}`; a "section" id is the composite "<layoutId>#<sectionIndex>" feeds straight into library_get/library_insert. `total:0` usually means no Library API key configured. Operates on the default site unless site_id is provided. |
2735
- | `yootheme_builder_library_delete` | destructive | `confirm`, `id`, `site_id`, `source` | Delete ONE saved entry from your "WootsUp AI Layouts" store (the isolated library yootheme_builder_library_save_as writes into) clean up experimental sections/layouts the agent saved. Pass the `id` from a library_browse(source:"ai") row. `source` defaults to "ai", the ONLY deletable store; the curated library is read-only, so source:"catalog" returns a 403. IRREVERSIBLE requires confirm:true. Returns `{deleted, id, deleted_count, source}`; an unknown id returns a 404. Advanced/gateway-only. Operates on the default site unless site_id is provided. |
2736
- | `yootheme_builder_library_get` | read+idempotent | `id`, `include_thumbnail`, `kind`, `site_id`, `source` | Fetch a SINGLE YOOtheme Pro Library layout or element preset by `id` — call after yootheme_builder_library_browse (items[].id feeds this). Returns `{id, kind, name, version, tree, sections, images_manifest_count}`: `tree` is the raw JSON; `sections` lists `tree.children` (layouts only, presets return `[]`), each `{index, name, path, element_count}`. `include_thumbnail:true` also returns an image block (costs tokens see its own description). Read-only; does not insert/bind. Operates on the default site unless site_id is provided. |
2737
- | `yootheme_builder_library_insert` | mutating | `etag`, `id`, `images`, `kind`, `section_index`, `site_id`, `source`, `target` | Insert a YOOtheme Pro Library item (layout, section, or preset) onto a page — call after yootheme_builder_library_get/browse. `target`: new_page (creates a page), {template_id,element_path?} (splices onto the layout root, or a preset onto a Column), or sublayout (splices rows into a fragment). A preset (kind:"element") MUST target a Column — 422 otherwise; scaffold one first with yootheme_builder_scaffold_section. `images`: "placeholder" (default) or "curated" (downloads to Media Library). Optional `etag` locks (412 if stale). Operates on the default site unless site_id is provided. |
2738
- | `yootheme_builder_library_save_as` | mutating | `destination`, `name`, `site_id`, `source` | Save a page or subtree into a reusable library the REVERSE of yootheme_builder_library_insert. `source`: {template_id, element_path?} (omit element_path to save the WHOLE page). `destination`: "ai" (default your isolated "WootsUp AI Layouts", reusable via library_browse/insert source:"ai") or "native" (YOOtheme's built-in My Layouts). Returns `{library_id, name, storage, invalid_source_possible}` (the last flags bound sources that may need re-binding after reload). Advanced/gateway-only. Operates on the default site unless site_id is provided. |
2739
- | `yootheme_builder_local_content_read` | read+idempotent | `fields`, `include_children`, `kind`, `limit`, `max_chars`, `offset`, `parent_id`, `search`, `site_id`, `status`, `taxonomy`, `type` | Read native records for one `kind`: entities (posts/pages/CPT/articles), terms (TERMS ids that archive-scope a Grid), users, fields (ACF/com_fields defs), menus, or media. Published-only + paginated: {items,total,next_offset}; pass next_offset back as `offset`. Archive-scoping flow: read {kind:"terms",taxonomy:"category"} pick a term id bind a source, pass query_arguments:{id:<term_id>}. CALL local_content_types FIRST for valid type/taxonomy. E.g. {kind:"entities",type:"product",search:"shoe"}. Slim with `fields[]`. Operates on the default site unless site_id is provided. |
2740
- | `yootheme_builder_local_content_types` | read+idempotent | `fields`, `kind`, `max_chars`, `site_id` | Discover the site's NATIVE content landscape post types/articles, taxonomies and custom-field groups (ACF/com_fields), native to WP/Joomla (no API Mapper). Returns {content_types, taxonomies, field_groups, capabilities}; rows carry `bindable` + `source_name` (null for native types bind those with element_bind_source by the content-type `name`, e.g. "post"). CALL FIRST, then local_content_read for records. Narrow with `kind`, slim with `fields[]`. E.g. {kind:"content_types"}. Operates on the default site unless site_id is provided. |
2741
- | `yootheme_builder_page_dynamize` | mutating | `conditions`, `dry_run`, `element_path`, `etag`, `leaf_map`, `list_source_name`, `page`, `remove_leaves`, `section_name`, `site_id`, `source_map`, `template_id` | One-call way to dynamize a designed section: copies the layout byte-identically and swaps only what you map. Use INSTEAD of hand-editing JSON. Give a REFERENCE (section_name from template_summary, or element_path) + list_source_name + a leaf_map (original leaf field-name flat flow field; #parent + tuned props kept). A section with several source blocks dynamizes in ONE call via source_map ({match, source_name, leaf_map?}[]). Sub-query panels: key by "<subquery>.<leaf>". Returns page + `summary`. Preview with dry_run:true. Operates on the default site unless site_id is provided. |
2742
- | `yootheme_builder_page_get_layout` | read+idempotent | `cursor`, `fields`, `flat`, `force`, `max_chars`, `mode`, `post_id`, `site_id`, `template_id`, `url` | Get the layout tree for one templateaddress it by `template_id` (from pages_list), `post_id`, or `url`. `mode:"bindings_only"` for dynamize/bind; `mode:"full"` (default) is nested `{layout, etag}`, `flat:true` a depth-first array. `max_chars` bounds the text; a clipped read returns `next_cursor` to page the rest. For a site-wide SEO/structure/content/links audit (h1, image alt, thin pages, dead links) do NOT loop this tool page-by-page call yootheme_builder_pages_audit (one sweep via yootheme_builder_advanced_read). Operates on the default site unless site_id is provided. |
2743
- | `yootheme_builder_page_get_schema` | read+idempotent | `fields`, `max_chars`, `post_id`, `site_id`, `template_id`, `url` | Get the flat schema for a templatenodes with their JSON-Pointer paths and element types. Lighter than page_get_layout, enough to locate elements before editing. Address by `template_id` (pages_list), `post_id`, or `url`. `fields[]` slims each node; `max_chars` bounds output. Operates on the default site unless site_id is provided. |
2744
- | `yootheme_builder_page_publish` | idempotent | `etag`, `quick_check`, `site_id`, `template_id` | Publish a template persist state, flush YT + WP caches, snapshot the published-state ETag. YOOtheme templates already publish on save, so this is a cache-flush + state-snapshot op. Neither publish nor save changes the CMS post status a draft page stays invisible to visitors; set status at creation via pages_create/library_insert. ETag optional when provided, 412 on conflict; when omitted, last-write-wins. Operates on the default site unless site_id is provided. |
2745
- | `yootheme_builder_page_save` | idempotent | `dry_run`, `etag`, `layout`, `quick_check`, `site_id`, `template_id` | Re-run save-transforms + flush caches. Optionally REPLACE the template tree by passing the INNER {type:"layout",children:[...]} tree as `layout` page_get_layout's `.layout` child, NOT the whole response it returns (rejected 422, would blank the page). Validated + ETag-locked; nothing persists on reject. Does NOT change CMS post statusa draft stays invisible to visitors; set status via pages_create. `dry_run:true` preflights; success carries `previous_layout` for rollback. ETag optional (412 on conflict; else last-write-wins). Operates on the default site unless site_id is provided. |
2746
- | `yootheme_builder_pages_audit` | read+idempotent | `checks`, `max_chars`, `site_id`, `stale_after_months`, `target`, `thin_content_threshold`, `types` | One-call site-wide SEO/structure/content/links audit severity-sorted findings with fix_hints (each a props FRAGMENT to merge, not a ready-made call supply template_id + path from the finding locator). Covers h1/headings, thin pages, image alt, duplicate layouts, title collisions, non-pretty URLs, dead anchors. A SITE-wide sweep caps hits/page and drops per-hit content_snippet; scope to ONE page via `target` for the full set (cap 100). Filter with `types`/`checks`. Disclaimer: SEO-plugin meta (title/description/OG) NOT covered. Operates on the default site unless site_id is provided. |
2747
- | `yootheme_builder_pages_create` | idempotent | `dry_run`, `layout`, `menu_item`, `published`, `site_id`, `slug`, `status`, `title` | Create a page (WordPress) or article (Joomla 5/6) with a YOOtheme per-page layout. Required: title. Optional: slug, status (default draft), published (default false live on true: WP publish / Joomla state 1), layout (JSON), menu_item (Joomla SEF route), dry_run (preflight, no persist). Returns page_id (WP) / article_id (Joomla), slug, template_id (page:{id} or article:{id}), etag, warnings. Joomla: a published article has NO SEF route until a menu item points at it — pass menu_item, else frontend_url may 404. Operates on the default site unless site_id is provided. |
2748
- | `yootheme_builder_pages_delete` | destructive | `confirm`, `force`, `post_id`, `site_id` | Delete a page (WordPress) or article (Joomla 5/6) with its YOOtheme per-page layout. Requires confirm:true (gate against accidental deletion). Default: trash (recoverable). force:true: permanent delete. Refuses non-page/article types with 403 (Layer 9 defense). Returns deleted:true, page_id (WP) / article_id (Joomla), force_deleted, bumped etag. Operates on the default site unless site_id is provided. |
2749
- | `yootheme_builder_pages_list` | read+idempotent | `cursor`, `fields`, `label_contains`, `limit`, `max_chars`, `site_id` | List pages, templates, and layouts in the YOOtheme Pro builder (id, label, type, elements_count, frontend_url, builder_url, serves_context_kind, serves_context_ids per row). CALL THIS FIRST to discover template IDs before page_get_layout / element_list / page_get_schema. Paginates: up to `limit` rows (default 50) plus `total` and a `next_cursor` (pass it back as `cursor`) when more remain. Filter with `label_contains`. Slim rows with `fields:["id","label"]`; bound output with `max_chars`. Operates on the default site unless site_id is provided. |
2750
- | `yootheme_builder_pages_resolve` | read+idempotent | `site_id`, `target`, `url` | Resolve a public page/article/archive URL to the template_id that renders it paste a URL, get the layout id. Pretty/SEF permalinks resolve on WordPress AND Joomla (incl. deep sub-category paths), not only raw ?option= URLs. A singular page its per-page layout; a category/tag/CPT/author archive the governing THEME TEMPLATE, and for a category/term the SPECIFICALLY-ASSIGNED template (honoring include-child-categories), not a generic one. Or a `target`. Returns {template_id, post_id, type, frontend_url, context, resolved_via}. Operates on the default site unless site_id is provided. |
2751
- | `yootheme_builder_rename_source` | idempotent | `dry_run`, `etag`, `field_map`, `from`, `site_id`, `source_map`, `template_id`, `to` | Template-wide RENAME of a source / type / field name across EVERY binding in a template, in ONE ETag-locked write preserving each binding's arguments, props and field_mappings. Use after a taxonomy/CPT rename (faq→help) breaks every bound layout. Pass `from`/`to` for one source, and/or `source_map`/`field_map` to rename a relation + its field tokens together; the dotted "<source>.<relation>" form is rewritten segment-by-segment. Returns {replaced_count, changed_paths, new_etag}. Preview with dry_run:true. ETag req. Operates on the default site unless site_id is provided. |
2752
- | `yootheme_builder_render_preview` | read+idempotent | `expect`, `post_id`, `site_id`, `template_id`, `url` | Render-check a saved template — does it actually render, with how many items, or is it blank? Address by `template_id` (from pages_list), `post_id`, or `url` (resolved for you) exactly one. `item_count` = items yielded by the template's dynamic/Multi-Items bindings (rendered repeat-source rows); 0 with empty:true means a binding returned nothing. Returns {rendered, item_count, empty, error?, cache_purge_hint}; with `expect`, also {matched, snippet}. Use AFTER a save/bind to catch a silently-empty page. Read-only. Operates on the default site unless site_id is provided. |
2753
- | `yootheme_builder_scaffold_section` | mutating | `column_widths`, `columns`, `etag`, `site_id`, `template_id` | Create a VALID section row column hull in ONE call; returns the column JSON-Pointer path(s) for content. The correct way to start a layout block: only `section` may sit at the layout root; a bare section accepts `row`, a bare row accepts `column`; leaf/content elements are rejected anywhere except inside a `column`. Pass `columns` (1-12, default 1); columns get width tokens so they render side-by-side (hand-built columns default to full width), or `column_widths` (one token per column) for an asymmetric row. Requires ETag. Operates on the default site unless site_id is provided. |
2754
- | `yootheme_builder_sites_list` | read+idempotent | `site_id` | List all sites configured in this multi-site MCP installation. Returns site_id + URL + platform (wordpress\|joomla) + default flag per row. CALL THIS FIRST when working with a fresh MCP connection to discover available site_ids before targeting one with any other tool. Read-only, no REST calls. Keywords: list sites, list connections, list installations, discover site_id, available sites, configured sites, what sites exist, multi-site index. The `default_site_id` is the write target when a call omits site_id. (site_id is accepted for schema-uniformity but ignored by this tool.) |
5277
+ | `yootheme_builder_clean_implode_directives` | write+destructive+idempotent | `confirm`, `element_path`, `etag`, `layout_id`, `site_id` | Use when a Multi-Items element renders rows as ONE comma-joined string instead of N repeated items — stale `implode` directives left by a plugin upgrade (detect via inspect_multi_items_binding). Destructive: the binding can't be restored without re-mapping. The clean requires confirm:true + ETag; the dry run neither. Without `confirm` the call runs a server-side DRY RUN and reports `would_clean_count` while writing nothing. Live at once. |
5278
+ | `yootheme_builder_diagnose` | read+idempotent | `site_id` | Why a call was refused: `auth_bound` (key accepted), `etag_ok` (layout readable), `site_url` (WHICH install; a key valid elsewhere fails here), `capability_ok` + `capability_warning` (the CMS user has no `can_edit`, so no blind 403). Install facts: health. |
5279
+ | `yootheme_builder_element_add` | write+non-idempotent | `children`, `element_type`, `etag`, `index`, `layout_id`, `name`, `parent_path`, `position`, `props`, `site_id`, `type_name` | Add a new element to a template. Provide `parent_path` (or "" for root), `element_type`, and optional `props`/`children`. Content/leaf elements may only be added inside a `column`; at the root only `section` else call scaffold_section for a valid column path. Inserting renumbers later siblings: `renumbered_siblings` names each moved sibling address (absent if none). Requires ETag. Live at once in the stored tree already save-transformed (defaults filled, empty children removed). Stage hidden with element_set_status(status:false). Prop names are snake_case. Common traps: `content` (not `title`) on button_item/headline/list_item/quotation/text; check `element_type_get_schema` first. The answer carries no `props`, only the new address. The stored node also carries the element's defaults read it back with element_get. |
5280
+ | `yootheme_builder_element_bind_source` | write+destructive+idempotent | `arguments`, `bindingLevel`, `confirm`, `directives`, `element_path`, `etag`, `field`, `field_mappings`, `layout_id`, `probe`, `query_arguments`, `relation`, `site_id`, `source_id`, `source_name` | Bind a Builder source to an element (canonical shape: `source` top-level). Pass `field_mappings` in the SAME call; bindingLevel "item" binds a container's first *_item child. Leaf under an iterating *_item → CONSOLIDATE onto item slots (409 slot_collision if taken); leaf under a #parent fragment → INHERIT (`__node_item__`); a standalone query renders empty. Map: bind `map_item`, map `location` to a "lat,lng" field or no markers. Requires ETag. Live at once. CONVENIENCE half of a pair plain field names, but it REPLACES `source.props` whole, never merges; element_set_source is the CAPABILITY half. A re-bind that would drop anything already bound answers with a preview until you repeat it with `confirm: true`. To remove a binding, call element_unbind_source. |
5281
+ | `yootheme_builder_element_clone` | write+non-idempotent | `element_path`, `etag`, `layout_id`, `site_id`, `to_index`, `to_parent_path` | Clone an element. Default: as a sibling right after the source; pass `to_parent_path` to drop the copy elsewhere in the SAME template instead. Renumbers later siblings: `renumbered_siblings` names each moved sibling address (absent if none). Requires ETag. Live at once stage hidden with element_set_status(status:false). |
5282
+ | `yootheme_builder_element_delete` | write+destructive+non-idempotent | `confirm`, `element_path`, `etag`, `layout_id`, `site_id` | PERMANENTLY delete an element + children. Cannot be undone. Pass `confirm: true` AND a fresh ETag. Preview: neither. A real delete returns `deleted:true` and NO `requires_confirmation` key; a returned `requires_confirmation:true` means NOTHING was deleted. `renumbered_siblings` names each moved sibling address (absent if none). Live at once. Hide instead: element_set_status(status:false). |
5283
+ | `yootheme_builder_element_get` | read+idempotent | `element_path`, `fields`, `layout_id`, `max_chars`, `site_id` | Get the full element object at a JSON-Pointer path, including props and a `children_count` (not the children — use element_list with root_path). On a `module`/`module_position` element it also returns `resolved` (the referenced widget/module row) plus `resolved_reason` when it could not be resolved. `max_chars` bounds the text output. |
5284
+ | `yootheme_builder_element_get_binding` | read+idempotent | `element_path`, `layout_id`, `max_chars`, `site_id` | Read the source binding on an element. Returns {layout_id, element_path, binding, has_binding, bound_via_item, item_binding?}: on a multi-item container (grid/slideshow/accordion/...) the binding sits on the `*_item` CHILD, so `has_binding:false` there does NOT mean static — read `bound_via_item`/`item_binding` first. Site-wide REVERSE question (which elements hang on a source): yootheme_builder_layout_query (gateway-only), OMIT layout_id. To remove what this returns, call element_unbind_source. |
5285
+ | `yootheme_builder_element_list` | read+idempotent | `cursor`, `depth`, `fields`, `include_meta`, `layout_id`, `limit`, `max_chars`, `root_path`, `site_id` | List elements in a template as a flat array with JSON-Pointer paths + types + `label`. Scope with `root_path`/`depth`, paginate with `limit`/`cursor`. Rows carry BOTH addresses: `path` (absolute) and `rel_path` `rel_path` is the one write calls take as `element_path`. To find WHERE a module/widget is used, call local_content_read (gateway-only), never loop this per page. |
5286
+ | `yootheme_builder_element_move` | write+idempotent | `element_path`, `etag`, `layout_id`, `site_id`, `to_index`, `to_parent_path` | Move an element to a new parent + index in the tree reordering or reparenting. Renumbers siblings at BOTH ends: `renumbered_siblings` names each moved sibling address (absent if none). Requires ETag. Live at once. |
5287
+ | `yootheme_builder_element_set_source` | write+destructive+idempotent | `confirm`, `element_path`, `etag`, `layout_id`, `site_id`, `source`, `source_extended` | Surgically SET a precomposed `source` (+ optional `source_extended.props`) on ONE element at a pointer, ETag-locked. Use this — not element_bind_source — when you need `source_extended.props`, which bind_source cannot write. CAPABILITY half of a pair it also REPLACES `source.props` whole, never merges; element_bind_source is the CONVENIENCE half. Element-prop keys the type does not render echo back as `unknown_prop_keys`. WHOLE-NODE write: whatever you do not send is REMOVED. Omitting `source_extended` clears it exactly like passing `null`, and a `source` without `props` drops every prop mapping. Re-send what you want to keep. A call that would drop existing mappings answers with a preview until you repeat it with `confirm: true`. Live at once. To remove a binding entirely, call element_unbind_source. |
5288
+ | `yootheme_builder_element_set_status` | write+idempotent | `element_path`, `etag`, `layout_id`, `site_id`, `status` | Enable or disable an element via its `props.status` field the SAME field YOOtheme's own native "Disable element" checkbox writes. status:false excludes the element and its descendants from BOTH the live render and live findings; pages_audit (gateway-only) and template_summary content_nodes both honour it. Requires ETag. Live at once. |
5289
+ | `yootheme_builder_element_type_get_schema` | read+idempotent | `element_type`, `field_names`, `fields`, `include_fields`, `max_chars`, `name_contains`, `site_id`, `type_name` | Prop schema; read before element_add/element_update_settings: undeclared keys are STORED, not refused: warns unknown_prop_keys. NO element_type: lists the types. With one: head only binding_contract (containers/items) + groups[] + field_count. include_fields:true adds every descriptor {name,type,label?,text?,description?,enum?,value_hint?,group?,no_effect?}; Select descriptors: field_names/name_contains, answered with field_count/returned_count. value_hint+enum first: some defaults are SEO-fatal (headline.title_elementh1); checkbox = boolean true/false; no_effect = read by nothing, never write it; group:"runtime-accepted" = honoured at render, hidden in UI. Bound size with fields[]/max_chars. |
5290
+ | `yootheme_builder_element_types_list` | read+idempotent | `fields`, `include_meta`, `site_id` | List element types registered on this site (built-ins + YOOessentials extras). Names feed `element_type` of element_add. "slider"/"carousel" `slideshow`/`panel-slider`/`overlay-slider`. Prop keys differ per elementuse element_type_get_schema before element_add/bind. |
5291
+ | `yootheme_builder_element_unbind_source` | write+destructive+idempotent | `confirm`, `element_path`, `etag`, `layout_id`, `site_id` | Remove the source binding from an element the counterpart to element_bind_source. Clears the canonical top-level `source` key AND the legacy `props.source`may break dynamic-content rendering, always ask the user to confirm. ETag on the unbind only. Live at once. |
5292
+ | `yootheme_builder_element_update_settings` | write+idempotent | `element_path`, `etag`, `layout_id`, `merge`, `name`, `props`, `site_id`, `unset` | Update `props` on an element. Default replaces all props; pass `merge:true` for server-side deep-merge (only request keys overwritten). Requires ETag. `unset:["key",…]` resets props to their YOOtheme default and is valid ON ITS OWN: sent without `props`, it removes only those keys and touches nothing else. Send `props`, `unset`, or both a call with neither is refused. Live at once. |
5293
+ | `yootheme_builder_fetch_public_url` | read+idempotent+openWorld | `expect`, `max_excerpt_chars`, `site_id`, `url` | The independent proof a publish reached visitors: fetches a page of the CONFIGURED site over the public network as an ANONYMOUS visitor — no login/cookies/Bearer and reports http_status, page_title, an EXCERPT (never the whole document), html_bytes, and whether a cache served it (cache_verdict; "unknown" means no header said, NOT that nothing cached, and no re-fetch settles it — purge the cache instead). With `expect` it answers expect_found true/false. Refuses any address outside the configured site and never follows a redirect off it — an unrestricted fetcher is an open proxy. Get the url from `frontend_url` (pages_list / page_publish). Searches the WHOLE page (header, menu, footer too); a hit is no proof of THIS layout. render_preview renders ONLY that layout tree, never the theme wrapper. |
5294
+ | `yootheme_builder_get_etag` | read+idempotent | `site_id` | Get the current site-wide ETag for optimistic locking. ANY write to ANY template bumps ita 412 means the site state advanced, not necessarily your template. Same token page_get_layout/element_list return. Pass back as `etag` on writes; on 412, re-read and retry. The ETag is SITE-wide, not per template that is why this tool takes no `layout_id`. |
5295
+ | `yootheme_builder_get_skill` | read+idempotent | `site_id`, `topic` | Read a chapter of the full workflow guide. Without `topic`: the list of available topics, each with the SIZE of what it returns. With `topic`: that chapter. The connect-time `instructions` carry only a short preamble — this is how you reach the rest. A long chapter is served in PARTS: its row lists their slugs, and asking for the parent returns the chapter intro plus that list. A row carrying `oversized_note` is served whole and is large — read the note before asking for it. |
5296
+ | `yootheme_builder_header_transparency_set` | write+idempotent | `acknowledge_contrast_risk`, `dry_run`, `etag`, `layout_id`, `mode`, `pull_content_behind`, `site_id`, `text_color`, `transparent` | Inspect + repair the transparent header. Always targets the FIRST section later ones are silently dead. dry_run defaults TRUE: returns current state + the theme-declared --uk-inverse, risk_class, recommendation. mode:"auto" writes the theme-declared colour; over a hero image/video it writes NONE and says LOOK. mode:"explicit" refuses a colour contradicting the theme (409) unless acknowledge_contrast_risk. transparent:false is always allowed. |
5297
+ | `yootheme_builder_heading_outline` | read+idempotent | `layout_id`, `max_chars`, `mode`, `site_id` | A real, doc-order H1-H6 outline of ONE template, each heading with its source element, plus alt-status of every image. Structure inspection, not findings use pages_audit for site-wide SEO findings. One row per CARRIER: a container whose bound item repeats appears once, while template_summary.heading_levels counts it once per rendered row. |
5298
+ | `yootheme_builder_health` | read+idempotent | `site_id` | Check plugin installed/reachable. Returns plugin version, YT Pro version, REST endpoints, site_url, home_url on both platforms; wp_version + yooessentials_version are WordPress-only. available_endpoints is derived from the live route registration on both platforms; every path is request-ready as printed. served_areas lists the tool areas this server serves (was `capabilities` kept one release as an alias). Multi-site: pass `site_id`, else this checks the active/default site. `write_rate_limit` = the write cap this site enforces, per BEARER KEY: everything sharing the key shares it. See diagnose for Bearer-validity + connectivity. |
5299
+ | `yootheme_builder_image_import` | write+idempotent | `full_url`, `site_id`, `url` | Sideload a photo into the site's Media Library. Only Unsplash, Pexels and the YOOtheme library CDN are accepted; other hosts are refused. Returns `{imported:true, local_url}`; shape is platform-specific: WordPress leads with a slash (/wp-content/uploads/...), Joomla does NOT (images/yootheme/...). Bind it VERBATIM via element_update_settings({props:{image: local_url}, merge:true}). Give the address as `url` OR as `full_url`, the field name an image_search row carries; one of the two is required. Identical re-imports reuse the existing file in the current upload folder. Failures return `{imported:false, reason, failure}`; `unavailable` = no url works now. |
5300
+ | `yootheme_builder_image_search` | read+idempotent | `color`, `include_meta`, `offset`, `orientation`, `per_page`, `query`, `site_id`, `source` | Search on-topic photos (Unsplash/Pexels), YOOtheme Pro Images, or (source:"local") this site's own media library. Rows carry `full_url` (feeds image_import) plus dimensions/author. `total:0` is the ONLY shape meaning the provider matched nothing; a search that never happened answers an `error_code` instead. No apikey gives `image_search_api_key_not_configured`; a provider that REFUSES gives `error_code: image_search_unavailable`, which no broader query can fix. A key can be CONFIGURED and still be REFUSED (401): report that the upstream refused it, never why (source:"local" is exempt see its field description). Works on WordPress and Joomla. |
5301
+ | `yootheme_builder_inspect_multi_items_binding` | read+idempotent | `element_path`, `layout_id`, `site_id` | Reports Multi-Items binding state: container/item pair (grid↔grid_item, map↔map_item, …), binding level (none\|container\|item), recommended_fix. For map/map_item, a bound item without a `location` field is flagged as a blocking map_location_warning (0 markers). If rows render as ONE comma-joined string, see clean_implode_directives (gateway-only). |
5302
+ | `yootheme_builder_inspect_sublayout` | read+idempotent | `element_path`, `layout_id`, `max_chars`, `site_id` | Inspects a panel/grid_item `< SUBLAYOUT`: lists the `fragment` subtree bindable leaves and the render-time inner grid. Flags `ungated_grid_prop`: grid props set but the gate unsatisfied silent no-op. For container↔item binding see inspect_multi_items_binding. |
5303
+ | `yootheme_builder_layout_query` | read+idempotent | `content_contains`, `cursor`, `element_type`, `fields`, `has_binding`, `has_implode_directives`, `include_meta`, `layout_id`, `limit`, `max_chars`, `max_depth`, `max_paths`, `name_matches`, `site_id`, `source_name`, `under_section` | TWO scopes. OMIT layout_id → SITE-WIDE: binding counts per `source_name` (COUNTED, never resolved — a source that no longer exists counts like a live one; pages_audit flags those as `dangling-source`, with paths), `max_paths` for individual paths, `has_implode_directives:true` to locate the comma-joining leftovers. WITH layout_id ONE layout, AND-combined `element_type`, `name_matches`, `content_contains` (case-insensitive substring of `content`/`title`/`meta` props), `has_binding`, `under_section`; paginate `limit`/`cursor`. Mixing scope arguments is REFUSED, never dropped. To find WHERE a module/widget is used call local_content_read (kind:"modules"). On a module/module_position row `label` is the builder-assigned element name, NOT the module title: read element_get(.resolved). |
5304
+ | `yootheme_builder_library_browse` | read+idempotent | `cursor`, `element_type`, `has_effects`, `include_meta`, `kind`, `limit`, `purpose`, `query`, `role`, `site_id`, `source`, `topic`, `type`, `website` | Browse the YOOtheme Pro Library catalog: pre-designed page LAYOUTS, element PRESETS, or purpose-classified SECTIONS. Filter: `kind`, `topic`, `type`/`role`, `element_type`, `purpose`, `query`. A section id is the composite "<layoutId>#<sectionIndex>" feeds library_get/insert. `source`: "catalog" (default) or "ai" YOUR saved entries from library_save_as, which library_delete removes again — both gateway-only, call via yootheme_builder_advanced({tool, arguments}). `total:0` = the catalog matched nothing; a read with no data answers `library_upstream_unavailable` or `library_api_key_not_configured`, never an empty page. |
5305
+ | `yootheme_builder_library_delete` | write+destructive+non-idempotent | `confirm`, `id`, `site_id`, `source` | Delete ONE saved entry from your "WootsUp AI Layouts" store (library_save_as writes into it) pass the `id` from a library_browse(source:"ai") row. `source` defaults to "ai", the ONLY deletable store; the curated library is read-only, source:"catalog" returns 403. IRREVERSIBLE requires confirm:true. |
5306
+ | `yootheme_builder_library_get` | read+idempotent | `id`, `include_thumbnail`, `kind`, `max_chars`, `site_id`, `source` | Fetch ONE Library layout or element preset by `id` call after library_browse (items[].id feeds this). `include_thumbnail:true` adds an image (costs tokens). This DETAIL endpoint checks the key, browse does not: no key gives 503 library_api_key_not_configured, a refused key 503 library_upstream_unavailable. Report the refusal, not a cause. `source`: "catalog" (default) or "ai" for your own library_save_as (gateway-only) entries. |
5307
+ | `yootheme_builder_library_insert` | write+destructive+non-idempotent | `confirm`, `etag`, `id`, `images`, `index`, `insert_mode`, `kind`, `section_index`, `site_id`, `source`, `target` | Insert a Library item onto a pagecall after library_get/browse. `target`: new_page, {template_id,element_path?} (splices onto the layout root, or a preset onto a Column), or sublayout. A preset (kind:"element") MUST target a Column (422 otherwise) scaffold one with scaffold_section. `images`: "placeholder" (default) or "curated" (downloads to Media Library). Placement: `insert_mode` for layouts, `index` for presets. `etag` locks (412 if stale). `source`: "catalog" (default) or "ai" to insert your own saved entry. |
5308
+ | `yootheme_builder_library_save_as` | write+non-idempotent | `destination`, `name`, `site_id`, `source` | Save a page or subtree into a reusable library the REVERSE of library_insert. `source`: {template_id, element_path?} (omit element_path to save the WHOLE page). `destination`: "ai" (default your isolated "WootsUp AI Layouts", reusable via library_browse source:"ai") or "native" (YOOtheme's built-in My Layouts). |
5309
+ | `yootheme_builder_local_content_read` | read+idempotent+openWorld | `fields`, `include_children`, `include_meta`, `include_usage`, `kind`, `limit`, `max_chars`, `offset`, `parent_id`, `search`, `site_id`, `status`, `taxonomy`, `type`, `verify_on_urls` | Read native records for one `kind`: entities, terms (ids that archive-scope a Grid), users, fields, menus, media, or modules (Builder widgets). Paginated {items,total,next_offset}, clamped to what the key may see. A deleted page/article is not in the default list; `status` (kind:"entities" only) gives the exclusive trashed view; the response carries `trashed_total` unasked when one exists. Archive-scoping: read {kind:"terms",taxonomy:"category"}, pick a term id, bind a source with query_arguments:{id:<term_id>}. Call local_content_types first. |
5310
+ | `yootheme_builder_local_content_types` | read+idempotent | `fields`, `kind`, `max_chars`, `site_id` | Discover the site's NATIVE content landscape, native to WP/Joomla (no API Mapper) {content_types, taxonomies, field_groups, capabilities, positions}; taxonomies include ACF/com_fields groups. Content-type and taxonomy rows carry `bindable` + `source_name` (null for native types bind via element_bind_source by content-type name); field-group rows carry neither. `positions` (B23) lists every valid widget-area / theme-position value for modules_create/modules_update's `position` argument read it here BEFORE a write, not from a 400 refusal after one; null when not determinable, never `[]` as a claim that none exist. Call first, then local_content_read for records. |
5311
+ | `yootheme_builder_modules_create` | write+non-idempotent | `base`, `cache`, `cache_time`, `cachemode`, `class_sfx`, `content`, `dry_run`, `endLevel`, `layout`, `menutype`, `moduleclass_sfx`, `placement`, `position`, `showAllChildren`, `showtitle`, `site_id`, `startLevel`, `tag_id`, `text`, `title`, `type`, `window_open` | Create a widget (WordPress: custom_html/text/block) or module (Joomla: mod_custom/mod_menu), exactly five known types. REQUIRES placement (position, module_element or position_element): a call without it creates nothing and returns ways (every way this platform offers) plus notes. `position` is validated server-side against what this site declares and must agree with placement. content/text stored byte-for-byte, never sanitized. dry_run defaults TRUE, creates nothing; a real create returns the full row PLUS next_step, the exact follow-up call for the placement you chose. Gateway-only, not in tools/list call via yootheme_builder_advanced. |
5312
+ | `yootheme_builder_modules_delete` | write+destructive+non-idempotent | `acknowledge_element_refs`, `confirm`, `id`, `intent`, `site_id` | Delete a widget (WordPress) or module (Joomla) the clean-up verb create/update lacked. Needs `intent`: call once without it and the server lists which are possible for this module's CURRENT state, each with its own effect. confirm defaults FALSE: returns the row plus usage.element_refs and changes NOTHING. A DIRECT layout reference blocks the irreversible step (409) until acknowledge_element_refs:true. |
5313
+ | `yootheme_builder_modules_update` | write+idempotent | `access`, `active`, `assignment`, `content`, `dry_run`, `id`, `ordering`, `position`, `showtitle`, `site_id`, `theme_overlay`, `title` | Update a widget/module's writable subset: title, content (byte-identical), position, active, theme_overlay; plus showtitle, ordering, access, assignment on Joomla. dry_run defaults TRUE, returns {before, after, changed, notes}, writes nothing. Refused: title on a WordPress block widget (it has none), a position this site does not declare (either platform, same error_code as modules_create), and active:true while a Joomla publish window leaves it scheduled/expired there active means published AND that window. Joomla: active:true also lifts a module out of the trash, PUBLISHED not as it was. position:"" clears the position on Joomla, refused on WordPress (deactivate with active:false instead). Gateway-only, not in tools/list — call via yootheme_builder_advanced. |
5314
+ | `yootheme_builder_navigation` | read+idempotent | `menu`, `site_id` | The site's navigation menus as a parent→children TREE, not a flat list "which page is under which menu item". Each item carries a `page_role` facet and, when derivable, a `target_page` ready for page_get_layout. |
5315
+ | `yootheme_builder_page_generate_dynamic` | write+non-idempotent | `conditions`, `dry_run`, `element_path`, `etag`, `layout_id`, `leaf_map`, `list_source_name`, `output`, `page`, `remove_leaves`, `section_name`, `site_id`, `source_map` | Copies a designed section byte-identically into a NEW, unpublished, unlinked page and swaps only what you map — it never edits the reference. Requires output:"new_page" (today's only value; "in_place" is not yet available). Give a REFERENCE (section_name or element_path) + list_source_name + a leaf_map. A section with several source blocks dynamizes in ONE call via source_map. Preview with dry_run:true (a would_change:false answer carries `reason`). Live at once — stage hidden with element_set_status(status:false). The response carries `cms_visibility.findable_by_visitor`: can a visitor FIND this one page via a navigation path? It reads the menu route AND the access level, which the per-row `visibility` of pages_list does not. Cannot make it the site front page (a CMS menu setting). |
5316
+ | `yootheme_builder_page_get_layout` | read+idempotent | `fields`, `flat`, `force`, `include_modules`, `layout_id`, `max_chars`, `mode`, `site_id`, `url` | Get the layout tree for one layout address by `layout_id` or `url`. `mode:"bindings_only"` for dynamize/bind. Over `max_chars` whole entries DROP (`truncated`+`omitted_count`; outline cuts its text) and NOTHING pages the rest no cursor in any mode. Narrow instead: `mode:"outline"` to map, then element_list(root_path). For a site-wide SEO/structure audit do NOT loop this tool page-by-page call yootheme_builder_pages_audit (gateway-only). For ONE narrow question (e.g. "where is the binding" or "which elements have type X") this whole-template read is heavier than it needs to be prefer yootheme_builder_layout_query (gateway-only; has_binding/element_type/name_matches filters) or element_list with `root_path` to scope the walk. |
5317
+ | `yootheme_builder_page_publish` | write+idempotent | `cms_status`, `etag`, `layout_id`, `menu_item`, `quick_check`, `site_id` | Publish a template's LAYOUT persist state, flush the plugin's own caches (a page-cache/CDN plugin is NOT purged: purge it yourself, then verify with fetch_public_url), snapshot the ETag; "publish" ALSO means the CMS post status, and this tool does not change that, so a draft page stays invisible. Step (2) of the three page_save describes; pages_set_status is step (3). ETag required 412 on conflict. Answers `cms_visibility.findable_by_visitor`: can a visitor FIND this one page via a navigation path? It reads the menu route AND the access level, which the per-row `visibility` of pages_list does not. Optional `cms_status` folds step (3) into this call for the common build-it-and-make-it-live case; without it nothing about the CMS status changes. |
5318
+ | `yootheme_builder_page_save` | write+idempotent | `dry_run`, `etag`, `layout`, `layout_id`, `quick_check`, `site_id` | Re-run save-transforms + flush the plugin's own caches. Optional whole-tree replace via `layout` the INNER {type:"layout",children:[...]} tree, NOT page_get_layout's whole response (rejected 422). THREE separate steps, and this is the first: (1) page_save replaces or re-normalises the stored tree every write, element or whole-tree, runs the same save-transforms; (2) page_publish snapshots the ETag and flushes them; (3) pages_set_status decides whether a visitor may see the page at all. This call does NOT do (2) or (3). Response `previous_layout` is the tree right before THIS call, not before your edits (element writes already landed) identical to what you just wrote is normal when the transforms change nothing. `dry_run:true` preflights, no ETag. ETag on the save (428/412). |
5319
+ | `yootheme_builder_pages_audit` | read+idempotent | `checks`, `include_meta`, `include_modules`, `max_chars`, `mode`, `site_id`, `stale_after_months`, `target`, `thin_content_threshold`, `types` | One-call site-wide SEO / structure / content / links / design audit — severity-sorted findings with fix_hints (a props fragment to merge). Covers heading structure and accessibility (h1/heading-skip), image alt text, thin content, duplicate layouts, title collisions, broken in-page anchor links (same-page `#id` jumps only no external link is ever fetched or resolved), and design consistency (markup outside the UIkit design system, header/navigation contrast). Only active (non-disabled) nodes produce findings. `target` scopes to ONE page for the full set. SEO-plugin meta (title/description/OG) NOT covered. |
5320
+ | `yootheme_builder_pages_create` | write+non-idempotent | `dry_run`, `layout`, `menu_item`, `published`, `site_id`, `slug`, `status`, `title` | Create a page (WordPress) or article (Joomla 5/6) with a per-page layout. Required: title. Optional: status (default draft), layout, menu_item, dry_run (preflight, no persist). Joomla: a published article has NO SEF route until a menu item points at it — pass menu_item for a navigable url. Cannot make it the site front page: which page answers / is a CMS menu setting this server never writes. pages_list marks the current one with is_public_homepage. |
5321
+ | `yootheme_builder_pages_delete` | write+destructive+non-idempotent | `confirm`, `intent`, `layout_id`, `site_id` | Delete a page (WordPress) or article (Joomla 5/6). OMIT confirm to preview: returns 200 with label, elements_count, what happens to media, and the intents this page's state allows, and deletes nothing. confirm:true + an intent carries it out. Already-trashed returns 200 {deleted:false, already_trashed:true}, not an error. |
5322
+ | `yootheme_builder_pages_list` | read+idempotent | `cursor`, `fields`, `include_meta`, `label_contains`, `limit`, `max_chars`, `site_id`, `sort`, `visibility` | List pages, templates and layouts. Each row returns `kind` (content \| theme_template) plus serves_context_kind, serves_context_ids and page_role — the template ROLE (WHICH objects it renders for), NOT a data binding; for who-binds-what call layout_query with no layout_id. The envelope returns `totals`. Call first for layout_id (page_get_layout/element_list). `type` = PAGE type, not element type (those filter on `element_type`). Rows with a CMS status carry `visibility`: public \| hidden \| undetermined, never empty. Reads STORED FIELDS only — status, plus WordPress password protection (`hidden`, with the reason). It does NOT read the menu route, the access level, or whether the permalink resolves, so Joomla rows stay `undetermined`; for THAT verdict read `cms_visibility.findable_by_visitor` from pages_set_status / page_publish — a different, per-page question. Page contents (SEO/structure/links/modules): yootheme_builder_pages_audit. `is_public_homepage` marks the one row served at / (the site front page); no tool here moves it. |
5323
+ | `yootheme_builder_pages_resolve` | read+idempotent | `site_id`, `target`, `url` | Resolve a page/article/archive URL to the layout_id that renders it. SEF/pretty permalinks resolve on both WordPress and Joomla. A category/term resolves to the SPECIFICALLY-ASSIGNED template (honoring include-child-categories), not a generic one. Returns {layout_id, post_id, type, frontend_url, context, resolved_via}. |
5324
+ | `yootheme_builder_pages_restore` | write+non-idempotent | `layout_id`, `site_id` | Restore a TRASHED page (WordPress) or article (Joomla) — the one correct way back (pages_set_status refuses a trashed page with 409 naming this tool). WordPress: reinstates the EXACT pre-trash status (core defaults restores to draft since 5.6) and attempts to reclaim the pre-trash slug — a hand-rolled write leaves the "__trashed" suffix instead; a slug another page has since taken comes back different — check the response's frontend_url. Joomla stores no pre-trash status, so this can only set state to unpublished (visible to editors, not visitors); call yootheme_builder_pages_set_status with status:"published" afterwards to make it visible. Not currently trashed → refused (409). Find a trashed id with yootheme_builder_local_content_read({kind:"entities", status:"trash"}). |
5325
+ | `yootheme_builder_pages_set_status` | write+idempotent | `layout_id`, `site_id`, `status` | Change the CMS status of an EXISTING page (WordPress) or article (Joomla) — keeps its id and URL. Step (3) of the three page_save describes. Use YOUR platform's own words: WordPress publish, draft, pending, private, future; Joomla published, unpublished, archived — the other platform's word is refused (422). Status is RE-READ after the write. To trash or delete use yootheme_builder_pages_delete; a page already IN the trash is refused (409) — use yootheme_builder_pages_restore first. The response carries `cms_visibility.findable_by_visitor`: can a visitor FIND this one page via a navigation path? It reads the menu route AND the access level, which the per-row `visibility` of pages_list does not. |
5326
+ | `yootheme_builder_render_preview` | read+idempotent | `expect`, `layout_id`, `site_id`, `url` | Render-check a SAVED layout. Address by `layout_id` or `url`. rendered_item_wrapper_count counts the repeat-item wrappers rendered, BOUND OR NOT, so a 0 is not evidence a binding failed (per-source rows: `source_item_counts`). Distinct from pages_list `elements_count` (all tree nodes). A source the registry cannot resolve is named in `unresolved_sources`, not just a smaller count. Renders ONLY the page's own layout tree, never the theme wrapper: a MISS with `expect` proves nothing beyond the tree (adds {matched, snippet, match_conclusive, match_note, mismatch_hints, stored_but_not_rendered — the last names elements that DO store the text but rendered no rows}). A Joomla module/module_position element sharpens this — EXCLUDED (fallback_used:true) or silently empty (dropped_count:0) — but match_conclusive:false marks EVERY inconclusive miss. Reports CMS status in `cms_status`/`cms_status_note` (absent for a theme template); pages_set_status remains the tool that changes it. |
5327
+ | `yootheme_builder_repair_source_references` | write+idempotent | `allow_unregistered`, `dry_run`, `etag`, `field_map`, `from`, `layout_id`, `site_id`, `source_map`, `to` | Repairs binding REFERENCES to a source/type/field name in ONE template, in one ETag-locked write, after that name changed OUTSIDE the builder (a taxonomy/CPT rename, a re-published flow). Does NOT rename the source itself — sources_list stays unchanged. Pass `from`/`to` for one token, or `source_map`/`field_map` for several. Preview with dry_run:true. |
5328
+ | `yootheme_builder_scaffold_section` | write+non-idempotent | `column_widths`, `columns`, `etag`, `layout_id`, `site_id` | Create a VALID section → row → column hull in ONE call; returns the column path(s) for content. Only `section` may sit at the layout root; leaf/content elements are rejected anywhere except inside a `column`. Pass `columns` (1-12) or `column_widths` for an asymmetric row. Requires ETag. |
5329
+ | `yootheme_builder_sites_list` | read+idempotent | `include_meta`, `site_id` | List all sites configured in this multi-site MCP installation — site_id, URL, platform, DEFAULT + ACTIVE flag per row. Call first to discover site_ids. A call omitting site_id goes to `active_site_id` (set by use_site, process-wide) else to `default_site_id`. Pass `site_id` to FILTER to one site; omit it for all. IDs from any tool (layout_id, element ids, …) are PER-SITE, never global: the same id can exist on a different site naming something else entirely. Always keep an id paired with the site_id it came from. |
5330
+ | `yootheme_builder_sites_overview` | read+idempotent | `include_meta`, `include_pages`, `max_chars`, `site_id` | Every configured site in ONE call: reachability, Bearer validity and page counts per site, probed in parallel. Replaces sites_list plus one sites_test and one pages_list per site. A site that is down becomes a row with an `error`, never a failed call. Pass `site_id` to narrow to one site; `include_pages:false` skips the page read (one HTTP request per site less). The content/theme_template split is absent on hosts that do not report it — never reported as zero. |
2755
5331
  | `yootheme_builder_sites_test` | read+idempotent | `site_id` | Verify connectivity to ONE site: probes /health (no auth) + /etag (auth) in parallel; returns plugin_reachable + bearer_valid. `site_id` is REQUIRED. Use sites_list to find IDs. |
2756
- | `yootheme_builder_source_get_schema` | read+idempotent | `max_chars`, `name`, `site_id` | Introspect ONE data source: its query methods + arguments + RELATIONS (e.g. Term→Posts: categoryFaqs.taxonomyCategoryFaq → field "faq") + a provides_archive_context flag (id:0 = current archive term, auto-scoped). SOME arguments carry `enum_values` (the allowed values); when an arg has none it is a free string — pick from enum_values when present, never invent values. CALL BEFORE binding on an archive/category template. Keywords: source schema, relations, archive context, enum values. Get `name` from yootheme_builder_sources_list. Operates on the default site unless site_id is provided. |
2757
- | `yootheme_builder_sources_list` | read+idempotent | `cursor`, `fields`, `group`, `kind`, `limit`, `max_chars`, `name_contains`, `site_id` | List all data sources, feeds, and dynamic content sources available in the YOOtheme Pro builder. Returns name + label + origin (apimapper / wordpress / joomla / essentials) per source. CALL THIS BEFORE binding any element to a data source. Then bind directly with yootheme_builder_element_bind_source. Keywords: list sources, list feeds, list bindings, dynamic content, what sources exist. Paginates (`limit`/`cursor`/`total`); scope with `group`/`kind`/`name_contains`; slim with `fields[]`; bound output with `max_chars`. Operates on the default site unless site_id is provided. |
2758
- | `yootheme_builder_template_set_type` | idempotent | `dry_run`, `etag`, `site_id`, `template_id`, `type` | Reassign which archive/single a YOOtheme THEME template applies to — set its `type` condition (the value pages_list shows) in ONE ETag-locked write. Use after a taxonomy/CPT rename orphans a template (e.g. re-type "taxonomy-category_faq" → "taxonomy-category_help"). Returns {type, previous_type, new_etag}. Site-wide + immediate — preview with dry_run:true, reversible via previous_type. THEME templates only: page:/article: is refused (422). ETag req. Operates on the default site unless site_id is provided. |
2759
- | `yootheme_builder_template_summary` | read+idempotent | `site_id`, `template_id` | Token-efficient template overview: element counts by type, binding count, max nesting depth, and named landmark sections — computed server-side in one call. Use this to grasp a large template before pulling element_list or page_get_layout. template_id only resolve a URL/post first via pages_resolve. Example: yootheme_builder_template_summary({ template_id: "default" }). Operates on the default site unless site_id is provided. |
2760
- | `yootheme_builder_use_site` | idempotent | `probe`, `site_id` | Switch the ACTIVE site for this multi-site session. After this call, every tool that omits `site_id` targets the site you picked here. NOTE: this default is PROCESS-WIDE — concurrent clients sharing this MCP server inherit it; pass `site_id` per call for isolation between parallel agents. Runs a /health + /etag probe by default; pass `probe: false` to switch offline. A degraded site is a soft warning (the switch still happens). `site_id` is REQUIRED. Keywords: switch/change/set active/target site, select installation, use profile. Use yootheme_builder_sites_list first to find IDs. |
5332
+ | `yootheme_builder_source_get_schema` | read+idempotent | `max_chars`, `name`, `site_id`, `source_name` | Introspect ONE data source: query methods + arguments + RELATIONS (e.g. Term→Posts: categoryFaqs.taxonomyCategoryFaq → field "faq"). `is_archive_context_provider` says whether the source CAN follow the current archive term; it does NOT say whether the source returns rows here only a render answers that (element_bind_source with probe:true, or render_preview). Some arguments carry `enum_values` — pick from those when present, never invent values. Call before binding on an archive/category template. Get `name` from sources_list. |
5333
+ | `yootheme_builder_sources_list` | read+idempotent | `cursor`, `fields`, `group`, `include_meta`, `kind`, `limit`, `max_chars`, `name_contains`, `site_id` | List data sources/feeds available in the builder name + label + origin + `group` per source. Call before binding an element with element_bind_source. `group` is what a source answers FROM: a "Page"-group LIST source serves the CURRENT page/archive context, so a standalone page gets zero rows from it. Each row says so in `context_note` when it applies. A long list narrows with `group` (the ORIGIN bucket: apimapper \| wordpress \| joomla \| essentials — not the per-row `group` column above), `name_contains`, or `kind`. Reach for one before paging. WHERE a source is used, site-wide: yootheme_builder_layout_query (gateway-only), OMIT layout_id. |
5334
+ | `yootheme_builder_template_set_type` | write+idempotent | `dry_run`, `etag`, `layout_id`, `site_id`, `type` | Reassign which archive/single a YOOtheme THEME template applies to — set its `type` condition in ONE ETag-locked write. Use after a taxonomy/CPT rename orphans a template. Site-wide + immediate — preview with dry_run:true, reversible via previous_type. THEME templates only: page:/article: refused (422). |
5335
+ | `yootheme_builder_template_summary` | read+idempotent | `cursor`, `include_nodes`, `layout_id`, `layout_ids`, `limit`, `site_id` | Token-efficient template overview: element counts by type, bindings, max depth, named sections — computed server-side in one call. Use before element_list/page_get_layout on a large template. `include_nodes:true` adds a `content_nodes` per-node breakdown (status/effective_visible/headings/alts), pageable via `limit`/`cursor`. `layout_ids:[...]` batches several: response carries `summaries`, `not_found` and `forbidden`. Example: yootheme_builder_template_summary({ layout_id: "default" }). |
5336
+ | `yootheme_builder_use_site` | write+idempotent | `probe`, `site_id` | Switch the ACTIVE site for this session subsequent calls that omit `site_id` target it. NOTE: this default is PROCESS-WIDE — concurrent clients sharing this MCP server inherit it; pass `site_id` per call for isolation. Runs a /health + /etag probe by default; `probe:false` switches offline. `site_id` is REQUIRED. |
2761
5337
 
2762
5338
  <!-- TOOL-CATALOG:END -->