@mnstry/atelier 0.2.0-alpha.6 → 0.2.0-alpha.8

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 (374) hide show
  1. package/CHANGELOG.md +176 -0
  2. package/README.md +59 -15
  3. package/SECURITY.md +6 -1
  4. package/contracts/atelier-adoption-policy.v1.schema.json +49 -0
  5. package/contracts/atelier-coauthor.v1.schema.json +163 -0
  6. package/contracts/atelier-decision-request.v1.schema.json +476 -0
  7. package/contracts/atelier-decision-result.v1.schema.json +500 -0
  8. package/contracts/atelier-guide.v1.schema.json +227 -0
  9. package/contracts/atelier-intake.v1.schema.json +151 -0
  10. package/contracts/atelier-migration.v2.schema.json +61 -0
  11. package/contracts/atelier-obsidian-acceptance-receipt.v1.schema.json +211 -0
  12. package/contracts/atelier-obsidian-apply-policy.v1.schema.json +298 -0
  13. package/contracts/atelier-obsidian-corpus-profile.v1.schema.json +98 -0
  14. package/contracts/atelier-obsidian-edit-operation.v1.schema.json +138 -0
  15. package/contracts/atelier-obsidian-ext-settings.v1.schema.json +287 -0
  16. package/contracts/atelier-obsidian-generation-manifest.v1.schema.json +353 -0
  17. package/contracts/atelier-obsidian-proposal-receipt.v1.schema.json +110 -0
  18. package/contracts/atelier-obsidian-publication-journal.v1.schema.json +167 -0
  19. package/contracts/atelier-obsidian-scope.v1.schema.json +264 -0
  20. package/contracts/atelier-obsidian-service-state.v1.schema.json +149 -0
  21. package/contracts/atelier-obsidian-source-snapshot.v1.schema.json +179 -0
  22. package/contracts/atelier-pane-presentation.v1.schema.json +76 -0
  23. package/contracts/atelier-presentation.v1.schema.json +1293 -0
  24. package/contracts/atelier-review.v1.schema.json +1601 -0
  25. package/contracts/atelier-skill-steward.v1.schema.json +692 -0
  26. package/contracts/atelier-upgrade-plan.v2.schema.json +398 -0
  27. package/contracts/atelier-upgrade-receipt.v1.schema.json +99 -0
  28. package/contracts/atelier-vault-publication.v1.schema.json +58 -0
  29. package/docs/assurance-controls.md +9 -1
  30. package/docs/atelier-sync.md +5 -4
  31. package/docs/blocks/claims.md +15 -10
  32. package/docs/blocks/will-not-do.md +4 -1
  33. package/docs/coauthor-session.md +57 -0
  34. package/docs/connected-composition.md +75 -0
  35. package/docs/decisions.md +167 -0
  36. package/docs/exact-upgrades.md +159 -0
  37. package/docs/extension-pack-lifecycle.md +33 -0
  38. package/docs/guided-upgrades.md +99 -0
  39. package/docs/install.md +65 -9
  40. package/docs/intake-and-guides.md +65 -0
  41. package/docs/integration-and-review-plan.md +409 -0
  42. package/docs/integration-contract-decisions.md +22 -0
  43. package/docs/local-review.md +84 -0
  44. package/docs/local-services.md +227 -0
  45. package/docs/local-state.md +107 -0
  46. package/docs/obsidian-contract.md +687 -0
  47. package/docs/obsidian.md +387 -0
  48. package/docs/portable-presentation.md +328 -0
  49. package/docs/presentation-consumer-boundaries.md +75 -0
  50. package/docs/presentation-review-disposition.md +47 -0
  51. package/docs/project-command-inventory.md +38 -0
  52. package/docs/project-options.md +53 -0
  53. package/docs/release-engineering.md +88 -0
  54. package/docs/review-portability.md +55 -0
  55. package/docs/skill-steward.md +156 -0
  56. package/docs/upgrade.md +55 -2
  57. package/docs/vault-service.md +292 -0
  58. package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
  59. package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
  60. package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
  61. package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
  62. package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
  63. package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
  64. package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
  65. package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
  66. package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
  67. package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
  68. package/fixtures/atelier-intake/valid/source.v1.json +7 -0
  69. package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
  70. package/fixtures/atelier-review/valid/bound.v1.json +229 -0
  71. package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
  72. package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
  73. package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
  74. package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
  75. package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
  76. package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
  77. package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
  78. package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
  79. package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
  80. package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
  81. package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
  82. package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
  83. package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
  84. package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
  85. package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
  86. package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
  87. package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
  88. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
  89. package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
  90. package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
  91. package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
  92. package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
  93. package/fixtures/decisions/request/valid/triage.v1.json +60 -0
  94. package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
  95. package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
  96. package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
  97. package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
  98. package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
  99. package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
  100. package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
  101. package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
  102. package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
  103. package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
  104. package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
  105. package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
  106. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
  107. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
  108. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
  109. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
  110. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
  111. package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
  112. package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
  113. package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
  114. package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
  115. package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
  116. package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
  117. package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
  118. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
  119. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
  120. package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
  121. package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
  122. package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
  123. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
  124. package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
  125. package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
  126. package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
  127. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
  128. package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
  129. package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
  130. package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
  131. package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
  132. package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
  133. package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
  134. package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
  135. package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
  136. package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
  137. package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
  138. package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
  139. package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
  140. package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
  141. package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
  142. package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
  143. package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
  144. package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
  145. package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
  146. package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
  147. package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
  148. package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
  149. package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
  150. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
  151. package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
  152. package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
  153. package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
  154. package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
  155. package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
  156. package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
  157. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
  158. package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
  159. package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
  160. package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
  161. package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
  162. package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
  163. package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
  164. package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
  165. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
  166. package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
  167. package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
  168. package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
  169. package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
  170. package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
  171. package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
  172. package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
  173. package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
  174. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
  175. package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
  176. package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
  177. package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
  178. package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
  179. package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
  180. package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
  181. package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
  182. package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
  183. package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
  184. package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
  185. package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
  186. package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
  187. package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
  188. package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
  189. package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
  190. package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
  191. package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
  192. package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
  193. package/fixtures/obsidian/edits/workspace.json +65 -0
  194. package/fixtures/obsidian/materialization/expected-full.json +99 -0
  195. package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
  196. package/fixtures/obsidian/materialization/workspace.json +54 -0
  197. package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
  198. package/fixtures/ui/presentation/pane.v1.json +7 -0
  199. package/fixtures/ui/presentation/reference.v1.json +293 -0
  200. package/package.json +57 -6
  201. package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
  202. package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
  203. package/skills/claude/atelier-open/SKILL.md +5 -0
  204. package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
  205. package/skills/claude/mnstry-readiness/SKILL.md +5 -0
  206. package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
  207. package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
  208. package/skills/codex/atelier-open/SKILL.md +5 -0
  209. package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
  210. package/skills/codex/mnstry-readiness/SKILL.md +5 -0
  211. package/src/access/preview-enforcer.mjs +84 -0
  212. package/src/boundary/policy.mjs +57 -25
  213. package/src/cli/project-options.mjs +58 -0
  214. package/src/cli/run.mjs +43 -3
  215. package/src/coauthor/session.mjs +161 -0
  216. package/src/coauthor/store.mjs +173 -0
  217. package/src/collaboration/event-ledger.mjs +16 -33
  218. package/src/collaboration/inspection-bundle.mjs +223 -0
  219. package/src/collaboration/review-contracts.mjs +25 -0
  220. package/src/collaboration/review-store.mjs +305 -0
  221. package/src/commands/coauthor.mjs +26 -0
  222. package/src/commands/config.mjs +6 -1
  223. package/src/commands/extension-pack.mjs +15 -8
  224. package/src/commands/init.mjs +44 -5
  225. package/src/commands/obsidian.mjs +368 -0
  226. package/src/commands/review.mjs +147 -0
  227. package/src/commands/setup.mjs +51 -14
  228. package/src/commands/skills.mjs +199 -0
  229. package/src/commands/sync.mjs +8 -1
  230. package/src/composition/registry.mjs +27 -0
  231. package/src/composition/wire.mjs +122 -0
  232. package/src/contracts/corpus.mjs +29 -0
  233. package/src/decisions/contracts.d.mts +96 -0
  234. package/src/decisions/contracts.mjs +321 -0
  235. package/src/disclosure/content-scan.mjs +13 -0
  236. package/src/export/atelier-export-contract.mjs +16 -1
  237. package/src/extension-packs/lifecycle.mjs +154 -0
  238. package/src/graph/graph.mjs +64 -2
  239. package/src/graph/knowledge-graph.mjs +548 -24
  240. package/src/guides/contracts.mjs +54 -0
  241. package/src/intake/store.mjs +119 -0
  242. package/src/preview/channel.mjs +89 -0
  243. package/src/project/config.mjs +26 -27
  244. package/src/project/durable-state.mjs +161 -0
  245. package/src/project/file-class.mjs +166 -0
  246. package/src/project/private-state.mjs +11 -1
  247. package/src/projection/obsidian/contracts.mjs +571 -0
  248. package/src/projection/obsidian/edits/align.mjs +169 -0
  249. package/src/projection/obsidian/edits/apply.mjs +889 -0
  250. package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
  251. package/src/projection/obsidian/edits/contribution.mjs +108 -0
  252. package/src/projection/obsidian/edits/index.mjs +19 -0
  253. package/src/projection/obsidian/edits/object-identity.mjs +61 -0
  254. package/src/projection/obsidian/edits/object-store.mjs +517 -0
  255. package/src/projection/obsidian/edits/observe.mjs +123 -0
  256. package/src/projection/obsidian/edits/policy.mjs +123 -0
  257. package/src/projection/obsidian/edits/regions.mjs +648 -0
  258. package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
  259. package/src/projection/obsidian/materialize/index.mjs +7 -0
  260. package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
  261. package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
  262. package/src/projection/obsidian/materialize/settings.mjs +57 -0
  263. package/src/projection/obsidian/materialize/stage.mjs +27 -0
  264. package/src/projection/obsidian/proposals/adapter.mjs +572 -0
  265. package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
  266. package/src/projection/obsidian/proposals/contribution.mjs +55 -0
  267. package/src/projection/obsidian/proposals/index.mjs +23 -0
  268. package/src/projection/obsidian/proposals/observation.mjs +216 -0
  269. package/src/projection/obsidian/proposals/queue.mjs +277 -0
  270. package/src/projection/obsidian/proposals/router.mjs +168 -0
  271. package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
  272. package/src/projection/obsidian/publication/exchange.mjs +117 -0
  273. package/src/projection/obsidian/publication/index.mjs +5 -0
  274. package/src/projection/obsidian/publication/publisher.mjs +588 -0
  275. package/src/projection/obsidian/publication/test-seam.mjs +16 -0
  276. package/src/projection/obsidian/publication/transport.mjs +139 -0
  277. package/src/projection/obsidian/recovery/index.mjs +6 -0
  278. package/src/projection/obsidian/recovery/journal.mjs +134 -0
  279. package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
  280. package/src/projection/obsidian/recovery/restart.mjs +316 -0
  281. package/src/projection/obsidian/recovery/store.mjs +234 -0
  282. package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
  283. package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
  284. package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
  285. package/src/projection/obsidian/selection-ui/index.mjs +12 -0
  286. package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
  287. package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
  288. package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
  289. package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
  290. package/src/projection/project.mjs +28 -19
  291. package/src/readiness-protocols/evidence.mjs +282 -0
  292. package/src/readiness-protocols/runtime.mjs +4 -3
  293. package/src/readiness-protocols/source-read.mjs +39 -0
  294. package/src/runtime/obsidian/app-capability.mjs +110 -0
  295. package/src/runtime/obsidian/app-production-seams.mjs +96 -0
  296. package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
  297. package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
  298. package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
  299. package/src/runtime/obsidian/contributions.mjs +27 -0
  300. package/src/runtime/obsidian/documents.mjs +37 -0
  301. package/src/runtime/obsidian/enablement.mjs +53 -0
  302. package/src/runtime/obsidian/engine.mjs +532 -0
  303. package/src/runtime/obsidian/errors.mjs +14 -0
  304. package/src/runtime/obsidian/extension-points.mjs +103 -0
  305. package/src/runtime/obsidian/index.mjs +34 -0
  306. package/src/runtime/obsidian/lifecycle.mjs +258 -0
  307. package/src/runtime/obsidian/machine-settings.mjs +260 -0
  308. package/src/runtime/obsidian/observation.mjs +158 -0
  309. package/src/runtime/obsidian/opening.mjs +243 -0
  310. package/src/runtime/obsidian/pending-edits.mjs +141 -0
  311. package/src/runtime/obsidian/pipeline.mjs +122 -0
  312. package/src/runtime/obsidian/private-lock.mjs +156 -0
  313. package/src/runtime/obsidian/process-identity.mjs +58 -0
  314. package/src/runtime/obsidian/service-client.mjs +66 -0
  315. package/src/runtime/obsidian/service-main.mjs +89 -0
  316. package/src/runtime/obsidian/service-record.mjs +169 -0
  317. package/src/runtime/obsidian/service-server.mjs +121 -0
  318. package/src/runtime/obsidian/service.mjs +216 -0
  319. package/src/runtime/obsidian/startup-adapters.mjs +87 -0
  320. package/src/runtime/obsidian/state-store.mjs +180 -0
  321. package/src/runtime/obsidian/sync-notice.mjs +33 -0
  322. package/src/runtime/obsidian/tick-loop.mjs +84 -0
  323. package/src/runtime/obsidian/watchers.mjs +36 -0
  324. package/src/runtime/supervisor.mjs +16 -1
  325. package/src/server/local-sidecar.mjs +44 -0
  326. package/src/server/server.mjs +1 -0
  327. package/src/skills/steward.mjs +780 -0
  328. package/src/ui/presentation/browser.mjs +278 -0
  329. package/src/ui/presentation/contract.mjs +116 -0
  330. package/src/ui/presentation/index.mjs +7 -0
  331. package/src/ui/presentation/native.mjs +153 -0
  332. package/src/ui/presentation/proof.mjs +57 -0
  333. package/src/ui/presentation/schema-check.mjs +30 -0
  334. package/src/ui/presentation/schema.generated.mjs +1371 -0
  335. package/src/ui/presentation/state.mjs +43 -0
  336. package/src/ui/presentation/styles.mjs +67 -0
  337. package/src/ui/presentation/tokens.mjs +74 -0
  338. package/src/ui/presentation/web.mjs +85 -0
  339. package/src/ui/review-page.mjs +32 -0
  340. package/src/upgrade/explanation.mjs +33 -0
  341. package/src/upgrade/provenance.mjs +338 -0
  342. package/src/upgrade/transaction-files.mjs +103 -0
  343. package/src/upgrade/transaction.mjs +475 -0
  344. package/src/upgrade/upgrade.mjs +63 -28
  345. package/src/vault/hosts.mjs +21 -0
  346. package/src/vault/identity.mjs +19 -0
  347. package/src/vault/index.mjs +7 -0
  348. package/src/vault/interface.mjs +11 -0
  349. package/src/vault/metadata.mjs +31 -0
  350. package/src/vault/privacy.mjs +148 -0
  351. package/src/vault/probe.mjs +89 -0
  352. package/src/vault/service.mjs +131 -0
  353. package/src/vault/source.mjs +45 -0
  354. package/src/vault/storage.mjs +23 -0
  355. package/templates/distribution-workspace/.claude/launch.json +6 -2
  356. package/templates/distribution-workspace/gitignore +1 -0
  357. package/templates/distribution-workspace/project/README.md +2 -2
  358. package/templates/external-project-workspace/README.md +45 -0
  359. package/templates/external-project-workspace/answers.example.json +4 -0
  360. package/templates/external-project-workspace/atelier.project.json +40 -0
  361. package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
  362. package/templates/external-project-workspace/gitignore +8 -0
  363. package/templates/external-project-workspace/pack-lifecycle.json +14 -0
  364. package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
  365. package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
  366. package/templates/external-project-workspace/repo-access.v1.json +9 -0
  367. package/templates/external-project-workspace/source/README.md +11 -0
  368. package/templates/launch.json +6 -2
  369. package/templates/private-domain-workspace/.claude/launch.json +6 -2
  370. package/templates/private-domain-workspace/domain/README.md +2 -2
  371. package/templates/private-domain-workspace/gitignore +1 -0
  372. package/templates/shared-project-workspace/.claude/launch.json +6 -2
  373. package/templates/shared-project-workspace/gitignore +1 -0
  374. package/templates/shared-project-workspace/project/README.md +2 -2
