@mnstry/atelier 0.2.0-alpha.5 → 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.
- package/CHANGELOG.md +191 -0
- package/README.md +86 -25
- package/SECURITY.md +6 -1
- package/contracts/atelier-adoption-policy.v1.schema.json +49 -0
- package/contracts/atelier-coauthor.v1.schema.json +163 -0
- package/contracts/atelier-decision-request.v1.schema.json +476 -0
- package/contracts/atelier-decision-result.v1.schema.json +500 -0
- package/contracts/atelier-guide.v1.schema.json +227 -0
- package/contracts/atelier-intake.v1.schema.json +151 -0
- package/contracts/atelier-migration.v2.schema.json +61 -0
- package/contracts/atelier-obsidian-acceptance-receipt.v1.schema.json +211 -0
- package/contracts/atelier-obsidian-apply-policy.v1.schema.json +298 -0
- package/contracts/atelier-obsidian-corpus-profile.v1.schema.json +98 -0
- package/contracts/atelier-obsidian-edit-operation.v1.schema.json +138 -0
- package/contracts/atelier-obsidian-ext-settings.v1.schema.json +287 -0
- package/contracts/atelier-obsidian-generation-manifest.v1.schema.json +353 -0
- package/contracts/atelier-obsidian-proposal-receipt.v1.schema.json +110 -0
- package/contracts/atelier-obsidian-publication-journal.v1.schema.json +167 -0
- package/contracts/atelier-obsidian-scope.v1.schema.json +264 -0
- package/contracts/atelier-obsidian-service-state.v1.schema.json +149 -0
- package/contracts/atelier-obsidian-source-snapshot.v1.schema.json +179 -0
- package/contracts/atelier-pane-presentation.v1.schema.json +76 -0
- package/contracts/atelier-presentation.v1.schema.json +1293 -0
- package/contracts/atelier-repository-observation.v1.schema.json +163 -0
- package/contracts/atelier-review.v1.schema.json +1601 -0
- package/contracts/atelier-skill-steward.v1.schema.json +692 -0
- package/contracts/atelier-upgrade-plan.v2.schema.json +398 -0
- package/contracts/atelier-upgrade-receipt.v1.schema.json +99 -0
- package/contracts/atelier-vault-publication.v1.schema.json +58 -0
- package/docs/assurance-controls.md +12 -2
- package/docs/atelier-runtime.md +13 -2
- package/docs/atelier-sync.md +172 -0
- package/docs/blocks/claims.md +28 -18
- package/docs/blocks/will-not-do.md +12 -3
- package/docs/coauthor-session.md +57 -0
- package/docs/connected-composition.md +75 -0
- package/docs/decisions.md +167 -0
- package/docs/exact-upgrades.md +159 -0
- package/docs/extension-pack-lifecycle.md +33 -0
- package/docs/guided-upgrades.md +99 -0
- package/docs/install.md +65 -9
- package/docs/intake-and-guides.md +65 -0
- package/docs/integration-and-review-plan.md +409 -0
- package/docs/integration-contract-decisions.md +22 -0
- package/docs/local-review.md +84 -0
- package/docs/local-services.md +227 -0
- package/docs/local-state.md +107 -0
- package/docs/obsidian-contract.md +687 -0
- package/docs/obsidian.md +387 -0
- package/docs/portable-presentation.md +328 -0
- package/docs/presentation-consumer-boundaries.md +75 -0
- package/docs/presentation-review-disposition.md +47 -0
- package/docs/project-command-inventory.md +38 -0
- package/docs/project-options.md +53 -0
- package/docs/release-engineering.md +99 -2
- package/docs/review-portability.md +55 -0
- package/docs/skill-steward.md +156 -0
- package/docs/upgrade.md +70 -2
- package/docs/vault-service.md +292 -0
- package/fixtures/atelier-coauthor/invalid/authority.v1.json +5 -0
- package/fixtures/atelier-coauthor/valid/config.v1.json +12 -0
- package/fixtures/atelier-guide/invalid/authority.v1.json +11 -0
- package/fixtures/atelier-guide/valid/capability.v1.json +10 -0
- package/fixtures/atelier-guide/valid/consent.v1.json +11 -0
- package/fixtures/atelier-guide/valid/engagement.v1.json +7 -0
- package/fixtures/atelier-guide/valid/offer.v1.json +10 -0
- package/fixtures/atelier-intake/invalid/authority.v1.json +9 -0
- package/fixtures/atelier-intake/valid/attempt.v1.json +8 -0
- package/fixtures/atelier-intake/valid/completion.v1.json +9 -0
- package/fixtures/atelier-intake/valid/source.v1.json +7 -0
- package/fixtures/atelier-repository-observation/invalid/complete-with-blocker.v1.json +18 -0
- package/fixtures/atelier-repository-observation/valid/complete-local.v1.json +48 -0
- package/fixtures/atelier-review/invalid/authenticated-identity.v1.json +22 -0
- package/fixtures/atelier-review/valid/bound.v1.json +229 -0
- package/fixtures/atelier-review/valid/bundle.v1.json +267 -0
- package/fixtures/atelier-review/valid/contribution.v1.json +22 -0
- package/fixtures/atelier-review/valid/lifecycle.v1.json +14 -0
- package/fixtures/atelier-skill-steward/audit/invalid/source-mutation.v1.json +26 -0
- package/fixtures/atelier-skill-steward/audit/valid/clean.v1.json +26 -0
- package/fixtures/atelier-skill-steward/candidates/invalid/workflow-key.v1.json +37 -0
- package/fixtures/atelier-skill-steward/candidates/valid/create.v1.json +37 -0
- package/fixtures/atelier-skill-steward/lock/invalid/bad-digest.v1.json +18 -0
- package/fixtures/atelier-skill-steward/lock/valid/installed.v1.json +23 -0
- package/fixtures/atelier-skill-steward/plan/invalid/delete-action.v1.json +33 -0
- package/fixtures/atelier-skill-steward/plan/valid/add.v1.json +33 -0
- package/fixtures/atelier-upgrade-transaction/adoption-policy/invalid/example.json +12 -0
- package/fixtures/atelier-upgrade-transaction/adoption-policy/valid/example.json +11 -0
- package/fixtures/atelier-upgrade-transaction/migration/invalid/example.json +20 -0
- package/fixtures/atelier-upgrade-transaction/migration/valid/example.json +19 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-plan/invalid/example.json +58 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-plan/valid/example.json +57 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-receipt/invalid/example.json +15 -0
- package/fixtures/atelier-upgrade-transaction/upgrade-receipt/valid/example.json +14 -0
- package/fixtures/atelier-vault-publication/invalid/publication.json +5 -0
- package/fixtures/atelier-vault-publication/valid/publication.json +11 -0
- package/fixtures/decisions/request/invalid/unknown-authority.v1.json +61 -0
- package/fixtures/decisions/request/valid/triage.v1.json +60 -0
- package/fixtures/decisions/result/invalid/execution-authority.v1.json +50 -0
- package/fixtures/decisions/result/valid/abstained.v1.json +23 -0
- package/fixtures/decisions/result/valid/assessed.v1.json +50 -0
- package/fixtures/obsidian/acceptance/ap05-extra-notes.json +14 -0
- package/fixtures/obsidian/acceptance/receipts/G07.valid.v1.json +63 -0
- package/fixtures/obsidian/acceptance/receipts/G13.valid.v1.json +69 -0
- package/fixtures/obsidian/acceptance/receipts/G14.valid.v1.json +62 -0
- package/fixtures/obsidian/acceptance/receipts/G15.valid.v1.json +56 -0
- package/fixtures/obsidian/acceptance/receipts/G16.valid.v1.json +73 -0
- package/fixtures/obsidian/acceptance/receipts/G17.valid.v1.json +75 -0
- package/fixtures/obsidian/acceptance/receipts/G18.valid.v1.json +64 -0
- package/fixtures/obsidian/acceptance/service-sentinels.json +10 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/duplicate-evidence-name.v1.json +39 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/evidence-by-absolute-path.v1.json +34 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/missing-candidate-tree.v1.json +33 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/no-evidence.v1.json +28 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-outcome.v1.json +34 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/invalid/unknown-top-level-field.v1.json +35 -0
- package/fixtures/obsidian/contracts/acceptance-receipt/valid/node-gate.v1.json +34 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/automatic-without-edit-class.v1.json +27 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/conflict-overwrite.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/missing-policy-digest.v1.json +28 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unbounded-batch.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unimplemented-edit-class.v1.json +30 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-mode.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-selector-operator.v1.json +22 -0
- package/fixtures/obsidian/contracts/apply-policy/invalid/unknown-top-level-field.v1.json +30 -0
- package/fixtures/obsidian/contracts/apply-policy/valid/automatic-scoped.v1.json +29 -0
- package/fixtures/obsidian/contracts/apply-policy/valid/manual-default.v1.json +20 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-path-in-ext.v1.json +30 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/absolute-repository-root.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/duplicate-repository-id.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/overlapping-managed-roots.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-enrollment.v1.json +27 -0
- package/fixtures/obsidian/contracts/corpus-profile/invalid/unknown-top-level-field.v1.json +28 -0
- package/fixtures/obsidian/contracts/corpus-profile/valid/three-repositories.v1.json +27 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/absolute-recovery-ref.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/inline-observed-bytes.v1.json +22 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/missing-base-digest.v1.json +20 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/short-idempotency-key.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/invalid/unknown-kind.v1.json +21 -0
- package/fixtures/obsidian/contracts/edit-operation/valid/pending-body-replacement.v1.json +21 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/absolute-path-prefix.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/apply-policy-in-portable-settings.v1.json +30 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/duplicate-scope-id.v1.json +27 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/machine-local-vault-path.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/service-port-in-portable-settings.v1.json +28 -0
- package/fixtures/obsidian/contracts/ext-settings/invalid/unknown-default-scope.v1.json +27 -0
- package/fixtures/obsidian/contracts/ext-settings/valid/disabled.v1.json +5 -0
- package/fixtures/obsidian/contracts/ext-settings/valid/enabled-with-scopes.v1.json +27 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/absolute-note-path.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/complete-with-unwritten-notes.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/derived-link-claimed-declared.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/duplicate-note-identity.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/in-scope-endpoint-missing.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/title-only-note-path.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-note-field.v1.json +115 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/unknown-relation-type.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/invalid/withheld-endpoint.v1.json +114 -0
- package/fixtures/obsidian/contracts/generation-manifest/valid/two-notes.v1.json +114 -0
- package/fixtures/obsidian/contracts/oracles/scope-cases.json +412 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/accepted-without-proposal-id.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/missing-adapter-operation-id.v1.json +11 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-backpressure-outcome.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/invalid/unknown-top-level-field.v1.json +13 -0
- package/fixtures/obsidian/contracts/proposal-receipt/valid/accepted-new.v1.json +12 -0
- package/fixtures/obsidian/contracts/proposal-receipt/valid/deferred.v1.json +12 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/absolute-recovery-ref.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/duplicate-sequence.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/empty-protocol-id.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/missing-protocol-id.v1.json +46 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-entry-field.v1.json +48 -0
- package/fixtures/obsidian/contracts/publication-journal/invalid/unknown-state.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/valid/committed.v1.json +47 -0
- package/fixtures/obsidian/contracts/publication-journal/valid/updating-after-restart.v1.json +22 -0
- package/fixtures/obsidian/contracts/scope/invalid/absolute-path-prefix.v1.json +15 -0
- package/fixtures/obsidian/contracts/scope/invalid/duplicate-ids.v1.json +18 -0
- package/fixtures/obsidian/contracts/scope/invalid/expansion-without-node-budget.v1.json +40 -0
- package/fixtures/obsidian/contracts/scope/invalid/full-mode-with-subset.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-mode.v1.json +41 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-key.v1.json +15 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-selector-operator.v1.json +14 -0
- package/fixtures/obsidian/contracts/scope/invalid/unknown-top-level-field.v1.json +42 -0
- package/fixtures/obsidian/contracts/scope/valid/explicit-empty.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/valid/focus.v1.json +11 -0
- package/fixtures/obsidian/contracts/scope/valid/full.v1.json +8 -0
- package/fixtures/obsidian/contracts/scope/valid/scoped-set-expression.v1.json +41 -0
- package/fixtures/obsidian/contracts/service-state/invalid/hostname-instead-of-literal-loopback.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/invalid/missing-consent.v1.json +18 -0
- package/fixtures/obsidian/contracts/service-state/invalid/privileged-port.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/invalid/unknown-top-level-field.v1.json +24 -0
- package/fixtures/obsidian/contracts/service-state/invalid/wildcard-bind.v1.json +23 -0
- package/fixtures/obsidian/contracts/service-state/valid/healthy.v1.json +23 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/absolute-file-path.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/duplicate-repository-id.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/mixed-read.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/parent-traversal-path.v1.json +48 -0
- package/fixtures/obsidian/contracts/source-snapshot/invalid/unknown-file-field.v1.json +49 -0
- package/fixtures/obsidian/contracts/source-snapshot/valid/two-repositories.v1.json +48 -0
- package/fixtures/obsidian/edits/workspace.json +65 -0
- package/fixtures/obsidian/materialization/expected-full.json +99 -0
- package/fixtures/obsidian/materialization/expected-scoped.json +39 -0
- package/fixtures/obsidian/materialization/workspace.json +54 -0
- package/fixtures/obsidian/proposals/expected-shapes.json +11 -0
- package/fixtures/ui/presentation/pane.v1.json +7 -0
- package/fixtures/ui/presentation/reference.v1.json +293 -0
- package/package.json +61 -6
- package/skills/claude/atelier-guided-coauthor/SKILL.md +49 -0
- package/skills/claude/atelier-guided-upgrade/SKILL.md +97 -0
- package/skills/claude/atelier-open/SKILL.md +5 -0
- package/skills/claude/atelier-skill-steward/SKILL.md +62 -0
- package/skills/claude/mnstry-readiness/SKILL.md +5 -0
- package/skills/codex/atelier-guided-coauthor/SKILL.md +49 -0
- package/skills/codex/atelier-guided-upgrade/SKILL.md +97 -0
- package/skills/codex/atelier-open/SKILL.md +5 -0
- package/skills/codex/atelier-skill-steward/SKILL.md +62 -0
- package/skills/codex/mnstry-readiness/SKILL.md +5 -0
- package/src/access/preview-enforcer.mjs +84 -0
- package/src/boundary/content-rules.mjs +9 -4
- package/src/boundary/policy.mjs +74 -42
- package/src/cli/project-options.mjs +58 -0
- package/src/cli/run.mjs +61 -3
- package/src/coauthor/session.mjs +161 -0
- package/src/coauthor/store.mjs +173 -0
- package/src/collaboration/event-ledger.mjs +16 -33
- package/src/collaboration/inspection-bundle.mjs +223 -0
- package/src/collaboration/review-contracts.mjs +25 -0
- package/src/collaboration/review-store.mjs +305 -0
- package/src/commands/coauthor.mjs +26 -0
- package/src/commands/config.mjs +6 -1
- package/src/commands/extension-pack.mjs +15 -8
- package/src/commands/init.mjs +44 -5
- package/src/commands/obsidian.mjs +368 -0
- package/src/commands/review.mjs +147 -0
- package/src/commands/setup.mjs +51 -14
- package/src/commands/skills.mjs +199 -0
- package/src/commands/sync.mjs +107 -0
- package/src/composition/registry.mjs +27 -0
- package/src/composition/wire.mjs +122 -0
- package/src/contracts/corpus.mjs +35 -0
- package/src/decisions/contracts.d.mts +96 -0
- package/src/decisions/contracts.mjs +321 -0
- package/src/disclosure/content-scan.mjs +13 -0
- package/src/export/atelier-export-contract.mjs +16 -1
- package/src/extension-packs/lifecycle.mjs +154 -0
- package/src/graph/graph.mjs +64 -2
- package/src/graph/knowledge-graph.mjs +548 -24
- package/src/guides/contracts.mjs +54 -0
- package/src/index.mjs +29 -0
- package/src/intake/store.mjs +119 -0
- package/src/preview/channel.mjs +89 -0
- package/src/project/config.mjs +50 -49
- package/src/project/durable-state.mjs +161 -0
- package/src/project/file-class.mjs +166 -0
- package/src/project/private-state.mjs +11 -1
- package/src/projection/obsidian/contracts.mjs +571 -0
- package/src/projection/obsidian/edits/align.mjs +169 -0
- package/src/projection/obsidian/edits/apply.mjs +889 -0
- package/src/projection/obsidian/edits/arbitrate.mjs +414 -0
- package/src/projection/obsidian/edits/contribution.mjs +108 -0
- package/src/projection/obsidian/edits/index.mjs +19 -0
- package/src/projection/obsidian/edits/object-identity.mjs +61 -0
- package/src/projection/obsidian/edits/object-store.mjs +517 -0
- package/src/projection/obsidian/edits/observe.mjs +123 -0
- package/src/projection/obsidian/edits/policy.mjs +123 -0
- package/src/projection/obsidian/edits/regions.mjs +648 -0
- package/src/projection/obsidian/materialize/byte-lens.mjs +98 -0
- package/src/projection/obsidian/materialize/index.mjs +7 -0
- package/src/projection/obsidian/materialize/path-registry.mjs +105 -0
- package/src/projection/obsidian/materialize/prepare-view.mjs +746 -0
- package/src/projection/obsidian/materialize/settings.mjs +57 -0
- package/src/projection/obsidian/materialize/stage.mjs +27 -0
- package/src/projection/obsidian/proposals/adapter.mjs +572 -0
- package/src/projection/obsidian/proposals/backpressure.mjs +113 -0
- package/src/projection/obsidian/proposals/contribution.mjs +55 -0
- package/src/projection/obsidian/proposals/index.mjs +23 -0
- package/src/projection/obsidian/proposals/observation.mjs +216 -0
- package/src/projection/obsidian/proposals/queue.mjs +277 -0
- package/src/projection/obsidian/proposals/router.mjs +168 -0
- package/src/projection/obsidian/publication/bridge-script.mjs +266 -0
- package/src/projection/obsidian/publication/exchange.mjs +117 -0
- package/src/projection/obsidian/publication/index.mjs +5 -0
- package/src/projection/obsidian/publication/publisher.mjs +588 -0
- package/src/projection/obsidian/publication/test-seam.mjs +16 -0
- package/src/projection/obsidian/publication/transport.mjs +139 -0
- package/src/projection/obsidian/recovery/index.mjs +6 -0
- package/src/projection/obsidian/recovery/journal.mjs +134 -0
- package/src/projection/obsidian/recovery/late-writer.mjs +46 -0
- package/src/projection/obsidian/recovery/restart.mjs +316 -0
- package/src/projection/obsidian/recovery/store.mjs +234 -0
- package/src/projection/obsidian/selection-ui/conflict-view.mjs +91 -0
- package/src/projection/obsidian/selection-ui/contribution.mjs +141 -0
- package/src/projection/obsidian/selection-ui/focus.mjs +82 -0
- package/src/projection/obsidian/selection-ui/index.mjs +12 -0
- package/src/projection/obsidian/selection-ui/policy-setup.mjs +107 -0
- package/src/projection/obsidian/selection-ui/receipt.mjs +173 -0
- package/src/projection/obsidian/selection-ui/selection-state.mjs +122 -0
- package/src/projection/obsidian/selection-ui/selection.mjs +109 -0
- package/src/projection/project.mjs +28 -19
- package/src/readiness-protocols/evidence.mjs +282 -0
- package/src/readiness-protocols/runtime.mjs +4 -3
- package/src/readiness-protocols/source-read.mjs +39 -0
- package/src/runtime/git-adapter.mjs +189 -0
- package/src/runtime/local-state.mjs +439 -0
- package/src/runtime/obsidian/app-capability.mjs +110 -0
- package/src/runtime/obsidian/app-production-seams.mjs +96 -0
- package/src/runtime/obsidian/contributions/proposal-adapter.mjs +4 -0
- package/src/runtime/obsidian/contributions/selection-ui.mjs +4 -0
- package/src/runtime/obsidian/contributions/source-apply.mjs +4 -0
- package/src/runtime/obsidian/contributions.mjs +27 -0
- package/src/runtime/obsidian/documents.mjs +37 -0
- package/src/runtime/obsidian/enablement.mjs +53 -0
- package/src/runtime/obsidian/engine.mjs +532 -0
- package/src/runtime/obsidian/errors.mjs +14 -0
- package/src/runtime/obsidian/extension-points.mjs +103 -0
- package/src/runtime/obsidian/index.mjs +34 -0
- package/src/runtime/obsidian/lifecycle.mjs +258 -0
- package/src/runtime/obsidian/machine-settings.mjs +260 -0
- package/src/runtime/obsidian/observation.mjs +158 -0
- package/src/runtime/obsidian/opening.mjs +243 -0
- package/src/runtime/obsidian/pending-edits.mjs +141 -0
- package/src/runtime/obsidian/pipeline.mjs +122 -0
- package/src/runtime/obsidian/private-lock.mjs +156 -0
- package/src/runtime/obsidian/process-identity.mjs +58 -0
- package/src/runtime/obsidian/service-client.mjs +66 -0
- package/src/runtime/obsidian/service-main.mjs +89 -0
- package/src/runtime/obsidian/service-record.mjs +169 -0
- package/src/runtime/obsidian/service-server.mjs +121 -0
- package/src/runtime/obsidian/service.mjs +216 -0
- package/src/runtime/obsidian/startup-adapters.mjs +87 -0
- package/src/runtime/obsidian/state-store.mjs +180 -0
- package/src/runtime/obsidian/sync-notice.mjs +33 -0
- package/src/runtime/obsidian/tick-loop.mjs +84 -0
- package/src/runtime/obsidian/watchers.mjs +36 -0
- package/src/runtime/repository-observation.mjs +491 -0
- package/src/runtime/supervisor.mjs +803 -0
- package/src/server/local-sidecar.mjs +44 -0
- package/src/server/server.mjs +1 -0
- package/src/skills/steward.mjs +780 -0
- package/src/ui/presentation/browser.mjs +278 -0
- package/src/ui/presentation/contract.mjs +116 -0
- package/src/ui/presentation/index.mjs +7 -0
- package/src/ui/presentation/native.mjs +153 -0
- package/src/ui/presentation/proof.mjs +57 -0
- package/src/ui/presentation/schema-check.mjs +30 -0
- package/src/ui/presentation/schema.generated.mjs +1371 -0
- package/src/ui/presentation/state.mjs +43 -0
- package/src/ui/presentation/styles.mjs +67 -0
- package/src/ui/presentation/tokens.mjs +74 -0
- package/src/ui/presentation/web.mjs +85 -0
- package/src/ui/review-page.mjs +32 -0
- package/src/upgrade/explanation.mjs +33 -0
- package/src/upgrade/provenance.mjs +338 -0
- package/src/upgrade/transaction-files.mjs +103 -0
- package/src/upgrade/transaction.mjs +475 -0
- package/src/upgrade/upgrade.mjs +63 -28
- package/src/vault/hosts.mjs +21 -0
- package/src/vault/identity.mjs +19 -0
- package/src/vault/index.mjs +7 -0
- package/src/vault/interface.mjs +11 -0
- package/src/vault/metadata.mjs +31 -0
- package/src/vault/privacy.mjs +148 -0
- package/src/vault/probe.mjs +89 -0
- package/src/vault/service.mjs +131 -0
- package/src/vault/source.mjs +45 -0
- package/src/vault/storage.mjs +23 -0
- package/templates/distribution-workspace/.claude/launch.json +6 -2
- package/templates/distribution-workspace/gitignore +1 -0
- package/templates/distribution-workspace/project/README.md +2 -2
- package/templates/external-project-workspace/README.md +45 -0
- package/templates/external-project-workspace/answers.example.json +4 -0
- package/templates/external-project-workspace/atelier.project.json +40 -0
- package/templates/external-project-workspace/boundary-policy.v1.json +60 -0
- package/templates/external-project-workspace/gitignore +8 -0
- package/templates/external-project-workspace/pack-lifecycle.json +14 -0
- package/templates/external-project-workspace/packs/protocols/contract-gate.v1.json +109 -0
- package/templates/external-project-workspace/packs/sample-pack.v1.json +48 -0
- package/templates/external-project-workspace/repo-access.v1.json +9 -0
- package/templates/external-project-workspace/source/README.md +11 -0
- package/templates/launch.json +6 -2
- package/templates/private-domain-workspace/.claude/launch.json +6 -2
- package/templates/private-domain-workspace/domain/README.md +2 -2
- package/templates/private-domain-workspace/gitignore +1 -0
- package/templates/shared-project-workspace/.claude/launch.json +6 -2
- package/templates/shared-project-workspace/gitignore +1 -0
- 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
|
+
}
|