@@ -0,0 +1,278 @@
1
+ import { resizeRequest, keyboardResize, editValueError, deliveryMessage } from './state.mjs'
2
+ import { assertPresentation } from './contract.mjs'
3
+ import { renderPresentation } from './web.mjs'
4
+
5
+ const bindings = new WeakMap()
6
+
7
+ // Scoped widget behavior only. No document/global keyboard listener, transport,
8
+ // storage, command catalog or business transition is installed.
9
+ export function bindPresentation(root, model, { onRequest } = {}) {
10
+ assertPresentation(model)
11
+ if (!root || root.dataset.apRoot !== model.id || model.schema !== 'atelier.presentation/v1' || model.version !== '1.0.0') throw new TypeError('root/model binding mismatch')
12
+ if (bindings.has(root)) throw new TypeError('presentation already bound')
13
+ let snapshot = structuredClone(model)
14
+ let nodes = new Map(snapshot.nodes.map(node => [node.id, node]))
15
+ let panes = new Map(snapshot.panes.map(pane => [pane.id, pane]))
16
+ let output = root.querySelector('[data-ap-delivery]')
17
+ let dialog = root.querySelector('[data-ap-confirm]')
18
+ const pending = new Map()
19
+ const drafts = new Map()
20
+ const listeners = []
21
+ let disposed = false, confirmation = null, opener = null, drag = null, composing = false
22
+ let queuedUpdate = null, updateRevision = 0
23
+ const listen = (target, event, handler, capture = false) => {
24
+ if (!target) return
25
+ target.addEventListener(event, handler, capture)
26
+ listeners.push(() => target.removeEventListener(event, handler, capture))
27
+ }
28
+ const say = text => { if (!disposed && output) output.textContent = text }
29
+ const validateDraft = (control, node) => {
30
+ const error = editValueError(control.value)
31
+ control.setCustomValidity(error ?? '')
32
+ const message = root.querySelector('[data-ap-edit-error="' + node.id + '"]')
33
+ if (message) { message.textContent = error ?? ''; message.hidden = !error }
34
+ if (error || node.error) control.setAttribute('aria-invalid', 'true')
35
+ else control.removeAttribute('aria-invalid')
36
+ const descriptions = [node.error ? snapshot.id + ':node:' + node.id + ':error' : '', error ? control.id + ':limit' : ''].filter(Boolean)
37
+ if (descriptions.length) control.setAttribute('aria-describedby', descriptions.join(' '))
38
+ else control.removeAttribute('aria-describedby')
39
+ return error
40
+ }
41
+ const syncBusy = () => {
42
+ for (const control of root.querySelectorAll('button,input,textarea')) {
43
+ const id = control.dataset.apAction ?? control.dataset.apSelect ?? control.dataset.apMove ?? control.dataset.apStep ?? control.dataset.apResize ?? control.dataset.apEdit
44
+ if (!disposed && pending.has(id)) control.setAttribute('aria-busy', 'true')
45
+ else control.removeAttribute('aria-busy')
46
+ }
47
+ }
48
+ const restoreFocus = () => {
49
+ if (opener?.isConnected) opener.focus()
50
+ else root.querySelector('[data-ap-pane]')?.focus()
51
+ opener = null
52
+ }
53
+ const close = () => {
54
+ confirmation = null
55
+ if (dialog?.open) dialog.close()
56
+ restoreFocus()
57
+ }
58
+ const send = async (request, control) => {
59
+ if (disposed || typeof onRequest !== 'function') return
60
+ if (queuedUpdate && request.kind !== 'edit') { say('Workspace update is waiting for text composition. No request sent.'); return }
61
+ if (pending.has(request.id) && request.kind !== 'edit') { say(deliveryMessage('suppressed')); return }
62
+ // Edits reach the host synchronously, including while earlier delivery is
63
+ // pending. A re-render/unmount cannot erase an undelivered coalesced draft.
64
+ pending.set(request.id, (pending.get(request.id) ?? 0) + 1)
65
+ syncBusy()
66
+ // Native disabled would eject keyboard focus. Pending is guarded above.
67
+ say(deliveryMessage('sending'))
68
+ let outcome = 'delivered'
69
+ try {
70
+ await onRequest(Object.freeze({ schema: 'atelier.presentation-request/v1', version: '1.0.0', presentationId: snapshot.id, status: 'proposed', executionAuthority: false, ...request }))
71
+ } catch {
72
+ outcome = 'failed'
73
+ } finally {
74
+ const remaining = pending.get(request.id) - 1
75
+ if (remaining) pending.set(request.id, remaining)
76
+ else pending.delete(request.id)
77
+ if (!disposed) { syncBusy(); say(deliveryMessage(outcome, [...pending.values()].reduce((sum, count) => sum + count, 0))) }
78
+ }
79
+ }
80
+ const requestResize = (paneId, value, control) => {
81
+ const pane = panes.get(paneId)
82
+ if (!pane?.width) return
83
+ const next = resizeRequest(pane.width, value)
84
+ // Geometry remains host-owned. The slider is a request, not optimistic state.
85
+ const slider = root.querySelector('[data-ap-resize="' + paneId + '"]')
86
+ if (slider) slider.value = pane.width.value
87
+ if (next !== pane.width.value) void send({ kind: 'resize', id: paneId, value: next }, control)
88
+ }
89
+ const move = (nodeId, itemId, position, control) => {
90
+ const node = nodes.get(nodeId), from = node?.items?.findIndex(item => item.id === itemId)
91
+ if (node?.type !== 'sequence' || !node.reorderable || from < 0 || !Number.isInteger(position) || position < 0 || position >= node.items.length || position === from) return
92
+ void send({ kind: 'move', id: nodeId, itemId, position }, control)
93
+ }
94
+ listen(root, 'click', event => {
95
+ const control = event.target.closest?.('button')
96
+ if (!control || !root.contains(control) || control.disabled) return
97
+ if (queuedUpdate) { say('Workspace update is waiting for text composition. No request sent.'); return }
98
+ const actionId = control.dataset.apAction
99
+ if (actionId) {
100
+ const node = nodes.get(actionId)
101
+ if (node?.type !== 'action' || node.disabled || pending.has(node.id)) return
102
+ if (node.confirmation) {
103
+ if (!dialog || typeof dialog.showModal !== 'function') { say('Confirmation is unavailable in this host. No request sent.'); return }
104
+ confirmation = node
105
+ opener = control
106
+ root.querySelector('[data-ap-confirm-title]').textContent = node.confirmation.title
107
+ root.querySelector('[data-ap-confirm-description]').textContent = node.confirmation.description
108
+ root.querySelector('[data-ap-cancel]').textContent = node.confirmation.cancelLabel
109
+ root.querySelector('[data-ap-confirm-action]').textContent = node.confirmation.confirmLabel
110
+ dialog.showModal()
111
+ root.querySelector('[data-ap-cancel]').focus()
112
+ } else void send({ kind: 'action', id: node.id, actionRef: node.actionRef }, control)
113
+ } else if (control.hasAttribute('data-ap-cancel')) close()
114
+ else if (control.hasAttribute('data-ap-confirm-action') && confirmation) {
115
+ const node = confirmation, source = opener
116
+ close()
117
+ void send({ kind: 'action', id: node.id, actionRef: node.actionRef, presentationConfirmed: true }, source)
118
+ } else if (control.dataset.apSelect) {
119
+ const node = nodes.get(control.dataset.apSelect), item = node?.items?.find(item => item.id === control.dataset.apItem)
120
+ if (item) void send({ kind: 'selection', id: node.id, itemId: item.id }, control)
121
+ } else if (control.dataset.apMove) {
122
+ move(control.dataset.apMove, control.dataset.apItem, Number(control.dataset.apPosition), control)
123
+ } else if (control.dataset.apStep) {
124
+ const pane = panes.get(control.dataset.apStep), delta = Number(control.dataset.apDelta)
125
+ if (pane?.width && [-1, 1].includes(delta)) requestResize(pane.id, pane.width.value + delta, control)
126
+ }
127
+ })
128
+ listen(root, 'dragstart', event => {
129
+ const item = event.target.closest?.('[data-ap-drag]'), node = nodes.get(item?.dataset.apDrag)
130
+ if (!item || !root.contains(item) || !node?.reorderable || typeof onRequest !== 'function') { event.preventDefault(); return }
131
+ drag = { id: node.id, itemId: item.dataset.apItem }
132
+ item.dataset.apDragging = 'true'
133
+ event.dataTransfer?.setData('text/plain', item.dataset.apItem)
134
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'
135
+ })
136
+ const endDrag = () => { drag = null; root.querySelectorAll('[data-ap-dragging]').forEach(item => item.removeAttribute('data-ap-dragging')) }
137
+ listen(root, 'dragover', event => { if (drag && event.target.closest?.('[data-ap-drag]')?.dataset.apDrag === drag.id) event.preventDefault() })
138
+ listen(root, 'drop', event => {
139
+ const item = event.target.closest?.('[data-ap-drag]')
140
+ if (drag && item?.dataset.apDrag === drag.id) {
141
+ event.preventDefault()
142
+ move(drag.id, drag.itemId, nodes.get(drag.id).items.findIndex(x => x.id === item.dataset.apItem))
143
+ }
144
+ endDrag()
145
+ })
146
+ listen(root, 'dragend', endDrag)
147
+ listen(root, 'change', event => {
148
+ const control = event.target
149
+ if (control.dataset.apResize) {
150
+ const value = Number(control.value)
151
+ if (Number.isFinite(value)) requestResize(control.dataset.apResize, value, control)
152
+ }
153
+ })
154
+ listen(root, 'keydown', event => {
155
+ const control = event.target, pane = panes.get(control.dataset.apResize)
156
+ if (!pane?.width || control.disabled || event.altKey || event.ctrlKey || event.metaKey) return
157
+ const next = keyboardResize(pane.width, event.key, snapshot.direction)
158
+ if (next !== null) { event.preventDefault(); requestResize(pane.id, next, control) }
159
+ })
160
+ const edit = event => {
161
+ const control = event.target, node = nodes.get(control.dataset.apEdit)
162
+ if (node && ['field', 'editor'].includes(node.type) && !node.disabled) {
163
+ drafts.set(node.id, control.value)
164
+ if (event.isComposing || composing) return
165
+ // A newer host model may have removed/disabled this control or explicitly
166
+ // reset its draft while the old DOM was still composing. Do not send it.
167
+ const nextNode = queuedUpdate?.model.nodes.find(next => next.id === node.id)
168
+ if (queuedUpdate && (!nextNode || nextNode.disabled || nextNode.type !== node.type || nextNode.input !== node.input || queuedUpdate.options.discardDrafts)) return
169
+ const error = validateDraft(control, node)
170
+ if (error) { say(error); return }
171
+ void send({ kind: 'edit', id: node.id, value: control.value })
172
+ }
173
+ }
174
+ listen(root, 'input', edit)
175
+ listen(root, 'compositionstart', () => { composing = true })
176
+ listen(root, 'compositionend', event => {
177
+ composing = false
178
+ const queued = queuedUpdate, revision = updateRevision
179
+ edit(event)
180
+ // A synchronous host callback may already have supplied a newer model.
181
+ // Never overwrite it with the model queued before the final composed edit.
182
+ if (queued && !disposed && revision === updateRevision) update(queued.model, queued.options)
183
+ })
184
+ listen(root, 'cancel', event => { if (event.target === dialog) { event.preventDefault(); close() } }, true)
185
+ // This dialog contains exactly two controls. Keep Tab inside its local scope;
186
+ // no command shortcuts or document-level focus bridge are registered.
187
+ listen(root, 'keydown', event => {
188
+ if (event.key !== 'Tab' || !dialog?.open || !dialog.contains(event.target)) return
189
+ const first = dialog.querySelector('[data-ap-cancel]')
190
+ const last = dialog.querySelector('[data-ap-confirm-action]')
191
+ if (event.shiftKey && root.ownerDocument.activeElement === first) { event.preventDefault(); last.focus() }
192
+ else if (!event.shiftKey && root.ownerDocument.activeElement === last) { event.preventDefault(); first.focus() }
193
+ })
194
+ listen(root, 'close', event => { if (event.target === dialog) { confirmation = null; if (opener) restoreFocus() } }, true)
195
+ if (typeof onRequest !== 'function') {
196
+ for (const control of root.querySelectorAll('button,input,textarea')) {
197
+ const previous = control.disabled
198
+ control.disabled = true
199
+ listeners.push(() => { control.disabled = previous })
200
+ }
201
+ say('Read-only view. No host request handler is connected.')
202
+ }
203
+ const dispose = () => {
204
+ if (disposed) return
205
+ if (dialog?.open) close()
206
+ disposed = true
207
+ queuedUpdate = null
208
+ composing = false
209
+ endDrag()
210
+ drafts.clear()
211
+ syncBusy()
212
+ for (const remove of listeners.reverse()) remove()
213
+ bindings.delete(root)
214
+ }
215
+ const update = (nextModel, { discardDrafts = false, tokenOverrides = {} } = {}) => {
216
+ assertPresentation(nextModel)
217
+ if (disposed || nextModel.id !== snapshot.id) throw new TypeError('update requires the same live presentation identity')
218
+ const rendered = renderPresentation(nextModel, { tokenOverrides })
219
+ updateRevision++
220
+ if (composing) {
221
+ queuedUpdate = { model: structuredClone(nextModel), options: { discardDrafts, tokenOverrides: structuredClone(tokenOverrides) } }
222
+ if (dialog?.open) close()
223
+ endDrag()
224
+ return api
225
+ }
226
+ queuedUpdate = null
227
+ const template = root.ownerDocument.createElement('template')
228
+ template.innerHTML = rendered
229
+ const next = template.content.querySelector('[data-ap-root]')
230
+ if (dialog?.open) close()
231
+ endDrag()
232
+ const focused = root.ownerDocument.activeElement
233
+ const keys = ['id', 'data-ap-select', 'data-ap-item', 'data-ap-move', 'data-ap-position', 'data-ap-step', 'data-ap-delta', 'href']
234
+ const identity = focused && root.contains(focused) ? keys.map(key => [key, focused.getAttribute(key)]) : null
235
+ const selection = focused && 'selectionStart' in focused ? [focused.selectionStart, focused.selectionEnd, focused.selectionDirection] : null
236
+ if (discardDrafts) drafts.clear()
237
+ const previousNodes = nodes
238
+ snapshot = structuredClone(nextModel)
239
+ nodes = new Map(snapshot.nodes.map(node => [node.id, node]))
240
+ panes = new Map(snapshot.panes.map(pane => [pane.id, pane]))
241
+ for (const [id, value] of drafts) if (!nodes.has(id) || nodes.get(id).type !== previousNodes.get(id)?.type || nodes.get(id).input !== previousNodes.get(id)?.input || nodes.get(id).value === value) drafts.delete(id)
242
+ // Email/number inputs have no public selection API. Retain the focused
243
+ // control's internal caret when the host refreshes the same field; copy
244
+ // validated presentation attributes and avoid a redundant value write.
245
+ if (focused?.dataset.apEdit && root.contains(focused) && ['email', 'number'].includes(focused.type)) {
246
+ const replacement = next.querySelector('[data-ap-edit="' + focused.dataset.apEdit + '"]')
247
+ if (replacement?.tagName === focused.tagName && replacement.type === focused.type) {
248
+ const value = drafts.get(focused.dataset.apEdit) ?? replacement.value
249
+ for (const attr of [...focused.attributes]) if (!replacement.hasAttribute(attr.name)) focused.removeAttribute(attr.name)
250
+ for (const attr of replacement.attributes) if (focused.getAttribute(attr.name) !== attr.value) focused.setAttribute(attr.name, attr.value)
251
+ if (focused.value !== value) focused.value = value
252
+ replacement.replaceWith(focused)
253
+ }
254
+ }
255
+ root.replaceChildren(...next.childNodes)
256
+ for (const attr of next.attributes) root.setAttribute(attr.name, attr.value)
257
+ output = root.querySelector('[data-ap-delivery]')
258
+ dialog = root.querySelector('[data-ap-confirm]')
259
+ for (const control of root.querySelectorAll('[data-ap-edit]')) {
260
+ if (drafts.has(control.dataset.apEdit) && control.value !== drafts.get(control.dataset.apEdit)) control.value = drafts.get(control.dataset.apEdit)
261
+ const error = validateDraft(control, nodes.get(control.dataset.apEdit))
262
+ if (error) say(error)
263
+ }
264
+ if (typeof onRequest !== 'function') for (const control of root.querySelectorAll('button,input,textarea')) control.disabled = true
265
+ syncBusy()
266
+ if (identity) {
267
+ const target = [...root.querySelectorAll('button,input,textarea,a,[tabindex]')].find(element => identity.every(([key, value]) => element.getAttribute(key) === value))
268
+ if (target && !target.disabled) {
269
+ target.focus({ preventScroll: true })
270
+ if (selection && selection[0] !== null && typeof target.setSelectionRange === 'function' && ['text', 'search', 'textarea'].includes(target.type)) target.setSelectionRange(...selection)
271
+ } else root.querySelector('[data-ap-pane]')?.focus({ preventScroll: true })
272
+ }
273
+ return api
274
+ }
275
+ bindings.set(root, dispose)
276
+ const api = Object.freeze({ dispose, update, get isComposing() { return composing }, get hasPendingUpdate() { return queuedUpdate !== null } })
277
+ return api
278
+ }
@@ -0,0 +1,116 @@
1
+ import { presentationSchema, paneSchema } from './schema.generated.mjs'
2
+ import { matchesPresentationSchema } from './schema-check.mjs'
3
+ import { canonicalize } from '../../attestation/jcs.mjs'
4
+
5
+ export const PRESENTATION_VERSION = '1.0.0'
6
+ export const PRESENTATION_SCHEMA = 'atelier.presentation/v1'
7
+ export const PRESENTATION_BYTE_LIMIT = 1048576
8
+ const validateModel = value => matchesPresentationSchema(presentationSchema, value)
9
+ const validatePaneSchema = value => matchesPresentationSchema(paneSchema, value)
10
+
11
+ function jsonData(value) {
12
+ const queue = [[value, 0]]
13
+ let count = 0
14
+ let stringBytes = 0
15
+ const size = value => {
16
+ if (value.length > PRESENTATION_BYTE_LIMIT) return false
17
+ stringBytes += new TextEncoder().encode(JSON.stringify(value)).length
18
+ return stringBytes <= PRESENTATION_BYTE_LIMIT
19
+ }
20
+ while (queue.length) {
21
+ const [item, depth] = queue.pop()
22
+ if (++count > 20000 || depth > 12) return false
23
+ if (typeof item === 'string') { if (!size(item)) return false; continue }
24
+ if (item === null || typeof item === 'boolean') continue
25
+ if (typeof item === 'number' && Number.isFinite(item)) continue
26
+ if (typeof item !== 'object') return false
27
+ if (!Array.isArray(item) && Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null) return false
28
+ for (const [key, d] of Object.entries(Object.getOwnPropertyDescriptors(item))) {
29
+ if (!Object.hasOwn(d, 'value')) return false
30
+ if (!Array.isArray(item) && !size(key)) return false
31
+ queue.push([d.value, depth + 1])
32
+ }
33
+ }
34
+ // Count punctuation, escaping and numeric encodings using the same canonical
35
+ // representation emitted by serializePresentation. No getters remain here.
36
+ try { return new TextEncoder().encode(canonicalize(value)).length <= PRESENTATION_BYTE_LIMIT }
37
+ catch { return false }
38
+ }
39
+
40
+ export function safeReference(value, { asset = false } = {}) {
41
+ if (typeof value !== 'string' || /[\u0000-\u0020\\<>]/u.test(value)) return false
42
+ if (!value.startsWith('/') && !(value.startsWith('#') && !asset)) return false
43
+ if (value.startsWith('//') || /%2f|%5c|%00/i.test(value)) return false
44
+ return true
45
+ }
46
+
47
+ function widthErrors(pane) {
48
+ return pane.width && (pane.width.min > pane.width.max || pane.width.value < pane.width.min || pane.width.value > pane.width.max)
49
+ ? ['pane width must satisfy min <= value <= max'] : []
50
+ }
51
+
52
+ export function validatePanePresentation(value) {
53
+ if (!jsonData(value)) return ['pane must be bounded plain JSON']
54
+ if (!validatePaneSchema(value)) return ['invalid pane shape']
55
+ return widthErrors(value)
56
+ }
57
+
58
+ export function validatePresentation(value) {
59
+ if (!jsonData(value)) return ['presentation must be bounded plain JSON']
60
+ if (!validateModel(value)) return ['invalid presentation shape or version']
61
+ const errors = []
62
+ const ids = new Set()
63
+ for (const entry of [...value.panes, ...value.nodes, ...value.navigation]) {
64
+ if (ids.has(entry.id)) errors.push('duplicate presentation identity')
65
+ ids.add(entry.id)
66
+ }
67
+ const nodes = new Map(value.nodes.map(node => [node.id, node]))
68
+ const panes = new Set(value.panes.map(pane => pane.id))
69
+ const mounted = new Set()
70
+ for (const pane of value.panes) {
71
+ errors.push(...widthErrors(pane))
72
+ for (const ref of pane.blocks) {
73
+ if (!nodes.has(ref)) errors.push('unknown pane block')
74
+ if (mounted.has(ref)) errors.push('block mounted more than once')
75
+ mounted.add(ref)
76
+ }
77
+ }
78
+ if (value.panes.filter(p => p.role === 'primary').length !== 1) errors.push('exactly one primary pane required')
79
+ for (const nav of value.navigation) if (!panes.has(nav.target)) errors.push('unknown navigation target')
80
+ for (const node of value.nodes) {
81
+ if (node.type === 'action' && node.disabled && !node.reason) errors.push('disabled action needs visible reason')
82
+ if (node.type === 'media' && !safeReference(node.src, { asset: true })) errors.push('media source must be a local path')
83
+ if (node.items) {
84
+ const localIds = new Set()
85
+ for (const item of node.items) {
86
+ if (localIds.has(item.id)) errors.push('duplicate item identity')
87
+ localIds.add(item.id)
88
+ if (item.href && !safeReference(item.href)) errors.push('link must be a local path or fragment')
89
+ }
90
+ for (const edge of node.edges ?? []) if (!localIds.has(edge.from) || !localIds.has(edge.to)) errors.push('unknown graph endpoint')
91
+ }
92
+ for (const ref of node.actions ?? []) {
93
+ if (nodes.get(ref)?.type !== 'action') errors.push('unknown action reference')
94
+ if (mounted.has(ref)) errors.push('action mounted more than once')
95
+ mounted.add(ref)
96
+ }
97
+ }
98
+ for (const id of nodes.keys()) if (!mounted.has(id)) errors.push('unmounted node')
99
+ return errors
100
+ }
101
+
102
+ export function assertPresentation(value) {
103
+ const errors = validatePresentation(value)
104
+ if (errors.length) throw new TypeError(errors.join('; '))
105
+ return value
106
+ }
107
+
108
+ export function serializePresentation(value) {
109
+ assertPresentation(value)
110
+ return canonicalize(value)
111
+ }
112
+
113
+ export function parsePresentation(serialized) {
114
+ if (typeof serialized !== 'string' || new TextEncoder().encode(serialized).length > PRESENTATION_BYTE_LIMIT) throw new TypeError('presentation byte limit')
115
+ return assertPresentation(JSON.parse(serialized))
116
+ }
@@ -0,0 +1,7 @@
1
+ // Opt-in library surface: importing the root CLI does not install a presenter.
2
+ export { PRESENTATION_SCHEMA, PRESENTATION_VERSION, PRESENTATION_BYTE_LIMIT, validatePresentation, assertPresentation, validatePanePresentation, parsePresentation, serializePresentation } from './contract.mjs'
3
+ export { tokenContract, resolveTokens, tokenVariables, contrastRatio } from './tokens.mjs'
4
+ export { presentationState, resizeRequest, keyboardResize, EDIT_CODE_POINT_LIMIT, editValueError } from './state.mjs'
5
+ export { renderPresentation, renderPresentationDocument, renderReadOnlyDocument } from './web.mjs'
6
+ export { presentationStyles } from './styles.mjs'
7
+ export { comparePresentationProofs } from './proof.mjs'
@@ -0,0 +1,153 @@
1
+ import { assertPresentation } from './contract.mjs'
2
+ import { resolveTokens } from './tokens.mjs'
3
+ import { editValueError, deliveryMessage, toneLabel } from './state.mjs'
4
+
5
+ // Inject the consumer's existing React/native (or Tamagui native) primitives.
6
+ // The root imports no framework and installs no host/window/global driver.
7
+ export function createNativePresentation({ React, View, Text, Pressable, TextInput, ScrollView, Image }) {
8
+ if (![React?.createElement, React?.useState, React?.useRef, React?.useEffect].every(x => typeof x === 'function') ||
9
+ [View, Text, Pressable, TextInput, ScrollView, Image].some(x => !x)) throw new TypeError('native primitive bindings required')
10
+ const h = React.createElement
11
+ return function NativePresentation({ model, onRequest, confirm, resolveAsset, tokenOverrides = {}, containerWidth = 320 }) {
12
+ assertPresentation(model)
13
+ if (!Number.isFinite(containerWidth) || containerWidth <= 0) throw new TypeError('container width required')
14
+ const tokens = resolveTokens(model.theme, tokenOverrides)
15
+ const totalWeight = model.panes.reduce((sum, pane) => sum + (pane.width?.value ?? 50), 0)
16
+ const paneSpace = containerWidth - tokens.spacing.large * (model.panes.length + 1)
17
+ const [delivery, setDelivery] = React.useState('')
18
+ const [, refreshPending] = React.useState(0)
19
+ const active = React.useRef(true)
20
+ const pending = React.useRef(new Map())
21
+ const drafts = React.useRef(new Map())
22
+ const current = React.useRef({ model, onRequest, confirm })
23
+ const generation = React.useRef(0)
24
+ const signature = JSON.stringify(model)
25
+ // Only committed model changes revoke pending confirmation. Inline callback
26
+ // identities routinely change when a host opens its confirmation dialog.
27
+ const useCommitEffect = React.useLayoutEffect ?? React.useEffect
28
+ useCommitEffect(() => {
29
+ if (current.current.signature !== signature) generation.current++
30
+ current.current = { signature, model, onRequest, confirm }
31
+ }, [signature, onRequest, confirm])
32
+ for (const [id, value] of drafts.current) if (!model.nodes.some(node => node.id === id && node.value !== value)) drafts.current.delete(id)
33
+ const confirmations = React.useRef(new Set())
34
+ React.useEffect(() => { active.current = true; return () => { active.current = false } }, [])
35
+ const request = async event => {
36
+ const host = current.current
37
+ if (typeof host.onRequest !== 'function' || !active.current) return
38
+ if (pending.current.has(event.id) && event.kind !== 'edit') { setDelivery(deliveryMessage('suppressed')); return }
39
+ pending.current.set(event.id, (pending.current.get(event.id) ?? 0) + 1)
40
+ refreshPending(value => value + 1)
41
+ setDelivery(deliveryMessage('sending'))
42
+ let outcome = 'delivered'
43
+ try {
44
+ await host.onRequest(Object.freeze({ schema: 'atelier.presentation-request/v1', version: '1.0.0', presentationId: host.model.id, status: 'proposed', executionAuthority: false, ...event }))
45
+ } catch {
46
+ // Settlement belongs to the request, not the confirmation/model generation.
47
+ outcome = 'failed'
48
+ } finally {
49
+ const remaining = pending.current.get(event.id) - 1
50
+ if (remaining) pending.current.set(event.id, remaining)
51
+ else pending.current.delete(event.id)
52
+ if (active.current) { refreshPending(value => value + 1); setDelivery(deliveryMessage(outcome, [...pending.current.values()].reduce((sum, count) => sum + count, 0))) }
53
+ }
54
+ }
55
+ const style = { color: tokens.color.text, fontFamily: tokens.typography.family, fontSize: tokens.typography.body, lineHeight: tokens.typography.body * tokens.typography.lineHeight }
56
+ const text = (value, props = {}) => h(Text, { ...props, allowFontScaling: true, style: [style, props.style] }, value)
57
+ const button = (label, props = {}) => h(Pressable, {
58
+ accessibilityRole: 'button', accessibilityLabel: label,
59
+ ...props,
60
+ style: ({ pressed }) => [ { minHeight: tokens.density.target, minWidth: tokens.density.target, padding: tokens.spacing.medium, borderWidth: tokens.border.width, borderColor: tokens.color.border, borderRadius: tokens.border.radius, backgroundColor: pressed && !props.disabled ? tokens.color.selected : tokens.color.panel, opacity: props.disabled ? tokens.state.disabledOpacity : 1 }, props.style ],
61
+ }, text(label))
62
+ const nodes = new Map(model.nodes.map(node => [node.id, node]))
63
+ const action = node => button(node.label, {
64
+ key: node.id, disabled: node.disabled || typeof onRequest !== 'function' || pending.current.has(node.id),
65
+ accessibilityState: { disabled: node.disabled || typeof onRequest !== 'function' || pending.current.has(node.id), busy: pending.current.has(node.id) },
66
+ accessibilityHint: node.reason,
67
+ onPress: async () => {
68
+ if (node.disabled || typeof onRequest !== 'function' || confirmations.current.has(node.id) || pending.current.has(node.id)) return
69
+ if (node.confirmation) {
70
+ const confirmCurrent = current.current.confirm
71
+ if (typeof confirmCurrent !== 'function') { setDelivery('Confirmation is unavailable in this host. No request sent.'); return }
72
+ let accepted = false
73
+ const started = generation.current
74
+ confirmations.current.add(node.id)
75
+ try { accepted = await confirmCurrent(Object.freeze({ ...node.confirmation, initialFocus: 'cancel', restoreFocus: true })) === true } catch { accepted = false }
76
+ finally { confirmations.current.delete(node.id) }
77
+ if (!active.current) return
78
+ if (started !== generation.current || typeof current.current.onRequest !== 'function' || typeof current.current.confirm !== 'function') {
79
+ setDelivery('Confirmation is no longer current. No request sent.'); return
80
+ }
81
+ if (!accepted) { setDelivery('Confirmation cancelled. No request sent.'); return }
82
+ }
83
+ await request({ kind: 'action', id: node.id, actionRef: node.actionRef, ...(node.confirmation ? { presentationConfirmed: true } : {}) })
84
+ },
85
+ })
86
+ const renderNode = node => {
87
+ let body = []
88
+ if (node.type === 'action') return h(View, { key: node.id }, action(node), node.reason ? text(node.reason) : null)
89
+ if (node.tone && toneLabel(node.tone)) body.push(text(toneLabel(node.tone), { style: { fontWeight: '600' } }))
90
+ if (node.text !== undefined) body.push(text(node.text))
91
+ if (node.type === 'field' || node.type === 'editor') {
92
+ const draft = drafts.current.get(node.id) ?? node.value
93
+ const draftError = editValueError(draft)
94
+ body.push(h(TextInput, { accessibilityLabel: node.label + (node.required ? ' (required)' : ''), value: draft, editable: !node.disabled && typeof onRequest === 'function',
95
+ multiline: node.type === 'editor', allowFontScaling: true,
96
+ inputMode: node.input === 'email' ? 'email' : node.input === 'number' ? 'decimal' : 'text',
97
+ returnKeyType: node.input === 'search' ? 'search' : 'default',
98
+ accessibilityState: { disabled: node.disabled || typeof onRequest !== 'function' },
99
+ style: { ...style, minHeight: tokens.density.target, padding: tokens.spacing.medium, borderWidth: node.error || draftError ? 2 : 1, borderColor: node.error || draftError ? tokens.color.danger : tokens.color.border },
100
+ onChangeText: value => {
101
+ // Editing is host-controlled; never claim persistence from this callback.
102
+ if (node.disabled || typeof onRequest !== 'function') return
103
+ drafts.current.set(node.id, value)
104
+ refreshPending(version => version + 1)
105
+ const error = editValueError(value)
106
+ if (error) { setDelivery(error); return }
107
+ void request({ kind: 'edit', id: node.id, value })
108
+ } }))
109
+ if (draftError) body.push(text(draftError, { accessibilityLiveRegion: 'polite' }))
110
+ if (node.error) body.push(text(node.error, { accessibilityLiveRegion: 'polite' }))
111
+ }
112
+ if (node.type === 'diff') body.push(text(node.beforeLabel), text(node.before), text(node.afterLabel), text(node.after))
113
+ if (node.type === 'media') {
114
+ const source = resolveAsset?.(node.src)
115
+ body.push(source ? h(Image, { source, accessible: true, accessibilityLabel: node.alt, style: { width: '100%', height: 180, resizeMode: 'contain' } }) : text('Media unavailable in this host: ' + node.alt))
116
+ if (node.caption) body.push(text(node.caption))
117
+ }
118
+ for (const [index, item] of (node.items ?? []).entries()) {
119
+ body.push(button(item.label, { key: item.id, disabled: typeof onRequest !== 'function',
120
+ accessibilityState: { selected: item.selected, disabled: typeof onRequest !== 'function' },
121
+ onPress: () => request(item.href ? { kind: 'navigation', id: node.id, itemId: item.id, href: item.href } : { kind: 'selection', id: node.id, itemId: item.id }),
122
+ style: item.selected ? { backgroundColor: tokens.color.selected } : {},
123
+ }))
124
+ if (item.detail) body.push(text(item.detail))
125
+ if (node.reorderable) body.push(h(View, { key: item.id + ':move', style: { flexDirection: 'row', flexWrap: 'wrap', gap: tokens.spacing.medium } },
126
+ ...[-1, 1].map(delta => button('Move ' + item.label + (delta === -1 ? ' earlier' : ' later'), {
127
+ key: String(delta), disabled: typeof onRequest !== 'function' || index + delta < 0 || index + delta >= node.items.length,
128
+ onPress: () => { if (index + delta >= 0 && index + delta < node.items.length) return request({ kind: 'move', id: node.id, itemId: item.id, position: index + delta }) },
129
+ }))))
130
+ }
131
+ const labels = new Map((node.items ?? []).map(item => [item.id, item.label]))
132
+ for (const edge of node.edges ?? []) body.push(text(labels.get(edge.from) + ' — ' + edge.label + ' — ' + labels.get(edge.to)))
133
+ for (const detail of node.details ?? []) body.push(text(detail.label + ': ' + detail.value))
134
+ for (const ref of node.actions ?? []) {
135
+ body.push(action(nodes.get(ref)))
136
+ if (nodes.get(ref).reason) body.push(text(nodes.get(ref).reason))
137
+ }
138
+ return h(View, { key: node.id, style: { gap: tokens.spacing.medium, marginBottom: tokens.spacing.section } },
139
+ text(node.label, { accessibilityRole: 'header', style: { fontSize: tokens.typography.heading } }), ...body)
140
+ }
141
+ return h(ScrollView, { style: { backgroundColor: tokens.color.page, direction: model.direction }, contentContainerStyle: { padding: tokens.spacing.large, gap: tokens.spacing.large }, keyboardShouldPersistTaps: 'handled' },
142
+ text(model.title, { accessibilityRole: 'header', style: { fontSize: tokens.typography.heading } }),
143
+ h(View, { style: { flexDirection: 'row', flexWrap: 'wrap', gap: tokens.spacing.medium } }, ...model.navigation.map(item => button(item.label, { key: item.id, disabled: typeof onRequest !== 'function', onPress: () => request({ kind: 'navigation', id: item.id, paneId: item.target }) }))),
144
+ h(View, { style: { flexDirection: containerWidth <= tokens.layout.narrow ? 'column' : 'row', flexWrap: 'wrap', gap: tokens.spacing.large } },
145
+ ...model.panes.map(pane => h(View, { key: pane.id, accessibilityLabel: pane.label, style: { flexGrow: containerWidth <= tokens.layout.narrow ? 0 : pane.width?.value ?? 50, flexShrink: 1, flexBasis: containerWidth <= tokens.layout.narrow ? 'auto' : Math.max(tokens.layout.paneMin, paneSpace * (pane.width?.value ?? 50) / totalWeight), minWidth: 0, maxWidth: '100%', padding: tokens.density[model.density], backgroundColor: tokens.color.panel, borderWidth: tokens.border.width, borderColor: tokens.color.border, borderRadius: tokens.border.radius } },
146
+ text(pane.label, { accessibilityRole: 'header', style: { fontSize: tokens.typography.heading } }),
147
+ pane.width && containerWidth > tokens.layout.narrow ? h(View, { style: { flexDirection: 'row', flexWrap: 'wrap', gap: tokens.spacing.medium } },
148
+ button('Decrease width of ' + pane.label, { disabled: typeof onRequest !== 'function' || pane.width.value <= pane.width.min, onPress: () => request({ kind: 'resize', id: pane.id, value: Math.max(pane.width.min, pane.width.value - 1) }) }),
149
+ button('Increase width of ' + pane.label, { disabled: typeof onRequest !== 'function' || pane.width.value >= pane.width.max, onPress: () => request({ kind: 'resize', id: pane.id, value: Math.min(pane.width.max, pane.width.value + 1) }) })) : null,
150
+ ...pane.blocks.map(id => renderNode(nodes.get(id)))))),
151
+ text(delivery || (typeof onRequest === 'function' ? 'Host-connected presentation.' : 'Read-only presentation.'), { accessibilityLiveRegion: 'polite' }))
152
+ }
153
+ }
@@ -0,0 +1,57 @@
1
+ import { canonicalize } from '../../attestation/jcs.mjs'
2
+
3
+ const text = value => typeof value === 'string' && value.length > 0 && value.length <= 256
4
+ const positive = value => Number.isFinite(value) && value > 0 && value <= 16384
5
+ const closed = (value, keys) => value !== null && typeof value === 'object' && !Array.isArray(value) &&
6
+ Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key))
7
+ const list = (values, predicate) => Array.isArray(values) && values.length > 0 && values.length <= 256 && new Set(values).size === values.length && values.every(predicate)
8
+ function plain(value, seen = new Set(), depth = 0) {
9
+ if (depth > 10 || seen.size > 10000) return false
10
+ if (value === null || ['string', 'boolean'].includes(typeof value)) return typeof value !== 'string' || value.length <= 4096
11
+ if (typeof value === 'number') return Number.isFinite(value)
12
+ if (typeof value !== 'object' || seen.has(value) || (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype)) return false
13
+ seen.add(value)
14
+ return Object.values(Object.getOwnPropertyDescriptors(value)).every(d => Object.hasOwn(d, 'value') && plain(d.value, seen, depth + 1))
15
+ }
16
+ function environmentValid(env) {
17
+ return closed(env, ['platform', 'release', 'architecture', 'locale', 'timezone', 'scale', 'font', 'motion', 'viewports', 'themes', 'densities']) &&
18
+ [env.platform, env.release, env.architecture, env.locale, env.timezone, env.font].every(text) && positive(env.scale) && ['reduce', 'no-preference'].includes(env.motion) &&
19
+ list(env.viewports, positive) && list(env.themes, x => ['light', 'dark'].includes(x)) && list(env.densities, x => ['comfortable', 'compact'].includes(x))
20
+ }
21
+ function conditionsValid(c, env) {
22
+ return closed(c, ['width', 'height', 'theme', 'density', 'motion', 'font', 'locale', 'scale']) &&
23
+ positive(c.width) && positive(c.height) && env.viewports.includes(c.width) && env.themes.includes(c.theme) && env.densities.includes(c.density) &&
24
+ c.motion === env.motion && c.font === env.font && c.locale === env.locale && c.scale === env.scale
25
+ }
26
+
27
+ // Comparison is deliberately not baseline acceptance or release authorization.
28
+ export function comparePresentationProofs(baseline, candidate) {
29
+ const digest = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)
30
+ const valid = proof => plain(proof) && closed(proof, ['schema', 'status', 'sourceHead', 'sourceDirty', 'sourceDigest', 'runnerDigest', 'fixtureDigest', 'environment', 'scope', 'nativeDeviceAccepted', 'adopterAccepted', 'visualBaselineAccepted', 'runs']) &&
31
+ proof.schema === 'atelier.presentation-browser-proof/v3' && proof.status === 'passed' && proof.sourceDirty === false &&
32
+ /^[a-f0-9]{40}$/.test(proof.sourceHead ?? '') && digest(proof.sourceDigest) && digest(proof.fixtureDigest) && digest(proof.runnerDigest) &&
33
+ proof.scope === 'synthetic-local-browser' && proof.nativeDeviceAccepted === false && proof.adopterAccepted === false && proof.visualBaselineAccepted === false &&
34
+ environmentValid(proof.environment) && Array.isArray(proof.runs) && proof.runs.length > 0 && proof.runs.length <= 3 &&
35
+ proof.runs.every(run => closed(run, ['browser', 'version', 'status', 'checks', 'screenshots'])) &&
36
+ new Set(proof.runs.map(run => run.browser)).size === proof.runs.length &&
37
+ proof.runs.every(run => ['chromium', 'firefox', 'webkit'].includes(run.browser) && run.status === 'passed' &&
38
+ text(run.version) && list(run.checks, x => text(x) && /^[a-z0-9:-]+$/.test(x)) &&
39
+ Array.isArray(run.screenshots) && run.screenshots.length > 0 && run.screenshots.length <= 256 &&
40
+ run.screenshots.every(frame => closed(frame, ['file', 'sha256', 'capture', 'conditions']) &&
41
+ closed(frame.capture, ['width', 'height', 'scrollWidth', 'scrollHeight']) &&
42
+ Object.values(frame.capture).every(n => Number.isSafeInteger(n) && positive(n)) &&
43
+ frame.capture.width === frame.capture.scrollWidth * frame.conditions.scale &&
44
+ frame.capture.height === frame.capture.scrollHeight * frame.conditions.scale) &&
45
+ new Set(run.screenshots.map(frame => frame.file)).size === run.screenshots.length &&
46
+ run.screenshots.every(frame => /^[a-z0-9-]+\.png$/.test(frame.file) && digest(frame.sha256) && conditionsValid(frame.conditions, proof.environment)))
47
+ const result = (status, reason, changedFrames = []) => Object.freeze({ status, reason, changedFrames, baselineApprovalVerified: false, executionAuthority: false })
48
+ try { if (!valid(baseline) || !valid(candidate)) return result('incomparable', 'Both complete proof envelopes and their byte digests are required.') }
49
+ catch { return result('incomparable', 'Malformed proof data.') }
50
+ const dimensions = proof => ({ environment: proof.environment, fixtureDigest: proof.fixtureDigest, runnerDigest: proof.runnerDigest,
51
+ runs: proof.runs.map(run => ({ browser: run.browser, version: run.version, checks: [...run.checks].sort(), frames: run.screenshots.map(({ file, conditions }) => ({ file, conditions })).sort((a, b) => a.file.localeCompare(b.file)) })).sort((a, b) => a.browser.localeCompare(b.browser)) })
52
+ if (canonicalize(dimensions(baseline)) !== canonicalize(dimensions(candidate))) return result('incomparable', 'Fixture, environment, engine or coverage changed; recapture and obtain an explicit baseline disposition.')
53
+ const previous = new Map(baseline.runs.flatMap(run => run.screenshots.map(frame => [run.browser + ':' + frame.file, frame.sha256])))
54
+ const changed = candidate.runs.flatMap(run => run.screenshots.filter(frame => previous.get(run.browser + ':' + frame.file) !== frame.sha256).map(frame => run.browser + ':' + frame.file))
55
+ return changed.length ? result('review-required', 'Retain the before/after images and explain each change; never auto-accept a new baseline.', changed)
56
+ : result('unchanged', 'Pixel digests match at equal recorded coverage. Owner acceptance and nonvisual gates remain separate.')
57
+ }