From b121431e00cd429e02cacdd0a966135c11451faa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:27:33 +0900 Subject: [PATCH 01/19] feat(review): add provider-neutral revision contract --- README.md | 4 +- docs/CONTRACTS.md | 4 +- docs/DATA_MODEL.md | 2 + docs/DOCUMENTATION_FITNESS.md | 1 + docs/OPERABILITY.md | 8 + docs/PRD.md | 8 +- docs/README.md | 1 + docs/TEST_STRATEGY.md | 5 + docs/THREAT_MODEL.md | 11 + docs/TRACEABILITY.md | 1 + docs/TRD.md | 19 + docs/UML.md | 30 ++ .../0027-provider-neutral-review-contract.md | 86 ++++ docs/adr/README.md | 1 + docs/package-distribution.md | 6 +- docs/review-contract.md | 85 ++++ ...revision-evidence-consumer-config.test.mjs | 5 +- ...amework-free-revision-evidence-package.mjs | 5 +- scripts/verify-review-package.mjs | 82 +++- scripts/verify-revision-evidence-package.mjs | 11 +- src/canonicalProductDocumentation.test.ts | 2 + src/collaboration/CollaborativeCwlEditor.tsx | 8 +- src/components/CwlEditor.review.test.tsx | 435 +++++++++++++++++ src/components/CwlEditor.tsx | 8 +- src/components/EditorFrame.tsx | 42 +- src/components/ReviewPanel.fixture.test.tsx | 42 ++ src/components/ReviewPanel.fixture.tsx | 83 ++++ src/components/ReviewPanel.test.tsx | 146 ++++++ src/components/ReviewPanel.tsx | 110 +++++ src/components/reviewOperations.ts | 298 ++++++++++++ src/components/useEditorHandle.ts | 46 +- src/index.ts | 28 ++ src/releaseBrowserEvidence.test.ts | 5 + src/review/contract.test.ts | 366 ++++++++++++++ src/review/contract.ts | 459 ++++++++++++++++++ src/review/index.ts | 30 ++ src/review/package.test.ts | 57 +++ src/sourceTextHygiene.test.ts | 4 + src/styles.css | 95 ++++ src/types.ts | 32 ++ tests/browser/harness.ts | 94 ++++ tests/browser/playwright.config.ts | 4 +- tests/browser/specs/review.browser.spec.ts | 58 +++ ...verify-framework-free-autosave-package.mjs | 5 +- vite.review.config.ts | 9 +- 45 files changed, 2799 insertions(+), 42 deletions(-) create mode 100644 docs/adr/0027-provider-neutral-review-contract.md create mode 100644 docs/review-contract.md create mode 100644 src/components/CwlEditor.review.test.tsx create mode 100644 src/components/ReviewPanel.fixture.test.tsx create mode 100644 src/components/ReviewPanel.fixture.tsx create mode 100644 src/components/ReviewPanel.test.tsx create mode 100644 src/components/ReviewPanel.tsx create mode 100644 src/components/reviewOperations.ts create mode 100644 src/review/contract.test.ts create mode 100644 src/review/contract.ts create mode 100644 src/review/package.test.ts create mode 100644 tests/browser/specs/review.browser.spec.ts diff --git a/README.md b/README.md index 42ea48ad..7fee929b 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ runtime. | Envelope identity | `@contextualwisdomlab/cwl-editor/envelope-identity` | Framework-independent bounded schema identity for host-owned migration routing | | Revision evidence | `@contextualwisdomlab/cwl-editor/revision-evidence` | Framework-independent canonical envelope, strong revision, and transition evidence | | Text-position selector | `@contextualwisdomlab/cwl-editor/text-position-selector` | React-free deterministic W3C `TextPositionSelector` projection core | -| Review target core | `@contextualwisdomlab/cwl-editor/review` | `implemented_on_active_pr` — React-free deterministic exact-revision review targets; durable review records and policy remain host-owned | +| Review | `@contextualwisdomlab/cwl-editor/review` | `implemented_on_active_pr` — React-free exact-revision targets plus bounded insert/delete suggestions and operation results; durable review records and policy remain host-owned | | Autosave | `@contextualwisdomlab/cwl-editor/autosave` | Provider-neutral bounded single-flight persistence coordination | | Headless Markdown | `@contextualwisdomlab/cwl-editor/markdown` | React-free deterministic Markdown/HTML/email/plain-text conversion | | Styles | `@contextualwisdomlab/cwl-editor/styles.css` | Editor layout and theming | @@ -728,4 +728,4 @@ capabilities they require. - **Fonts:** Noto Sans families are SIL Open Font License 1.1. See [`LICENSE`](LICENSE), [`src/fonts/OFL.txt`](src/fonts/OFL.txt), and -[`src/fonts/NOTICE`](src/fonts/NOTICE). \ No newline at end of file +[`src/fonts/NOTICE`](src/fonts/NOTICE). diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 52ab1bdf..2625ed1d 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -16,7 +16,7 @@ The root package may expose React/TipTap integration plus framework-independent Package exports, declaration files, runtime support, and packed ESM/CommonJS/strict-TypeScript consumers are release evidence. A source file being present is not sufficient proof that a public contract is packaged correctly. -Protected `main` includes the React-free `@contextualwisdomlab/cwl-editor/text-position-selector` subpath for deterministic W3C selector projection. The root editor surface remains the authority for capturing one immutable editor state and binding that projection to an exact document revision. +Protected `main` includes the React-free `@contextualwisdomlab/cwl-editor/text-position-selector` subpath for deterministic W3C selector projection. The active review line adds the framework-independent `@contextualwisdomlab/cwl-editor/review` contract for bounded revision-scoped targets and deterministic insert/delete operation results; it is not protected-main implementation authority until merged and reverified. The root editor surface remains the authority for capturing one immutable editor state and binding that projection to an exact document revision. ## Document envelope and revision contract @@ -71,6 +71,8 @@ The host owns annotation identifiers/bodies, source-resource IRI policy, authent ADR 0018 is the durable authority decision. `docs/selection-lifecycle.md` and `docs/doctoring/w3c-text-position-selector-evidence.md` record the operator-facing semantics and APA-7 standards basis. Packed ESM/CommonJS/strict-TypeScript consumers verify both the root public API and the protected React-free `@contextualwisdomlab/cwl-editor/text-position-selector` subpath; no database, network, provider credential, model, naruon, or contextual-orchestrator dependency is introduced by the projection subpath or evidence operation. +The active review contract keeps comment bodies, durable thread/suggestion identity, authorization, persistence, audit, collaboration-provider state, and cross-revision re-anchoring with the host. Inkspan validates only bounded immutable revision/selector/projection metadata and deterministic insert/delete lifecycle/transition semantics. `docs/review-contract.md` and ADR 0027 define the proposed editor integration and its fail-closed stale-revision boundary; active-PR evidence must not be described as shipped behavior. + ## Autosave contract Autosave is a local coordination surface. It provides bounded single-flight ordering, bounded pending work, explicit blocked/conflict/failure state, optional lifecycle observation, and host callback handoff. The host performs authenticated transport and durable persistence. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 28b86bc5..553c7817 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -48,6 +48,7 @@ erDiagram - `document_transition`: previous/resulting revision pair plus changed classification. It deliberately omits the document body from ordinary evidence. - `selection_evidence`: ProseMirror structural coordinates bound to one exact revision. It is a local evidence value, not a durable cross-revision anchor. - `text_position_selector_evidence`: `implemented_on_protected_main` as a frozen revision-scoped W3C `TextPositionSelector` plus explicit `inkspan-prosemirror-text` projection identity. It satisfies `0 <= start <= end <= projectedCodePointLength`; inclusive `start` and exclusive `end` count Unicode code points, boundaries are grapheme-validated, and ordinary evidence contains no selected quote text. It is not a durable cross-revision anchor, annotation identity, authorization record, timestamp, signature, or persistence receipt. +- `review_target` / `review_suggestion`: `implemented_on_active_pr` under Proposed ADR 0027. These are bounded revision-scoped target and insert/delete lifecycle values; operation results contain only revisions and compact transition evidence. They do not contain comment bodies or transfer host identity, authorization, persistence, collaboration, audit, or cross-revision re-anchoring authority. - `autosave_revision`: detached immutable revision evidence accepted by the local single-flight autosave coordinator. - `autosave_snapshot`: frozen document-free queue/session lifecycle metadata such as idle/saving/blocked/closing/closed and bounded pending state. The explicit in-process snapshot may also carry the bounded active/pending/last-saved strong-validator fields defined by the autosave contract; those fields are confidential local concurrency metadata rather than generic telemetry. - `clipboard_policy`: bounded local policy describing the supported semantic rich-paste boundary. It grants no host network or tenant authority. @@ -86,6 +87,7 @@ These values may remain ephemeral or release-artifact metadata. Their presence i | `document_transition` | none required; host may store | change evidence | no | content-lineage evidence only | | `selection_evidence` | none required | review/selection capture | no | exact-revision ProseMirror coordinates only | | `text_position_selector_evidence` | none required; `implemented_on_protected_main` | interoperable review/annotation capture | no | exact-revision W3C text positions satisfying `0 <= start <= end <= projectedCodePointLength` under one versioned projection only | +| `review_target` / `review_suggestion` | none required; `implemented_on_active_pr` | inline review and deterministic suggestion operation | no | bounded revision-scoped metadata and local transition evidence only; host owns bodies, identity, authorization, persistence, audit, and re-anchoring | | `autosave_revision` | none required | queued local save evidence | envelope-bearing evidence may be retained boundedly by queue | local save ordering only | | `autosave_snapshot` | none required | lifecycle observation/coordination | no | local machine state only; validator fields remain confidential metadata | | `durable_validator` | host | durable version | no | host concurrency evidence, not authorization | diff --git a/docs/DOCUMENTATION_FITNESS.md b/docs/DOCUMENTATION_FITNESS.md index cdb52e18..8fcdda96 100644 --- a/docs/DOCUMENTATION_FITNESS.md +++ b/docs/DOCUMENTATION_FITNESS.md @@ -50,6 +50,7 @@ Document fitness and implementation maturity are independent. A `present_current | Accessible editor placeholder semantics | accessibility guidance/doctoring and protected editor source from #131 | `present_current` | `implemented_on_protected_main` | Visual placeholder guidance and `aria-placeholder` remain synchronized without making placeholder text an accessible name or persistent document value. | | Revision-scoped selection evidence | selection lifecycle guide, doctoring and protected public handle/type contract | `present_current` | `implemented_on_protected_main` | Atomic selection+revision evidence, privacy minimization and host-owned re-anchoring are reconstructable. | | W3C text-position selector evidence / React-free text-position selector subpath | ADR 0018, protected handle/type contract, package subpath, selector implementation/verifier and doctoring | `present_current` | `implemented_on_protected_main` | ADR 0018 makes Unicode-code-point offsets, grapheme-boundary fail-closed semantics, projection identity, same-state revision binding, React-free text-position selector subpath reuse and text-free evidence reconstructable without confusing W3C offsets with ProseMirror positions. | +| Provider-neutral review contract / React-free review subpath | Proposed ADR 0027, `docs/review-contract.md`, package subpath, core tests and packed verifier | `present_current` | `implemented_on_active_pr` | Bounded revision-scoped targets and deterministic insert/delete transition semantics are reconstructable; editor UI, browser accessibility, durable comments, collaboration, and re-anchoring remain unshipped acceptance work. | | Document-transition evidence | transition doctoring, public framework-independent contract and protected revision-evidence package | `present_current` | `implemented_on_protected_main` | Previous/resulting revision lineage is reconstructable without implying actor/time/durable-write provenance. | | Envelope schema identity / migration routing | ADR 0015, PRD, TRD, DATA_MODEL, envelope guide/doctoring and protected identity-routing source | `present_current` | `implemented_on_protected_main` | Bounded schema identification remains separate from host-owned migration execution and persistence. | | Cross-engine browser-semantic release assurance | ADR 0016, doctoring, TEST_STRATEGY, OPERABILITY, TRACEABILITY and protected browser-evidence workflows | `present_current` | `implemented_on_protected_main` | SafeClipboard is also `implemented_on_protected_main`; Chromium/Firefox/WebKit evidence, exact source/lock/browser identity, packed-artifact digest binding and fail-closed divergence are protected release authority. | diff --git a/docs/OPERABILITY.md b/docs/OPERABILITY.md index e96d053c..fa45ecea 100644 --- a/docs/OPERABILITY.md +++ b/docs/OPERABILITY.md @@ -10,6 +10,14 @@ This distinction controls incident ownership: an Inkspan incident is a determini Protected `main` is the shipped implementation authority. Open PRs are not operational authority until protected integration. +The active review contract is an operationally bounded proposal. When its +editor adapter is enabled, hosts must treat `stale` results as a refetch/review +decision, not as permission to re-anchor or overwrite. Host callbacks must +record durable thread/suggestion outcomes in the host system; Inkspan's local +transition evidence is not a durable audit event. Review UI failure must leave +the document editable or explicitly read-only according to host policy without +inventing acceptance or rejection. + ## Health and evidence Inkspan itself does not expose a network health endpoint. Operational health is proven through exact-head CI and package-consumer evidence, deterministic runtime outcomes, bounded public errors, security scans, package/release verification, and host-observable local state. diff --git a/docs/PRD.md b/docs/PRD.md index 899086f3..76aba20b 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -58,6 +58,7 @@ The product promise is: **author, convert, collaborate, and prove document chang - The producer derives the range from one valid ordered ProseMirror `Selection`; it does not accept arbitrary caller-supplied selector numbers. Any impossible emitted range violation is an internal defect and must not be normalized or published. - ProseMirror structural positions and W3C text positions are distinct coordinate systems even when numeric values happen to match for a simple document. - Text-position evidence remains revision-scoped and text-free. Hosts own annotation identifiers/bodies, source-resource identifiers, publication, durable persistence, authorization, tenant policy, and cross-revision re-anchoring. +- **Active PR / Proposed review mode** adds bounded revision-scoped comment targets and deterministic insert/delete suggestions. Accept must change the document, reject must preserve the revision, and stale targets fail closed without implicit re-anchoring. The editor panel, callbacks, browser evidence, and full acceptance slice remain active work and are not protected-main product claims. - Autosave remains single-flight with bounded active/pending work and explicit conflict/failure recovery. - Durable saves use a host/server-selected strong validator; conflict or ambiguous failure never silently advances it. - Lifecycle observation emits only distinct externally visible document-free state transitions; construction and no-op operations do not manufacture notifications. @@ -105,7 +106,7 @@ The product promise is: **author, convert, collaborate, and prove document chang ## Non-goals -Inkspan is not an identity provider, tenant database, durable document store, collaboration authorization server, deployment platform, credential manager, retention engine, model router, durable audit service, application migration owner, annotation database, cross-revision annotation re-anchoring service, or merge/release authority for host products. +Inkspan is not an identity provider, tenant database, durable document store, collaboration authorization server, deployment platform, credential manager, retention engine, model router, durable audit service, application migration owner, annotation database, cross-revision annotation re-anchoring service, or merge/release authority for host products. The active review contract does not change those non-goals. Inkspan does not promise universal HTML/Office round-trip fidelity, arbitrary executable document content, implicit network fetching, model-generated content as trusted source, silent migration of unknown document schemas, a security bounty, legal safe harbor, fixed vulnerability-response SLA, or certification from repository documentation alone. @@ -146,3 +147,8 @@ Shareable acquisition evidence excludes production tenant content and credential Protected `main` is the sole implemented baseline. Open PRs may describe Proposed or Active work but are not shipped contracts until protected integration. Canonical documentation must state when a requirement is target architecture rather than current implementation. SafeClipboard, real Chromium/Firefox/WebKit release assurance, lifecycle observation, the root security disclosure lifecycle, toolbar shortcut accessibility metadata, SSR/native-form serialization, revision-scoped selection evidence, W3C text-position selector evidence, document-transition evidence, and envelope identity migration routing are implemented on protected `main`. + +The revision-scoped review contract and `@contextualwisdomlab/cwl-editor/review` +subpath are `implemented_on_active_pr` under Proposed ADR 0027. The interactive +review panel, editor transaction integration, Storybook/equivalent states, and +cross-engine interaction evidence are not shipped until protected integration. diff --git a/docs/README.md b/docs/README.md index 2b617f7c..fd922ecf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ This directory is the discoverable index for Inkspan's product, technical, secur | [`TRD.md`](TRD.md) | Technical invariants, runtime boundaries, failure semantics and release evidence | | [`CONTRACTS.md`](CONTRACTS.md) | Public package/API/event/schema/plugin/collaboration and host-integration contracts | | [`package-distribution.md`](package-distribution.md) | Buyer-facing public npm package entrypoints, packaged contents, runtime dependency boundaries, and consumer verification | +| [`review-contract.md`](review-contract.md) | Active-PR provider-neutral review contract, ownership boundary, and research basis | | [`email-output.md`](email-output.md) | Deterministic email fragment/full-document authority, language/direction metadata, accessibility and host-owned transport boundary | | [`print-output.md`](print-output.md) | Browser print/paged-media presentation, accessibility/fidelity limits, host-owned governed-export boundary, and rollback | | [`UML.md`](UML.md) | Component, sequence, state and authority-flow diagrams | diff --git a/docs/TEST_STRATEGY.md b/docs/TEST_STRATEGY.md index 34e903fc..45cbf895 100644 --- a/docs/TEST_STRATEGY.md +++ b/docs/TEST_STRATEGY.md @@ -55,6 +55,11 @@ At minimum, maintain regressions for: - SSR client-controlled form values, escaping, hydration continuity, reset behavior, and absence of server editor construction; - autosave stale validators, conflict/failure recovery, ambiguous transport outcomes, duplicate/no-op lifecycle transitions, callback exceptions, queue bounds, flush/close behavior, and durable-validator coherence; - selection/revision races and document movement during asynchronous hashing; +- review target/suggestion accessors and exotic prototypes, unsupported + projections, bounded text/counts, exact-once accept/reject semantics, + stale-revision fail-closed behavior, undo/redo, keyboard/screen-reader + review-panel states, print output, and packed `./review` ESM/CommonJS/strict + TypeScript consumers; - Office formula prefixes, invalid XML characters, malicious strings, path/publication races, invalid worksheet names, invalid freeze panes, cyclic input, pathological nesting, excessive container size, and partial write failure; - package/release stale draft assets, unexpected or non-regular local entries, exact three-file inventory violations, incomplete remote uploads, GitHub-vs-local digest mismatch, stale exact-head evidence, mutable provenance inputs, and isolated packed-consumer behavior. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index e1337651..ace82200 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -53,6 +53,17 @@ Local SHA-256 revisions identify deterministic content equality only. Selection Concurrent editors, delayed digests, stale selections, ambiguous transport failure, or stale durable validators can cause lost updates or false success. Inkspan must bind asynchronous evidence to one immutable local state, keep autosave single-flight with bounded pending work, fail closed on ambiguous durable outcomes, and require explicit recovery from blocked conflict/failure states. Hosts own atomic persistence transactions and durable conflict resolution. +### Review targets, suggestions, and comment metadata + +Host-supplied thread/suggestion values can contain accessors, exotic +prototypes, oversized identifiers/text, unsupported projections, stale +revisions, or attempts to smuggle comment bodies into evidence. The active +review contract validates plain bounded records, binds operations to the exact +revision, requires accept to change and reject to preserve the revision, and +returns stale rather than re-anchoring. It carries no body, credential, +authorization, durable-write, or cross-revision anchor claim; hosts must still +authorize and persist their own records. + ### Collaboration and Yjs Inkspan may bind to Yjs-compatible document/awareness surfaces but does not own provider creation, room authorization, tenant identity, persistence, retention, or durable audit. Yjs updates and awareness metadata can contain sensitive tenant information. Host providers must authenticate rooms, authorize membership, bound awareness disclosure, and apply retention/encryption policy. Inkspan must not silently create a network provider or elevate an awareness update into authorization. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index d120cec7..8c535ffb 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -15,6 +15,7 @@ This record maps durable Inkspan product decisions to authoritative standards, p | Envelope version routing | A bounded identity-only inspector identifies `schemaId`/`schemaVersion` for dispatch while the current parser stays strict and the host owns migration execution | RFC 8259; RFC 7493; RFC 8785 for canonical current-schema bytes | ADR 0015, protected-main `documentEnvelopeIdentity` implementation/tests, envelope guide/doctoring and framework-independent packed consumers | Protected-main evidence proves only bounded routing metadata; identifying a schema generation does not validate that generation's document semantics, authorize migration, or prove durable persistence | | Canonical document bytes | Deterministic revision evidence is derived from canonicalized validated document content | RFC 8785, JSON Canonicalization Scheme | revision-evidence, transition-evidence, restore tests | A content digest proves equality only, not actor/time/authorization/durable write | | W3C text-position selector | Revision-scoped annotation interoperability uses a distinct versioned logical-text projection satisfying `0 <= start <= end <= projectedCodePointLength`, with inclusive `start`, exclusive `end`, Unicode-code-point offsets, grapheme-boundary validation, and same-state revision binding instead of relabeling ProseMirror coordinates | W3C Web Annotation Data Model; ProseMirror reference manual; ECMA-402 13th edition | ADR 0018, protected-main text-position selector implementation/tests, packed consumer verifier, selection lifecycle and doctoring | Protected-main evidence proves positions only for the named projection and exact revision; it does not prove actor, authorization, durable annotation acceptance, source IRI policy, or cross-revision re-anchoring | +| Provider-neutral review contract | Active-PR v1 bounds revision-scoped thread/suggestion targets and deterministic insert/delete operation results; accept changes, reject preserves, stale fails closed, and bodies/authorization/persistence remain host-owned | W3C Web Annotation Data Model; official Tiptap Comments and CKEditor 5 Track Changes documentation | Proposed ADR 0027, `docs/review-contract.md`, `src/review/contract.ts` tests, packed `./review` consumer verifier | `implemented_on_active_pr`; core package evidence does not prove shipped editor UI, browser accessibility, durable comments, collaboration, or cross-revision re-anchoring | | Headless deterministic Markdown conversion | One serializer implementation and one framework-neutral safe-link/inline-raster policy are exposed through a self-contained ESM/CommonJS/TypeScript `./markdown` subpath | CommonMark 0.31.2; Node.js package `exports` documentation | protected-main #114 implementation, packed Node consumers, package-distribution contract, `docs/doctoring/headless-markdown-package.md` | `implemented_on_protected_main`; deterministic conversion does not grant MIME delivery, recipient, auth, tenant, persistence, network, credential, or model authority | | Provenance semantics | Local transition/release evidence keeps content lineage separate from actor/authorization/durable claims | W3C PROV family | transition evidence, release evidence, canonical data model | Inkspan does not claim complete PROV conformance or host audit provenance | | Accessibility | Native controls, keyboard semantics, shortcut metadata, semantic placeholder guidance, and host-facing status state support accessible embedding | W3C WCAG 2.2; WAI-ARIA 1.2 where used | protected toolbar/accessibility tests, SSR tests, autosave lifecycle data, protected #131 placeholder tests/packed consumer and `docs/doctoring/editor-placeholder-accessibility.md` | Component evidence alone is not a full host WCAG conformance claim; `aria-placeholder` supplements but never replaces the accessible name | diff --git a/docs/TRD.md b/docs/TRD.md index 80cec07a..1168de27 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -54,6 +54,25 @@ Selection, transition, and W3C text-position evidence are implemented on protect The React-free text-position-selector package surface is also protected-main behavior. It reuses the deterministic projection helper without claiming editor state capture, annotation identity, authorization, durable persistence, or cross-revision re-anchoring. Hosts retain those authorities. +## Provider-neutral review contract + +The active review line adds Proposed ADR 0027 and the React-free `./review` +subpath. Its v1 core validates bounded host-supplied thread/suggestion metadata +against one immutable SHA-256 revision and the named W3C logical-text +projection. Insert targets are empty ranges; delete targets are non-empty +ranges. Accessors, exotic prototypes, unsupported projections, malformed +revisions, and invalid lifecycle states fail closed. + +The planned editor adapter must apply accept/reject through the real +ProseMirror transaction path, bind the before/after envelopes to transition +evidence, and return `stale` when the expected revision no longer matches. An +accepted operation must change the document; a rejected operation must preserve +the revision. Comment bodies, durable identifiers, authorization, persistence, +notifications, collaboration providers, audit, and cross-revision re-anchoring +remain host-owned. The active package is not protected-main behavior until its +editor, accessibility, browser, print, Storybook/equivalent, and packed-consumer +acceptance evidence is current at the exact merge head. + ## Autosave state machine and durable concurrency States are `idle`, `saving`, `blocked`, `closing`, and `closed`, with explicit blocked reasons. The local queue remains single-flight and retains bounded active/pending work and bounded flush waiters. Evidence supplied to a callback is immutable and validated before scheduling. diff --git a/docs/UML.md b/docs/UML.md index 5b67b163..8721360f 100644 --- a/docs/UML.md +++ b/docs/UML.md @@ -335,6 +335,36 @@ sequenceDiagram ProseMirror structural positions and W3C text positions are distinct coordinate systems. The selector remains valid only for the exact revision and named projection. Inkspan does not include selected quote text in ordinary evidence and does not own annotation identifiers/bodies, source-resource IRI policy, authentication, authorization, tenancy, durable persistence, audit, publication, or cross-revision re-anchoring. ADR 0018 governs this protected-main authority. +## Provider-neutral review operation (Active PR / Proposed) + +```mermaid +sequenceDiagram + participant Host + participant Review as Review contract + participant Editor as Immutable editor state + participant Revision as Transition evidence + + Host->>Review: bounded thread/suggestion metadata + Review->>Review: validate revision, selector, projection, and lifecycle + Host->>Editor: accept or reject pending suggestion + Editor->>Editor: capture exact current state + alt expected revision is stale + Editor-->>Host: stale result; no re-anchor or mutation + else accept + Editor->>Editor: deterministic ProseMirror transaction + Editor->>Revision: bind previous/resulting envelopes + Revision-->>Host: accepted + revision-only transition evidence + else reject + Editor->>Revision: verify unchanged resulting envelope + Revision-->>Host: rejected + unchanged revision evidence + end + Host->>Host: authorize, persist, audit, notify, and re-anchor by host policy +``` + +The review package and this flow are active-PR evidence only. Comment bodies, +durable identity, provider lifecycle, and cross-revision re-anchoring remain +outside Inkspan. + ## Provider-neutral Yjs collaboration sequence ```mermaid diff --git a/docs/adr/0027-provider-neutral-review-contract.md b/docs/adr/0027-provider-neutral-review-contract.md new file mode 100644 index 00000000..f40f54c7 --- /dev/null +++ b/docs/adr/0027-provider-neutral-review-contract.md @@ -0,0 +1,86 @@ +# ADR 0027: Provider-neutral revision-scoped review contract + +- Status: Proposed +- Date: 2026-08-20 +- Scope: Inkspan JavaScript package and editor integration + +## Context + +Reviewers need inline targets, threads, and suggestions while hosts retain +identity, authorization, durable comments, collaboration, and persistence. +W3C text-position selectors provide interoperable range semantics but are +fragile when the underlying revision changes. Commercial editor products expose +review panels and individually accept/rejectable changes, but their hosted +services are outside Inkspan's provider-neutral boundary. + +## Alternatives considered + +1. Add a database-backed annotation and comments service to Inkspan. +2. Depend on a vendor comments or track-changes extension as the public model. +3. Expose a bounded revision-scoped contract and a controlled React adapter, + leaving durable records and provider integration to the host. + +## Decision + +Choose alternative 3. Version the core as +`https://inkspan.io/schemas/review/v1`. Validate immutable revision, selector, +projection, bounded IDs/counts/text, and insert/delete shape. Accept and reject +are exact-once local operations: accept must change the document, reject must +not, and an expected-revision mismatch returns `stale` without re-anchoring. +Return only revision and transition evidence; never return comment or document +bodies from the operation-result contract. + +## Consequences + +Inkspan gains a reusable React-free package surface and a deterministic editor +integration seam. Hosts supply thread/suggestion metadata, authorization, +durable storage, callbacks, notification policy, collaboration lifecycle, +audit, and cross-revision re-anchoring. The active review package must remain +usable without React, TipTap view, Yjs, network, credentials, or a database. + +## Failure and recovery + +Malformed or unsupported metadata fails closed with redacted error codes. A +stale revision returns a stale result so the host can refetch and decide what to +show. An invalid accept/reject revision transition is an error; the editor must +not mutate the document or mark the host record final. Undo/redo is delegated to +the editor transaction history after a successful local operation. + +## Security and privacy impact + +The contract bounds hostile input and rejects accessors/exotic objects. It does +not authorize an actor, tenant, thread, or suggestion. It carries no comment +body, selected quote, credential, network handle, or durable audit claim. + +## Compatibility and migration + +The `v1` schema and projection identity are explicit. Unknown review schema or +projection versions fail closed. Hosts own storage migrations and may retain +older records, but migrated values must pass the current validator before the +editor displays or operates on them. + +## Verification + +The active branch must provide core 100% statement/branch/function/line +coverage, strict TSDoc, packed ESM/CommonJS/TypeScript consumers, React editor +integration tests, keyboard and assistive metadata tests, Chromium/Firefox/ +WebKit interaction coverage, print-state coverage, and a rendered Storybook or +equivalent state fixture. Review/check evidence must be bound to the exact +current head before merge; active-PR evidence is not shipped behavior. + +## Rollback or supersession + +Before protected merge, delete or revise the active package surface and its +documentation if the acceptance contract changes. After acceptance, supersede +this ADR with a versioned decision before changing selector, lifecycle, or +ownership semantics. A host may disable the optional review UI without changing +the document-envelope or text-position contracts. + +## References + +World Wide Web Consortium. (2017, February 23). *Web Annotation Data Model*. +https://www.w3.org/TR/annotation-model/ + +CKSource. (n.d.). *Track changes overview*. CKEditor 5 documentation. Retrieved +August 20, 2026, from +https://ckeditor.com/docs/ckeditor5/latest/features/collaboration/track-changes/track-changes.html diff --git a/docs/adr/README.md b/docs/adr/README.md index df8b7b80..d2f736b7 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -32,6 +32,7 @@ This index records durable architectural decisions. Protected-main implementatio | [0024](0024-bounded-docx-paragraph-alignment.md) | Accepted | Bounded paragraph alignment in deterministic DOCX output | | [0025](0025-bounded-docx-heading-alignment.md) | Accepted | Bounded heading alignment in deterministic DOCX output | | [0026](0026-bounded-docx-external-hyperlinks.md) | Accepted | Bounded external hyperlinks in deterministic DOCX rich text | +| [0027](0027-provider-neutral-review-contract.md) | Proposed | Provider-neutral revision-scoped review contract | ## Decision discipline diff --git a/docs/package-distribution.md b/docs/package-distribution.md index dfbf032e..5258d5fd 100644 --- a/docs/package-distribution.md +++ b/docs/package-distribution.md @@ -17,7 +17,7 @@ integrations. | `@contextualwisdomlab/cwl-editor/envelope-identity` | Framework-independent identity-only envelope routing for bounded schema identity inspection; migration remains host-owned | | `@contextualwisdomlab/cwl-editor/revision-evidence` | Framework-independent revision evidence and document-transition evidence for local content equality/lineage claims | | `@contextualwisdomlab/cwl-editor/text-position-selector` | `implemented_on_protected_main` — React-free text-position projection core implementing W3C `TextPositionSelector`; interactive capture, revision binding, authorization, persistence, and re-anchoring remain outside this subpath | -| `@contextualwisdomlab/cwl-editor/review` | `implemented_on_active_pr` — React-free deterministic review-target validation bound to exact local SHA-256 revision evidence plus Inkspan's W3C text-position projection; durable review records and policy remain host-owned | +| `@contextualwisdomlab/cwl-editor/review` | `implemented_on_active_pr` — framework-independent exact-revision targets, bounded insert/delete suggestions, and revision-only operation results; editor UI and host-owned comment persistence remain outside this subpath | | `@contextualwisdomlab/cwl-editor/markdown` | `implemented_on_active_pr` — headless deterministic Markdown/HTML/email/plain-text conversion with the same safe-link and strict inline-raster policies as the editor, without importing the React/TipTap editor graph | | `@contextualwisdomlab/cwl-editor/styles.css` | Editor layout and theming | | `@contextualwisdomlab/cwl-editor/fonts.css` | Full offline KR/EN/JP/SC/TC/VI font bundle | @@ -91,6 +91,10 @@ embedded in the npm tarball. actor identity, authorization, tenant boundary, persistence, retention, notification, audit occurrence, collaboration-provider admission, or cross-revision re-anchoring authority. Hosts retain all of those duties. +- The review subpath is a bounded provider-neutral contract for revision-scoped + targets, insert/delete suggestion states, and compact operation results. It + contains no comment bodies, host authorization, database, network, provider + credentials, collaboration transport, or durable persistence authority. - Envelope identity output is routing metadata only. It does not accept an unsupported document generation as current semantics and does not move schema registry, migration, persistence, rollback, or authorization authority into diff --git a/docs/review-contract.md b/docs/review-contract.md new file mode 100644 index 00000000..e898b13c --- /dev/null +++ b/docs/review-contract.md @@ -0,0 +1,85 @@ +# Provider-neutral review contract + +Status: Active PR / Proposed; not protected-main implementation authority + +## Scope + +The active review line adds a framework-independent `review` package surface and +an eventual controlled editor integration for inline comments, thread state, and +deterministic insert/delete suggestions. This document describes the target +contract; only the packed core contract and its source tests are evidence on the +current branch. + +Inkspan owns deterministic projection interpretation, bounded validation, +revision binding, exact-once local operation state, and compact before/after +transition evidence. The host owns comment bodies, durable thread and +suggestion records, identity, authorization, tenant isolation, persistence, +notifications, collaboration-provider lifecycle, audit, and cross-revision +re-anchoring. + +## Versioned core + +`@contextualwisdomlab/cwl-editor/review` exposes +`https://inkspan.io/schemas/review/v1`. A target contains one immutable SHA-256 +document revision, the existing W3C `TextPositionSelector` shape, and the +`inkspan-prosemirror-text` projection identity. Insert suggestions require an +empty range; delete suggestions require a non-empty range. Identifiers, text, +thread counts, and reply counts are bounded. Accessors, exotic prototypes, +unsupported projections, malformed revisions, and invalid state transitions +fail closed with redacted error codes. + +Accepting a pending suggestion must change the editor document. Rejecting one +must preserve the document revision. If the observed revision differs from the +suggestion's expected revision, the operation returns `stale` without silently +re-anchoring. Operation results contain revisions and compact transition +evidence, never document or comment bodies. + +The projection helper is intentionally not an editor transaction. The editor +integration must capture one immutable revision, apply a deterministic +ProseMirror transaction, and bind the resulting envelope pair through the +operation-result helper. Host persistence remains outside this contract. + +## Interactive target + +The planned React surface accepts host-supplied thread/suggestion metadata and +host callbacks for refresh, operation result handling, and failures. Inline +markers and a review panel must expose state without color alone, support +keyboard navigation and screen-reader names, remain usable at narrow/mobile +widths, and hide non-document controls in print output. Stale operations are +disabled or reported as stale; they are never locally re-anchored. + +The acceptance slice must include exact-once accept/reject behavior, undo/redo, +concurrent stale protection, keyboard and Chromium/Firefox/WebKit checks, +packed consumers, a React-free core, and full statement/branch/function/line +coverage plus public TSDoc. The repository-rendered equivalent fixture is +`src/components/ReviewPanel.fixture.tsx`, with pending insert/delete, resolved, +and empty states covered by `ReviewPanel.fixture.test.tsx`. The pinned +cross-engine browser harness exercises the actual panel through +`tests/browser/specs/review.browser.spec.ts`. No Figma artifact is claimed +because no Figma file was used for this implementation. + +## Research and standards basis + +The W3C model treats a text-position selector as an inclusive-start, +exclusive-end range and warns that position selectors are brittle when the +underlying resource changes; Inkspan therefore binds the selector to an exact +revision and refuses implicit re-anchoring (World Wide Web Consortium, 2017). +Current editor products demonstrate the buyer expectation of sidebar/inline +review surfaces and individually accept/rejectable suggestions, but their +server, comments, and collaboration services are not Inkspan dependencies +(CKSource, n.d.; Tiptap, n.d.-a, n.d.-b). + +### References + +CKSource. (n.d.). *Track changes overview*. CKEditor 5 documentation. Retrieved +August 20, 2026, from +https://ckeditor.com/docs/ckeditor5/latest/features/collaboration/track-changes/track-changes.html + +Tiptap. (n.d.-a). *Integrate comments into your editor*. Retrieved August 20, +2026, from https://tiptap.dev/docs/editor/extensions/functionality/comments + +Tiptap. (n.d.-b). *Comments*. Retrieved August 20, 2026, from +https://tiptap.dev/docs/comments/getting-started/overview + +World Wide Web Consortium. (2017, February 23). *Web Annotation Data Model*. +https://www.w3.org/TR/annotation-model/ diff --git a/scripts/revision-evidence-consumer-config.test.mjs b/scripts/revision-evidence-consumer-config.test.mjs index ccc0cfac..17f31546 100644 --- a/scripts/revision-evidence-consumer-config.test.mjs +++ b/scripts/revision-evidence-consumer-config.test.mjs @@ -88,7 +88,10 @@ test('copies the locked pnpm dependency tree into the independent consumer', () true, ); const stagedPackage = realpathSync(join(targetNodeModules, 'example')); - assert.equal(relative(targetNodeModules, stagedPackage).startsWith('..'), false); + assert.equal( + relative(realpathSync(targetNodeModules), stagedPackage).startsWith('..'), + false, + ); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/scripts/verify-framework-free-revision-evidence-package.mjs b/scripts/verify-framework-free-revision-evidence-package.mjs index 118cadb0..8f3eacf0 100644 --- a/scripts/verify-framework-free-revision-evidence-package.mjs +++ b/scripts/verify-framework-free-revision-evidence-package.mjs @@ -41,7 +41,10 @@ function run(command, argumentsList, cwd = repositoryRoot) { /** Assert that a resolved path remains inside the framework-free consumer. */ function assertInsideConsumer(resolvedPath, description) { - const relativePath = relative(consumerDirectory, realpathSync(resolvedPath)); + const relativePath = relative( + realpathSync(consumerDirectory), + realpathSync(resolvedPath), + ); assert.equal(isAbsolute(relativePath), false, description); assert.equal( relativePath === '..' || relativePath.startsWith(`..${sep}`), diff --git a/scripts/verify-review-package.mjs b/scripts/verify-review-package.mjs index 5249e4e7..d38232f5 100644 --- a/scripts/verify-review-package.mjs +++ b/scripts/verify-review-package.mjs @@ -32,7 +32,10 @@ const externalRuntimeImportPattern = /(?:\bimport\s+(?:[^'";]*?\sfrom\s*)?['"][^'"]+['"]|\bexport\s+[^'";]*?\sfrom\s*['"][^'"]+['"])/u; const ambientAuthorityPattern = /(?:\bfetch\s*\(|\bXMLHttpRequest\b|\bWebSocket\b|\bEventSource\b|\bprocess\.env\b|\bimport\.meta\.env\b|\bDeno\.env\b|\bBun\.env\b)/u; +const forbiddenProductGraphPattern = + /(?:\bReact\b|react-dom|@tiptap|y-prosemirror|\byjs\b|nar(u|uo)n|contextual-orchestrator|NVIDIA_NIM_API_KEY|COPILOT_GITHUB_TOKEN)/iu; +/** Execute one deterministic package-consumer command. */ function run(command, argumentsList, cwd = repositoryRoot) { return execFileSync(command, argumentsList, { cwd, @@ -44,14 +47,13 @@ function run(command, argumentsList, cwd = repositoryRoot) { function preparePackage() { mkdirSync(extractionDirectory, { recursive: true }); mkdirSync(dirname(packageDirectory), { recursive: true }); - const packOutput = run('npm', [ + const packResult = JSON.parse(run('npm', [ 'pack', '--json', '--ignore-scripts', '--pack-destination', verificationRoot, - ]); - const packResult = JSON.parse(packOutput)[0]; + ]))[0]; assert.equal(packResult.name, packageJson.name); assert.equal(packResult.version, packageJson.version); const tarballPath = join(verificationRoot, packResult.filename); @@ -64,33 +66,27 @@ function preparePackage() { 'utf8', ); + // The public declarations retain erased TipTap/ProseMirror structural types; + // expose the repository's already-installed declarations without installing +// anything into the system runtime. const repositoryTiptap = join(repositoryRoot, 'node_modules', '@tiptap'); const consumerTiptap = join(consumerDirectory, 'node_modules', '@tiptap'); assert.ok(existsSync(repositoryTiptap)); symlinkSync(repositoryTiptap, consumerTiptap, 'dir'); } +/** Prove the React-free review bundle cannot acquire runtime authority. */ function verifyAuthorityFreeBundles() { for (const filename of ['cwl-review.js', 'cwl-review.cjs']) { const source = readFileSync(join(packageDirectory, 'dist', filename), 'utf8'); - assert.equal( - dynamicLoaderPattern.test(source), - false, - `${filename} must not invoke dynamic module loaders`, - ); - assert.doesNotMatch( - source, - externalRuntimeImportPattern, - `${filename} must not import external runtime authority`, - ); - assert.doesNotMatch( - source, - ambientAuthorityPattern, - `${filename} must not reference ambient network or credential authority`, - ); + assert.equal(dynamicLoaderPattern.test(source), false, filename); + assert.doesNotMatch(source, externalRuntimeImportPattern, filename); + assert.doesNotMatch(source, ambientAuthorityPattern, filename); + assert.doesNotMatch(source, forbiddenProductGraphPattern, filename); } } +/** Exercise the exact packed ESM and CommonJS subpath. */ function verifyRuntimeConsumers() { const esmPath = join(consumerDirectory, 'consumer.mjs'); writeFileSync( @@ -106,6 +102,9 @@ import { createDocumentEnvelopeRevision, createReviewTarget, createTextPositionSelector, + REVIEW_CONTRACT_SCHEMA_ID, + REVIEW_CONTRACT_SCHEMA_VERSION, + createReviewOperationResult, } from '${packageJson.name}/review'; assert.equal(INKSPAN_REVIEW_CONTRACT_VERSION, 1); assert.equal(TEXT_POSITION_PROJECTION_ID, 'inkspan-prosemirror-text'); @@ -134,9 +133,37 @@ assert.throws( () => createReviewTarget({ ...target, contractVersion: 2 }), CwlReviewTargetError, ); +const envelope = (reviewed = false) => ({ + schemaId: 'https://inkspan.io/schemas/document-envelope/v1', + schemaVersion: 1, + documentJson: reviewed ? { type: 'doc', attrs: { reviewed: true } } : { type: 'doc' }, +}); +const revision = (fill) => { + const digestHex = fill.toString(16).padStart(2, '0').repeat(32); + return { algorithm: 'SHA-256', digestHex, strongEntityTag: \`"sha256-\${digestHex}"\` }; +}; +const expected = revision(1); +let digestCalls = 0; +const result = await createReviewOperationResult({ + action: 'accept', + suggestion: { + suggestionId: 'packed-suggestion', + kind: 'insert', + state: 'pending', + expectedRevision: expected, + target: { revision: expected, selector: { type: 'TextPositionSelector', start: 0, end: 0 }, textProjection: { id: 'inkspan-prosemirror-text', version: 1 } }, + text: 'x', + }, +}, envelope(), envelope(true), undefined, { digest: async () => new Uint8Array(32).fill(++digestCalls).buffer }); +assert.equal(REVIEW_CONTRACT_SCHEMA_ID, 'https://inkspan.io/schemas/review/v1'); +assert.equal(REVIEW_CONTRACT_SCHEMA_VERSION, 1); +assert.equal(result.status, 'accepted'); +assert.equal(result.transitionEvidence.changed, true); +assert.equal('documentJson' in result, false); `, 'utf8', ); + const cjsPath = join(consumerDirectory, 'consumer.cjs'); writeFileSync( cjsPath, @@ -162,6 +189,10 @@ const target = review.createReviewTarget({ }); assert.equal(target.revision.digestHex, digestHex); assert.equal(Object.isFrozen(target.projection), true); +assert.equal(review.REVIEW_CONTRACT_SCHEMA_ID, 'https://inkspan.io/schemas/review/v1'); +assert.equal(review.REVIEW_CONTRACT_SCHEMA_VERSION, 1); +assert.equal(typeof review.validateReviewTarget, 'function'); +assert.equal(typeof review.createReviewOperationResult, 'function'); `, 'utf8', ); @@ -169,6 +200,7 @@ assert.equal(Object.isFrozen(target.projection), true); run(process.execPath, [cjsPath], consumerDirectory); } +/** Compile one strict TypeScript consumer against only the public subpath. */ function verifyDeclarationConsumer() { const sourcePath = join(consumerDirectory, 'consumer.ts'); const configurationPath = join(consumerDirectory, 'tsconfig.json'); @@ -183,6 +215,10 @@ function verifyDeclarationConsumer() { type CwlEditorDocumentRevision, type CwlEditorTextPositionSelector, type CwlEditorTextProjectionIdentity, + REVIEW_CONTRACT_SCHEMA_ID, + validateReviewTarget, + type CwlEditorReviewTarget, + type ReviewContractErrorCode, } from '${packageJson.name}/review'; declare const revision: CwlEditorDocumentRevision; declare const selector: CwlEditorTextPositionSelector; @@ -202,6 +238,14 @@ void [ detachedTarget.projection.id, code, ]; +const operationTarget: CwlEditorReviewTarget = { + revision: { algorithm: 'SHA-256', digestHex: 'a'.repeat(64), strongEntityTag: '"sha256-' + 'a'.repeat(64) + '"' }, + selector: { type: 'TextPositionSelector', start: 0, end: 0 }, + textProjection: { id: 'inkspan-prosemirror-text', version: 1 }, +}; +const operationValidated = validateReviewTarget(operationTarget); +const operationCode: ReviewContractErrorCode = 'stale_revision'; +void [REVIEW_CONTRACT_SCHEMA_ID, operationValidated.selector.start, operationCode]; `, 'utf8', ); @@ -243,7 +287,7 @@ try { verifyRuntimeConsumers(); verifyDeclarationConsumer(); console.log( - `Verified packed ${packageJson.name}/review through authority-bounded ESM, CommonJS, and strict TypeScript consumers.`, + `Verified packed ${packageJson.name}/review through provider-neutral ESM, CommonJS, and strict TypeScript consumers.`, ); } finally { rmSync(verificationRoot, { recursive: true, force: true }); diff --git a/scripts/verify-revision-evidence-package.mjs b/scripts/verify-revision-evidence-package.mjs index c0a5f0e3..dade16ca 100644 --- a/scripts/verify-revision-evidence-package.mjs +++ b/scripts/verify-revision-evidence-package.mjs @@ -77,7 +77,10 @@ function readInstalledDependencyVersion(packageName) { /** Assert that a resolved path cannot escape the independent consumer tree. */ function assertPathInsideConsumer(resolvedPath, description) { - const relativePath = relative(verificationDirectory, resolvedPath); + const relativePath = relative( + realpathSync(verificationDirectory), + realpathSync(resolvedPath), + ); assert.equal( isAbsolute(relativePath), false, @@ -258,7 +261,7 @@ import { isAbsolute, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import * as editor from '${packageJson.name}'; -const consumerDirectory = ${JSON.stringify(verificationDirectory)}; +const consumerDirectory = ${JSON.stringify(realpathSync(verificationDirectory))}; function assertInsideConsumer(resolvedPath, description) { const resolvedRelative = relative(consumerDirectory, resolvedPath); assert.equal(isAbsolute(resolvedRelative), false, description); @@ -270,7 +273,7 @@ function assertInsideConsumer(resolvedPath, description) { } const resolvedEntry = fileURLToPath(import.meta.resolve('${packageJson.name}')); assertInsideConsumer(resolvedEntry, 'packed ESM entry escaped consumer tree'); -const packageRelative = relative(${JSON.stringify(packageDirectory)}, resolvedEntry); +const packageRelative = relative(${JSON.stringify(realpathSync(packageDirectory))}, resolvedEntry); assert.equal(isAbsolute(packageRelative), false); assert.equal( packageRelative === '..' || packageRelative.startsWith('..' + sep), @@ -329,7 +332,7 @@ const editor = require('${packageJson.name}'); void (async () => { const resolvedEntry = require.resolve('${packageJson.name}'); - const resolvedRelative = relative(${JSON.stringify(packageDirectory)}, resolvedEntry); + const resolvedRelative = relative(${JSON.stringify(realpathSync(packageDirectory))}, resolvedEntry); assert.equal(isAbsolute(resolvedRelative), false); assert.equal( resolvedRelative === '..' || resolvedRelative.startsWith('..' + sep), diff --git a/src/canonicalProductDocumentation.test.ts b/src/canonicalProductDocumentation.test.ts index 746ed52e..e1eebd0b 100644 --- a/src/canonicalProductDocumentation.test.ts +++ b/src/canonicalProductDocumentation.test.ts @@ -48,6 +48,8 @@ const requiredFiles = [ 'docs/adr/0022-informative-docx-png-figures.md', 'docs/adr/0023-bounded-docx-rich-text-runs.md', 'docs/adr/0024-bounded-docx-paragraph-alignment.md', + 'docs/adr/0027-provider-neutral-review-contract.md', + 'docs/review-contract.md', 'src/fonts/OFL.txt', 'src/fonts/NOTICE', 'src/fonts/fonts.css', diff --git a/src/collaboration/CollaborativeCwlEditor.tsx b/src/collaboration/CollaborativeCwlEditor.tsx index eea89b6c..cc9a9f5f 100644 --- a/src/collaboration/CollaborativeCwlEditor.tsx +++ b/src/collaboration/CollaborativeCwlEditor.tsx @@ -18,6 +18,7 @@ import { createEditorDocumentSnapshot } from '../components/editorDocumentSnapsh import { applyEditorFormReset } from '../components/editorFormReset.js'; import { editorHtmlToValue } from '../components/editorSerialization.js'; import { useEditorHandle } from '../components/useEditorHandle.js'; +import { useReviewActions } from '../components/reviewOperations.js'; import { useLatestRef } from '../components/useLatestRef.js'; import type { ClipboardSanitizationError } from '../extensions/SafeClipboard.js'; import { buildExtensions } from '../extensions/kit.js'; @@ -73,6 +74,7 @@ export const CollaborativeCwlEditor = forwardRef< onFocus, onBlur, onSelectionChange, + review, onImageError, clipboard, onClipboardError, @@ -260,7 +262,8 @@ export const CollaborativeCwlEditor = forwardRef< [collaborationDocument, scopedProvider, normalizedField, presenceEnabled], ); - useEditorHandle(ref, editor, modeRef); + const reviewActions = useReviewActions(editor, review); + useEditorHandle(ref, editor, modeRef, reviewActions.finalizedIdsRef); useEffect(() => { editor?.setEditable(editable); @@ -346,6 +349,9 @@ export const CollaborativeCwlEditor = forwardRef< formFieldDisabled={formFieldDisabled} onFormReset={editor && onFormReset ? handleFormReset : undefined} status={status} + review={review} + onReviewAction={reviewActions.onReviewAction} + onReviewSelect={reviewActions.onReviewSelect} /> ); }); diff --git a/src/components/CwlEditor.review.test.tsx b/src/components/CwlEditor.review.test.tsx new file mode 100644 index 00000000..a40cb28e --- /dev/null +++ b/src/components/CwlEditor.review.test.tsx @@ -0,0 +1,435 @@ +import { + act, + cleanup, + fireEvent, + render, + renderHook, + screen, + waitFor, +} from '@testing-library/react'; +import { createRef } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { + CwlEditorDocumentRevision, + DocumentEnvelopeDigestProvider, +} from '../documentEnvelopeRevision.js'; +import type { + CwlEditorReviewOperationResult, + CwlEditorReviewSuggestion, + CwlEditorReviewTarget, +} from '../review/contract.js'; +import type { CwlEditorHandle } from '../types.js'; +import { EditorState, TextSelection } from '@tiptap/pm/state'; +import { Schema } from '@tiptap/pm/model'; +import { CwlEditor } from './CwlEditor.js'; +import { + applyReviewOperation, + createReviewMarkerPlugin, + findReviewRange, + useReviewActions, +} from './reviewOperations.js'; + +afterEach(cleanup); + +function digestProvider(): DocumentEnvelopeDigestProvider { + return { + digest: async (_algorithm, source) => { + const bytes = source as Uint8Array; + const digest = new Uint8Array(32); + for (let index = 0; index < bytes.length; index += 1) { + digest[index % digest.length] ^= bytes[index]!; + } + return digest.buffer; + }, + }; +} + +function revision(fill: number): CwlEditorDocumentRevision { + const digestHex = fill.toString(16).padStart(2, '0').repeat(32); + return Object.freeze({ + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }); +} + +function suggestion( + id: string, + target: CwlEditorReviewTarget, + kind: 'insert' | 'delete', + expectedRevision = target.revision, +): CwlEditorReviewSuggestion { + return kind === 'insert' + ? { + suggestionId: id, + kind, + state: 'pending', + expectedRevision, + target, + text: 'X', + } + : { + suggestionId: id, + kind, + state: 'pending', + expectedRevision, + target, + }; +} + +describe('CwlEditor provider-neutral review integration', () => { + it('accepts an exact insert once through the imperative handle', async () => { + const editorRef = createRef(); + render(); + await waitFor(() => expect(editorRef.current?.getEditor()).not.toBeNull()); + + const handle = editorRef.current!; + const editor = handle.getEditor()!; + act(() => { + editor.view.dispatch( + editor.state.tr.setSelection( + TextSelection.create(editor.state.doc, editor.state.doc.content.size - 1), + ), + ); + }); + const evidence = await handle.getTextPositionSelectorEvidence( + undefined, + digestProvider(), + ); + const reviewSuggestion = suggestion( + 'insert-once', + { + revision: evidence!.revision, + selector: evidence!.selector, + textProjection: evidence!.textProjection, + }, + 'insert', + ); + + let result: CwlEditorReviewOperationResult | null | undefined; + await act(async () => { + result = await handle.acceptReviewSuggestion( + reviewSuggestion, + undefined, + digestProvider(), + ); + }); + expect((result as CwlEditorReviewOperationResult).status).toBe('accepted'); + expect(handle.getHTML()).toContain('HelloX'); + await act(async () => { + await expect( + handle.acceptReviewSuggestion(reviewSuggestion, undefined, digestProvider()), + ).rejects.toMatchObject({ code: 'operation_already_final' }); + }); + }); + + it('rejects without mutation and returns stale without re-anchoring', async () => { + const editorRef = createRef(); + render(); + await waitFor(() => expect(editorRef.current?.getEditor()).not.toBeNull()); + const handle = editorRef.current!; + const editor = handle.getEditor()!; + + act(() => editor.commands.setTextSelection({ from: 1, to: 2 })); + const evidence = await handle.getTextPositionSelectorEvidence( + undefined, + digestProvider(), + ); + const deleteSuggestion = suggestion( + 'delete-reject', + { + revision: evidence!.revision, + selector: evidence!.selector, + textProjection: evidence!.textProjection, + }, + 'delete', + ); + const rejected = await handle.rejectReviewSuggestion( + deleteSuggestion, + undefined, + digestProvider(), + ); + expect(rejected?.status).toBe('rejected'); + expect(handle.getHTML()).toContain('Hello'); + + act(() => handle.setValue('Changed elsewhere')); + const stale = await handle.acceptReviewSuggestion( + suggestion( + 'stale-insert', + { + revision: evidence!.revision, + selector: { ...evidence!.selector, start: 0, end: 0 }, + textProjection: evidence!.textProjection, + }, + 'insert', + ), + undefined, + digestProvider(), + ); + expect(stale?.status).toBe('stale'); + expect(handle.getHTML()).toContain('Changed elsewhere'); + }); + + it('accepts a delete through the editor transaction boundary', async () => { + const editorRef = createRef(); + render(); + await waitFor(() => expect(editorRef.current?.getEditor()).not.toBeNull()); + const handle = editorRef.current!; + const editor = handle.getEditor()!; + act(() => editor.commands.setTextSelection({ from: 1, to: 2 })); + const evidence = await handle.getTextPositionSelectorEvidence( + undefined, + digestProvider(), + ); + const resultBox: { value: CwlEditorReviewOperationResult | null } = { value: null }; + await act(async () => { + resultBox.value = await handle.acceptReviewSuggestion( + suggestion( + 'delete-once', + { + revision: evidence!.revision, + selector: evidence!.selector, + textProjection: evidence!.textProjection, + }, + 'delete', + ), + undefined, + digestProvider(), + ); + }); + expect(resultBox.value?.status).toBe('accepted'); + expect(handle.getHTML()).toContain('ello'); + }); + + it('rejects a transaction that claims to accept without changing the document', async () => { + const editorRef = createRef(); + render(); + await waitFor(() => expect(editorRef.current?.getEditor()).not.toBeNull()); + const handle = editorRef.current!; + const editor = handle.getEditor()!; + const evidence = await handle.getTextPositionSelectorEvidence( + undefined, + digestProvider(), + ); + const transaction = { + doc: editor.state.doc, + docChanged: false, + insertText: vi.fn(), + delete: vi.fn(), + }; + const fakeEditor = { + getJSON: () => editor.getJSON(), + state: { doc: editor.state.doc, tr: transaction }, + view: { dispatch: vi.fn() }, + } as unknown as typeof editor; + await expect( + applyReviewOperation( + fakeEditor, + { + suggestion: suggestion( + 'no-change', + { + revision: evidence!.revision, + selector: evidence!.selector, + textProjection: evidence!.textProjection, + }, + 'insert', + ), + action: 'accept', + }, + undefined, + digestProvider(), + ), + ).rejects.toMatchObject({ code: 'operation_must_change_document' }); + await expect( + applyReviewOperation( + fakeEditor, + { + suggestion: suggestion( + 'out-of-range', + { + revision: evidence!.revision, + selector: { ...evidence!.selector, start: 99, end: 99 }, + textProjection: evidence!.textProjection, + }, + 'insert', + ), + action: 'accept', + }, + undefined, + digestProvider(), + ), + ).rejects.toMatchObject({ code: 'selector_out_of_range' }); + }); + + it('renders accessible panel and inline markers and reports stale actions', async () => { + const onOperation = vi.fn(); + let readyEditor: ReturnType = null; + const staleTarget = { + revision: revision(9), + selector: { type: 'TextPositionSelector' as const, start: 0, end: 0 }, + textProjection: { id: 'inkspan-prosemirror-text' as const, version: 1 as const }, + }; + const threadTarget = { + ...staleTarget, + selector: { type: 'TextPositionSelector' as const, start: 0, end: 1 }, + }; + render( + { + readyEditor = editor; + }} + review={{ + threads: [ + { + threadId: 'thread-1', + target: threadTarget, + state: 'open', + replyCount: 1, + }, + ], + suggestions: [ + suggestion('stale-ui', staleTarget, 'insert'), + suggestion('inline-ui', threadTarget, 'delete'), + ], + onOperation, + }} + />, + ); + + await waitFor(() => { + expect(readyEditor).not.toBeNull(); + expect(findReviewRange(readyEditor!.state, staleTarget.selector)).toEqual({ + from: 1, + to: 1, + }); + expect(findReviewRange(readyEditor!.state, threadTarget.selector)).toEqual({ + from: 1, + to: 2, + }); + expect(screen.getByRole('region', { name: 'Document review' })).toBeInTheDocument(); + expect(document.querySelector('.cwl-review-marker--thread')).toBeTruthy(); + expect(document.querySelector('[data-review-id="stale-ui"]')).toBeTruthy(); + expect(document.querySelector('[data-review-id="inline-ui"]')).toBeTruthy(); + }); + fireEvent.click(screen.getAllByRole('button', { name: 'Accept' })[0]!); + await waitFor(() => expect(onOperation).toHaveBeenCalledWith( + expect.objectContaining({ status: 'stale', suggestionId: 'stale-ui' }), + )); + expect(document.querySelector('.cwl-editor__content')).toHaveTextContent('Hi'); + }); + + it('fails closed for an unmappable accepted target and selects mapped review targets', async () => { + const editorRef = createRef(); + const onError = vi.fn(); + const target = { + revision: revision(9), + selector: { type: 'TextPositionSelector' as const, start: 99, end: 99 }, + textProjection: { id: 'inkspan-prosemirror-text' as const, version: 1 as const }, + }; + render( + , + ); + await waitFor(() => expect(editorRef.current?.getEditor()).not.toBeNull()); + fireEvent.click(screen.getByRole('button', { name: /unmappable/ })); + await waitFor(() => expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'selector_out_of_range' }), + )); + }); + + it('keeps exact-once state across panel retries and redacts host callback failures', async () => { + const editorRef = createRef(); + const onError = vi.fn(); + const rendered = render( + undefined} />, + ); + await waitFor(() => expect(editorRef.current?.getEditor()).not.toBeNull()); + const currentRevision = await editorRef.current!.getDocumentEnvelopeRevision(); + const target = { + revision: currentRevision!, + selector: { type: 'TextPositionSelector' as const, start: 0, end: 0 }, + textProjection: { id: 'inkspan-prosemirror-text' as const, version: 1 as const }, + }; + const reviewSuggestion = suggestion('exact-once', target, 'insert'); + rendered.rerender( + , + ); + await waitFor(() => expect(screen.getByRole('region', { name: 'Document review' })).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /exact-once/ })); + fireEvent.click(screen.getAllByRole('button', { name: 'Accept' })[0]!); + await waitFor(() => expect(editorRef.current!.getHTML()).toContain('XHi')); + fireEvent.click(screen.getAllByRole('button', { name: 'Accept' })[0]!); + await waitFor(() => expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'operation_already_final' }), + )); + }); + + it('redacts errors from host callbacks and covers invalid structural boundaries', async () => { + const onError = vi.fn(() => { throw new Error('presentation failed'); }); + const onOperation = vi.fn(() => { throw new Error('host failed'); }); + let readyEditor: ReturnType = null; + const target = { + revision: revision(9), + selector: { type: 'TextPositionSelector' as const, start: 0, end: 0 }, + textProjection: { id: 'inkspan-prosemirror-text' as const, version: 1 as const }, + }; + render( + { readyEditor = editor; }} + review={{ suggestions: [suggestion('host-failure', target, 'insert')], onOperation, onError }} + />, + ); + await waitFor(() => expect(screen.getByRole('region', { name: 'Document review' })).toBeInTheDocument()); + expect(findReviewRange( + readyEditor!.state, + { type: 'TextPositionSelector', start: 0, end: 0 }, + )).toEqual({ from: 1, to: 1 }); + fireEvent.click(screen.getByRole('button', { name: 'Accept' })); + await waitFor(() => expect(onOperation).toHaveBeenCalled()); + }); + + it('handles review actions safely while the editor is unavailable', async () => { + const onError = vi.fn(); + const { result } = renderHook(() => useReviewActions(null, { onError })); + const emptySuggestion = suggestion('unavailable', { + revision: revision(1), + selector: { type: 'TextPositionSelector', start: 0, end: 0 }, + textProjection: { id: 'inkspan-prosemirror-text', version: 1 }, + }, 'insert'); + await act(async () => { + await result.current.onReviewAction(emptySuggestion, 'accept'); + }); + act(() => result.current.onReviewSelect(emptySuggestion.target)); + expect(onError).not.toHaveBeenCalled(); + }); + + it('returns an empty decoration set when plugin state is not installed', () => { + const plugin = createReviewMarkerPlugin({}); + const schema = new Schema({ + nodes: { + doc: { content: 'block+' }, + paragraph: { content: 'inline*', group: 'block' }, + text: { group: 'inline' }, + }, + }); + const state = EditorState.create({ + schema, + doc: schema.node('doc', null, [schema.node('paragraph', null, [schema.text('Hi')])]), + }); + expect(plugin.spec.state?.init?.({}, state)).toBeDefined(); + expect(plugin.props.decorations?.call(plugin, {} as never)).toBeDefined(); + }); +}); diff --git a/src/components/CwlEditor.tsx b/src/components/CwlEditor.tsx index 598ac948..d19d2897 100644 --- a/src/components/CwlEditor.tsx +++ b/src/components/CwlEditor.tsx @@ -18,6 +18,7 @@ import { createEditorDocumentSnapshot } from './editorDocumentSnapshot.js'; import { applyEditorFormReset } from './editorFormReset.js'; import { editorHtmlToValue, editorValueToHtml } from './editorSerialization.js'; import { useEditorHandle } from './useEditorHandle.js'; +import { useReviewActions } from './reviewOperations.js'; import { useLatestRef } from './useLatestRef.js'; /** @@ -39,6 +40,7 @@ export const CwlEditor = forwardRef( onFocus, onBlur, onSelectionChange, + review, onImageError, clipboard, onClipboardError, @@ -190,7 +192,8 @@ export const CwlEditor = forwardRef( }, }); - useEditorHandle(ref, editor, modeRef); + const reviewActions = useReviewActions(editor, review); + useEditorHandle(ref, editor, modeRef, reviewActions.finalizedIdsRef); useEffect(() => { editor?.setEditable(editable); @@ -248,6 +251,9 @@ export const CwlEditor = forwardRef( formFieldDisabled={formFieldDisabled} formFieldInitialValue={selectedDocumentValue} onFormReset={editor && observesFormReset ? handleFormReset : undefined} + review={review} + onReviewAction={reviewActions.onReviewAction} + onReviewSelect={reviewActions.onReviewSelect} /> ); }, diff --git a/src/components/EditorFrame.tsx b/src/components/EditorFrame.tsx index 9cf49c23..f13f696c 100644 --- a/src/components/EditorFrame.tsx +++ b/src/components/EditorFrame.tsx @@ -1,7 +1,20 @@ import { EditorContent, type Editor } from '@tiptap/react'; -import { useCallback, type KeyboardEvent, type ReactNode } from 'react'; -import type { EditorMode, ImageConfig } from '../types.js'; +import { useCallback, useEffect, type KeyboardEvent, type ReactNode } from 'react'; +import type { + CwlEditorReviewProps, + EditorMode, + ImageConfig, +} from '../types.js'; +import type { + CwlEditorReviewSuggestion, + CwlEditorReviewTarget, +} from '../review/contract.js'; import { EditorFormField } from './EditorFormField.js'; +import { + createReviewMarkerPlugin, + REVIEW_MARKER_KEY, +} from './reviewOperations.js'; +import { ReviewPanel } from './ReviewPanel.js'; import { Toolbar } from './Toolbar.js'; /** Props for the visual editor shell shared by every Inkspan editing mode. */ @@ -20,6 +33,12 @@ export interface EditorFrameProps { formFieldInitialValue?: string; onFormReset?: (event: Event) => void; status?: ReactNode; + review?: CwlEditorReviewProps; + onReviewAction?: ( + suggestion: CwlEditorReviewSuggestion, + action: 'accept' | 'reject', + ) => Promise; + onReviewSelect?: (target: CwlEditorReviewTarget) => void; } /** @@ -40,6 +59,9 @@ export function EditorFrame({ formFieldInitialValue, onFormReset, status, + review, + onReviewAction, + onReviewSelect, }: EditorFrameProps) { const onKeyDown = useCallback( (event: KeyboardEvent) => { @@ -68,6 +90,15 @@ export function EditorFrame({ [editor], ); + useEffect(() => { + if (!editor || !review) return; + const plugin = createReviewMarkerPlugin(review); + editor.registerPlugin(plugin); + return () => { + editor.unregisterPlugin(REVIEW_MARKER_KEY); + }; + }, [editor, review]); + return (
{status} + {review && onReviewAction && onReviewSelect ? ( + + ) : null} {!hideToolbar && editor && editable ? ( { + it.each([ + 'pending-insert', + 'pending-delete', + 'resolved', + 'empty', + ])('renders the %s state', async (state) => { + render(); + expect(screen.getByRole('region', { name: 'Document review' })).toBeInTheDocument(); + await act(async () => { + if (state !== 'empty') { + fireEvent.click(screen.getAllByRole('button')[0]!); + } + if (state === 'pending-insert') { + fireEvent.click(screen.getByRole('button', { name: 'Accept' })); + } + if (state === 'pending-delete') { + fireEvent.click(screen.getByRole('button', { name: 'Reject' })); + } + await Promise.resolve(); + }); + if (state === 'empty') { + expect(screen.getByText('No suggestions.')).toBeInTheDocument(); + } + if (state === 'pending-insert') { + expect(screen.getByText(/pending text/)).toBeInTheDocument(); + } + if (state === 'pending-delete') { + expect(screen.getByText(/fixture-delete/)).toBeInTheDocument(); + } + if (state === 'resolved') { + expect(screen.getByText(/fixture-thread/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Accept' })).toBeDisabled(); + } + }); +}); diff --git a/src/components/ReviewPanel.fixture.tsx b/src/components/ReviewPanel.fixture.tsx new file mode 100644 index 00000000..9385cd77 --- /dev/null +++ b/src/components/ReviewPanel.fixture.tsx @@ -0,0 +1,83 @@ +import type { CwlEditorReviewProps } from '../types.js'; +import type { CwlEditorReviewTarget } from '../review/contract.js'; +import { ReviewPanel } from './ReviewPanel.js'; + +/** Render-ready states used by repository tests and manual accessibility checks. */ +export type ReviewPanelFixtureState = + | 'pending-insert' + | 'pending-delete' + | 'resolved' + | 'empty'; + +const FIXTURE_REVISION = Object.freeze({ + algorithm: 'SHA-256' as const, + digestHex: '01'.repeat(32), + strongEntityTag: `"sha256-${'01'.repeat(32)}"`, +}); + +const FIXTURE_TARGET: CwlEditorReviewTarget = Object.freeze({ + revision: FIXTURE_REVISION, + selector: Object.freeze({ + type: 'TextPositionSelector' as const, + start: 0, + end: 0, + }), + textProjection: Object.freeze({ + id: 'inkspan-prosemirror-text' as const, + version: 1 as const, + }), +}); + +function fixtureReview(state: ReviewPanelFixtureState): CwlEditorReviewProps { + if (state === 'empty') return {}; + if (state === 'resolved') { + return { + threads: [{ + threadId: 'fixture-thread', + target: { ...FIXTURE_TARGET, selector: { ...FIXTURE_TARGET.selector, end: 1 } }, + state: 'resolved', + replyCount: 2, + }], + suggestions: [{ + suggestionId: 'fixture-accepted', + kind: 'insert', + state: 'accepted', + expectedRevision: FIXTURE_REVISION, + target: FIXTURE_TARGET, + text: 'accepted text', + }], + }; + } + if (state === 'pending-delete') { + return { + suggestions: [{ + suggestionId: 'fixture-delete', + kind: 'delete', + state: 'pending', + expectedRevision: FIXTURE_REVISION, + target: { ...FIXTURE_TARGET, selector: { ...FIXTURE_TARGET.selector, end: 1 } }, + }], + }; + } + return { + suggestions: [{ + suggestionId: 'fixture-insert', + kind: 'insert', + state: 'pending', + expectedRevision: FIXTURE_REVISION, + target: FIXTURE_TARGET, + text: 'pending text', + }], + }; +} + +/** Minimal Storybook-equivalent render fixture with pending/final/empty states. */ +export function ReviewPanelFixture({ state }: { readonly state: ReviewPanelFixtureState }) { + return ( + undefined} + onSelect={() => undefined} + /> + ); +} diff --git a/src/components/ReviewPanel.test.tsx b/src/components/ReviewPanel.test.tsx new file mode 100644 index 00000000..1995ba74 --- /dev/null +++ b/src/components/ReviewPanel.test.tsx @@ -0,0 +1,146 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { CwlEditorReviewProps } from '../types.js'; +import type { CwlEditorReviewTarget } from '../review/contract.js'; +import { ReviewPanel } from './ReviewPanel.js'; + +function target(): CwlEditorReviewTarget { + const digestHex = '01'.repeat(32); + return { + revision: { + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }, + selector: { type: 'TextPositionSelector', start: 0, end: 1 }, + textProjection: { id: 'inkspan-prosemirror-text', version: 1 }, + }; +} + +function panel( + review: CwlEditorReviewProps, + onAction: CwlEditorReviewProps['onOperation'] = vi.fn(), +) { + return render( + { + await onAction?.({ + schemaId: 'https://inkspan.io/schemas/review/v1', + schemaVersion: 1, + suggestionId: suggestion.suggestionId, + action, + status: action === 'accept' ? 'accepted' : 'rejected', + beforeRevision: suggestion.expectedRevision, + }); + }} + onSelect={vi.fn()} + />, + ); +} + +describe('ReviewPanel', () => { + it('selects comment targets through the host callback', () => { + const onSelect = vi.fn(); + render( + undefined} + onSelect={onSelect} + />, + ); + fireEvent.click(screen.getByRole('button', { name: /thread/ })); + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ selector: target().selector })); + }); + + it('renders empty state and disables finalized delete suggestions', () => { + panel({ + suggestions: [{ + suggestionId: 'done-delete', + kind: 'delete', + state: 'accepted', + expectedRevision: target().revision, + target: target(), + }], + }); + expect(screen.queryByText('No suggestions.')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Accept/ })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Reject/ })).toBeDisabled(); + }); + + it('renders the empty suggestions state and completes both action handlers', async () => { + const onAction = vi.fn(); + const review: CwlEditorReviewProps = { + suggestions: [ + { + suggestionId: 'insert', + kind: 'insert', + state: 'pending', + expectedRevision: target().revision, + target: { ...target(), selector: { ...target().selector, end: 0 } }, + text: 'x', + }, + { + suggestionId: 'delete', + kind: 'delete', + state: 'pending', + expectedRevision: target().revision, + target: target(), + }, + ], + }; + const { rerender } = render( + { + onAction(suggestion.suggestionId, action); + }} + onSelect={vi.fn()} + />, + ); + fireEvent.click(screen.getAllByRole('button', { name: 'Accept' })[0]!); + fireEvent.click(screen.getAllByRole('button', { name: 'Reject' })[1]!); + await waitFor(() => expect(onAction).toHaveBeenCalledTimes(2)); + expect(onAction).toHaveBeenNthCalledWith(1, 'insert', 'accept'); + expect(onAction).toHaveBeenNthCalledWith(2, 'delete', 'reject'); + + rerender( undefined} onSelect={vi.fn()} />); + expect(screen.getByText('No suggestions.')).toBeInTheDocument(); + }); + + it('marks an action busy until the host operation settles', async () => { + let resolveAction: (() => void) | undefined; + const onAction = vi.fn(() => new Promise((resolve) => { + resolveAction = resolve; + })); + const review: CwlEditorReviewProps = { + suggestions: [{ + suggestionId: 'busy', + kind: 'insert', + state: 'pending', + expectedRevision: target().revision, + target: { ...target(), selector: { ...target().selector, end: 0 } }, + text: 'x', + }], + }; + render( + , + ); + const accept = screen.getByRole('button', { name: 'Accept' }); + fireEvent.click(accept); + await waitFor(() => expect(accept).toBeDisabled()); + resolveAction?.(); + await waitFor(() => expect(accept).not.toBeDisabled()); + }); +}); diff --git a/src/components/ReviewPanel.tsx b/src/components/ReviewPanel.tsx new file mode 100644 index 00000000..e9e55cb0 --- /dev/null +++ b/src/components/ReviewPanel.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react'; +import type { + CwlEditorReviewProps, +} from '../types.js'; +import type { + CwlEditorReviewSuggestion, + CwlEditorReviewTarget, +} from '../review/contract.js'; + +interface ReviewPanelProps { + readonly review: CwlEditorReviewProps; + readonly onAction: ( + suggestion: CwlEditorReviewSuggestion, + action: 'accept' | 'reject', + ) => Promise; + readonly onSelect: (target: CwlEditorReviewTarget) => void; +} + +/** Render accessible host-controlled threads and deterministic suggestions. */ +export function ReviewPanel({ review, onAction, onSelect }: ReviewPanelProps) { + const [busyIds, setBusyIds] = useState>(new Set()); + const suggestions = review.suggestions ?? []; + const threads = review.threads ?? []; + const setBusy = (id: string, busy: boolean) => { + setBusyIds((current) => { + const next = new Set(current); + if (busy) next.add(id); + else next.delete(id); + return next; + }); + }; + + return ( +
+

Review

+ {threads.length > 0 ? ( +
+

Comments

+
    + {threads.map((thread) => ( +
  • + +
  • + ))} +
+
+ ) : null} +
+

Suggestions

+ {suggestions.length === 0 ?

No suggestions.

: null} +
    + {suggestions.map((suggestion) => { + const busy = busyIds.has(suggestion.suggestionId); + const final = suggestion.state !== 'pending'; + return ( +
  • + + {suggestion.kind === 'insert' ? ( + “{suggestion.text}” + ) : null} +
    + + +
    +
  • + ); + })} +
+
+
+ ); +} diff --git a/src/components/reviewOperations.ts b/src/components/reviewOperations.ts new file mode 100644 index 00000000..0ae7566c --- /dev/null +++ b/src/components/reviewOperations.ts @@ -0,0 +1,298 @@ +import { useCallback, useRef } from 'react'; +import { TextSelection, type EditorState } from '@tiptap/pm/state'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import { Decoration, DecorationSet } from '@tiptap/pm/view'; +import type { Editor } from '@tiptap/react'; +import { + createReviewOperationResult, + ReviewContractError, + validateReviewSuggestion, + type CwlEditorReviewOperation, + type CwlEditorReviewSuggestion, + type CwlEditorReviewTarget, +} from '../review/contract.js'; +import { + createTextPositionSelector, + type CwlEditorTextPositionSelector, +} from '../textPositionSelectorEvidence.js'; +import { + createDocumentEnvelope, + type DocumentEnvelopeLimits, +} from '../documentEnvelope.js'; +import { + createDocumentEnvelopeRevision, + type CwlEditorDocumentRevision, + type DocumentEnvelopeDigestProvider, +} from '../documentEnvelopeRevision.js'; +import type { CwlEditorReviewProps } from '../types.js'; + +interface ReviewRange { + readonly from: number; + readonly to: number; +} + +export const REVIEW_MARKER_KEY = new PluginKey('cwl-review-markers'); + +/** Compare two immutable revision values without retaining source content. */ +function sameRevision( + left: CwlEditorDocumentRevision, + right: CwlEditorDocumentRevision, +): boolean { + return ( + left.algorithm === right.algorithm && + left.digestHex === right.digestHex && + left.strongEntityTag === right.strongEntityTag + ); +} + +/** Convert a validated digest into the provider shape used for known revisions. */ +function digestBuffer(revision: CwlEditorDocumentRevision): ArrayBuffer { + const bytes = new Uint8Array(revision.digestHex.length / 2); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt( + revision.digestHex.slice(index * 2, index * 2 + 2), + 16, + ); + } + return bytes.buffer; +} + +/** Let the core result builder validate already-derived before/after revisions. */ +function knownRevisionProvider( + previous: CwlEditorDocumentRevision, + resulting: CwlEditorDocumentRevision, +): DocumentEnvelopeDigestProvider { + let index = 0; + return { + digest: async () => { + const revision = index++ === 0 ? previous : resulting; + return digestBuffer(revision); + }, + }; +} + +/** + * Find one exact structural range for a versioned logical-text selector. + * ponytail: bounded structural scan; build a cached projection index only if + * large-document profiling shows this O(document positions) lookup matters. + */ +export function findReviewRange( + editorState: EditorState, + selector: CwlEditorTextPositionSelector, +): ReviewRange | null { + let from: number | undefined; + let to: number | undefined; + for (let position = 0; position <= editorState.doc.content.size; position += 1) { + try { + const resolved = editorState.doc.resolve(position); + if (!resolved.parent.inlineContent) continue; + const selection = TextSelection.create(editorState.doc, position, position); + const projected = createTextPositionSelector( + editorState.doc, + selection, + ).selector; + if (from === undefined && projected.start === selector.start) { + from = position; + } + if (projected.start === selector.end) { + to = position; + } + } catch { + // Invalid structural or grapheme boundaries are not selectable targets. + } + } + return from === undefined || to === undefined || from > to + ? null + : { from, to }; +} + +/** Return a stale result without exposing document content or re-anchoring. */ +function staleReviewResult( + operation: CwlEditorReviewOperation, + beforeRevision: CwlEditorDocumentRevision, +): Readonly<{ + readonly schemaId: 'https://inkspan.io/schemas/review/v1'; + readonly schemaVersion: 1; + readonly suggestionId: string; + readonly action: 'accept' | 'reject'; + readonly status: 'stale'; + readonly beforeRevision: CwlEditorDocumentRevision; +}> { + return Object.freeze({ + schemaId: 'https://inkspan.io/schemas/review/v1' as const, + schemaVersion: 1 as const, + suggestionId: operation.suggestion.suggestionId, + action: operation.action, + status: 'stale' as const, + beforeRevision, + }); +} + +/** Apply one review operation through the real editor transaction boundary. */ +export async function applyReviewOperation( + editor: Editor, + operation: CwlEditorReviewOperation, + limits?: DocumentEnvelopeLimits, + digestProvider?: DocumentEnvelopeDigestProvider | null, +) { + const suggestion = validateReviewSuggestion(operation.suggestion); + const previousEnvelope = createDocumentEnvelope(editor.getJSON(), limits); + const previousRevision = await createDocumentEnvelopeRevision( + previousEnvelope, + limits, + digestProvider, + ); + if (!sameRevision(previousRevision, suggestion.expectedRevision)) { + return staleReviewResult(operation, previousRevision); + } + + const range = findReviewRange(editor.state, suggestion.target.selector); + if (!range) throw new ReviewContractError('selector_out_of_range'); + + const transaction = editor.state.tr; + if (operation.action === 'accept') { + if (suggestion.kind === 'insert') { + transaction.insertText(suggestion.text, range.from, range.to); + } else { + transaction.delete(range.from, range.to); + } + if (!transaction.docChanged) { + throw new ReviewContractError('operation_must_change_document'); + } + } + + const resultingEnvelope = operation.action === 'accept' + ? createDocumentEnvelope(transaction.doc.toJSON(), limits) + : previousEnvelope; + const resultingRevision = operation.action === 'accept' + ? await createDocumentEnvelopeRevision(resultingEnvelope, limits, digestProvider) + : previousRevision; + const result = await createReviewOperationResult( + operation, + previousEnvelope, + resultingEnvelope, + limits, + knownRevisionProvider(previousRevision, resultingRevision), + ); + if (result.status === 'accepted') editor.view.dispatch(transaction); + return result; +} + +/** Build accessible inline review markers for one editor state. */ +function reviewDecorations( + state: EditorState, + review: CwlEditorReviewProps, +): DecorationSet { + const decorations: Decoration[] = []; + for (const thread of review.threads ?? []) { + const range = findReviewRange(state, thread.target.selector); + if (range && range.from < range.to) { + decorations.push( + Decoration.inline(range.from, range.to, { + class: 'cwl-review-marker cwl-review-marker--thread', + 'data-review-id': thread.threadId, + 'data-review-state': thread.state, + }), + ); + } + } + for (const suggestion of review.suggestions ?? []) { + const range = findReviewRange(state, suggestion.target.selector); + if (!range) continue; + if (range.from === range.to) { + decorations.push( + Decoration.widget(range.from, () => { + const marker = document.createElement('span'); + marker.className = + 'cwl-review-marker cwl-review-marker--suggestion cwl-review-marker--widget'; + marker.dataset.reviewId = suggestion.suggestionId; + marker.setAttribute('aria-hidden', 'true'); + marker.textContent = '•'; + return marker; + }), + ); + } else { + decorations.push( + Decoration.inline(range.from, range.to, { + class: 'cwl-review-marker cwl-review-marker--suggestion', + 'data-review-id': suggestion.suggestionId, + 'data-review-state': suggestion.state, + }), + ); + } + } + return DecorationSet.create(state.doc, decorations); +} + +/** Register a replaceable marker plugin for controlled review metadata. */ +export function createReviewMarkerPlugin(review: CwlEditorReviewProps) { + return new Plugin({ + key: REVIEW_MARKER_KEY, + state: { + init: (_, state) => reviewDecorations(state, review), + apply: (_, _old, _oldState, state) => reviewDecorations(state, review), + }, + props: { + decorations: (state) => + REVIEW_MARKER_KEY.getState(state) ?? DecorationSet.empty, + }, + }); +} + +/** Coordinate host callbacks, exact-once local state, and marker selection. */ +export function useReviewActions( + editor: Editor | null, + review: CwlEditorReviewProps | undefined, +) { + const finalizedIdsRef = useRef(new Set()); + const reviewRef = useRef(review); + reviewRef.current = review; + + const reportError = useCallback((error: unknown) => { + const safeError = + error instanceof ReviewContractError + ? error + : new ReviewContractError('invalid_review_contract'); + try { + reviewRef.current?.onError?.(safeError); + } catch { + // Host presentation failures must not change local editor state. + } + }, []); + + const onReviewAction = useCallback( + async (suggestion: CwlEditorReviewSuggestion, action: 'accept' | 'reject') => { + if (!editor) return; + if (finalizedIdsRef.current.has(suggestion.suggestionId)) { + reportError(new ReviewContractError('operation_already_final')); + return; + } + try { + const result = await applyReviewOperation(editor, { suggestion, action }); + if (result.status !== 'stale') { + finalizedIdsRef.current.add(suggestion.suggestionId); + } + await reviewRef.current?.onOperation?.(result); + } catch (error) { + reportError(error); + } + }, + [editor, reportError], + ); + + const onReviewSelect = useCallback( + (target: CwlEditorReviewTarget) => { + if (!editor) return; + const range = findReviewRange(editor.state, target.selector); + if (!range) { + reportError(new ReviewContractError('selector_out_of_range')); + return; + } + editor.commands.setTextSelection(range); + editor.commands.focus(); + }, + [editor, reportError], + ); + + return { finalizedIdsRef, onReviewAction, onReviewSelect }; +} diff --git a/src/components/useEditorHandle.ts b/src/components/useEditorHandle.ts index 710fe883..6cf94217 100644 --- a/src/components/useEditorHandle.ts +++ b/src/components/useEditorHandle.ts @@ -4,6 +4,7 @@ import { type ForwardedRef, type MutableRefObject, } from 'react'; +import { useRef } from 'react'; import { createDocumentEnvelope, type CwlEditorDocumentEnvelope, @@ -17,7 +18,10 @@ import { restoreDocumentEnvelopeBytesIfMatch, restoreDocumentEnvelopeIfMatch, } from '../documentEnvelopeIfMatch.js'; -import { createValidatedDocumentEnvelopeRevision } from '../documentEnvelopeRevision.js'; +import { + createValidatedDocumentEnvelopeRevision, + type DocumentEnvelopeDigestProvider, +} from '../documentEnvelopeRevision.js'; import { createValidatedDocumentEnvelopeRevisionEvidence } from '../documentRevisionEvidence.js'; import { restoreDocumentEnvelope, @@ -30,7 +34,15 @@ import { validateDocumentJson, } from '../documentSchema.js'; import { createTextPositionSelector } from '../textPositionSelectorEvidence.js'; -import type { CwlEditorHandle, EditorMode } from '../types.js'; +import { + ReviewContractError, + type CwlEditorReviewSuggestion, +} from '../review/contract.js'; +import type { + CwlEditorHandle, + EditorMode, +} from '../types.js'; +import { applyReviewOperation } from './reviewOperations.js'; import { createEditorDocumentSnapshot } from './editorDocumentSnapshot.js'; import { editorHtmlToValue, editorValueToHtml } from './editorSerialization.js'; @@ -47,7 +59,31 @@ export function useEditorHandle( ref: ForwardedRef, editor: Editor | null, modeRef: MutableRefObject, + finalizedReviewIdsRef?: MutableRefObject>, ): void { + const localFinalizedReviewIdsRef = useRef(new Set()); + const finalizedIdsRef = finalizedReviewIdsRef ?? localFinalizedReviewIdsRef; + const runReviewOperation = async ( + suggestion: CwlEditorReviewSuggestion, + action: 'accept' | 'reject', + limits?: DocumentEnvelopeLimits, + digestProvider?: DocumentEnvelopeDigestProvider | null, + ) => { + if (!editor) return null; + if (finalizedIdsRef.current.has(suggestion.suggestionId)) { + throw new ReviewContractError('operation_already_final'); + } + const result = await applyReviewOperation( + editor, + { suggestion, action }, + limits, + digestProvider, + ); + if (result.status !== 'stale') { + finalizedIdsRef.current.add(suggestion.suggestionId); + } + return result; + }; useImperativeHandle( ref, (): CwlEditorHandle => ({ @@ -196,11 +232,15 @@ export function useEditorHandle( if (!editor) return; editor.chain().focus().insertContent(documentJson).run(); }, + acceptReviewSuggestion: (suggestion, limits, digestProvider) => + runReviewOperation(suggestion, 'accept', limits, digestProvider), + rejectReviewSuggestion: (suggestion, limits, digestProvider) => + runReviewOperation(suggestion, 'reject', limits, digestProvider), clear: () => { editor?.commands.clearContent(true); }, isEmpty: () => editor?.isEmpty ?? true, }), - [editor, modeRef], + [editor, finalizedIdsRef, modeRef], ); } diff --git a/src/index.ts b/src/index.ts index f7905449..00a8e46c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ export type { CwlEditorFormResetEvent, CwlEditorHandle, CwlEditorProps, + CwlEditorReviewProps, CwlEditorSelectionEvent, CwlEditorSelectionRevisionEvidence, CwlEditorSelectionSnapshot, @@ -50,6 +51,33 @@ export type { TextPositionSelectorEvidenceErrorCode, } from './textPositionSelectorEvidence.js'; +// Provider-neutral revision-scoped review contract. Comment bodies, durable +// identifiers, authorization, persistence, and collaboration transport remain +// host-owned; this surface owns bounded deterministic target and operation +// validation plus revision-only transition evidence. +export { + DEFAULT_REVIEW_LIMITS, + REVIEW_CONTRACT_SCHEMA_ID, + REVIEW_CONTRACT_SCHEMA_VERSION, + ReviewContractError, + applyReviewSuggestionToTextProjection, + createReviewOperationResult, + validateReviewSuggestion, + validateReviewTarget, + validateReviewThread, +} from './review/contract.js'; +export type { + CwlEditorDeleteSuggestion, + CwlEditorInsertSuggestion, + CwlEditorReviewOperation, + CwlEditorReviewOperationResult, + CwlEditorReviewSuggestion, + CwlEditorReviewTarget, + CwlEditorReviewThread, + ReviewContractErrorCode, + ReviewSuggestionState, +} from './review/contract.js'; + // Versioned, lossless persistence boundary. export { DEFAULT_DOCUMENT_ENVELOPE_LIMITS, diff --git a/src/releaseBrowserEvidence.test.ts b/src/releaseBrowserEvidence.test.ts index 643c6f1d..74ea0b34 100644 --- a/src/releaseBrowserEvidence.test.ts +++ b/src/releaseBrowserEvidence.test.ts @@ -23,6 +23,7 @@ const browserHarness = repositoryFile('tests/browser/harness.ts'); const browserEvidenceContract = repositoryFile('tests/browser/evidenceContract.ts'); const browserSpec = repositoryFile('tests/browser/specs/clipboard.browser.spec.ts'); const consensusSpec = repositoryFile('tests/browser/specs/clipboard.consensus.spec.ts'); +const reviewSpec = repositoryFile('tests/browser/specs/review.browser.spec.ts'); describe('release cross-engine browser evidence contract', () => { it('requires the release tag commit to equal the current protected main tip', () => { @@ -108,6 +109,7 @@ describe('release cross-engine browser evidence contract', () => { it('binds browser evidence to one fresh run, current lock, and packed package bytes', () => { expect(playwrightConfig).toContain("globalSetup: './globalSetup.ts'"); + expect(playwrightConfig).toContain('clipboard|print|review'); expect(browserHarness).toContain("from 'inkspan-browser-under-test'"); expect(browserEvidenceContract).toContain("createReadStream(resolve(releaseDirectory"); expect(browserEvidenceContract).toContain("entry.name.endsWith('.tgz')"); @@ -129,6 +131,9 @@ describe('release cross-engine browser evidence contract', () => { expect(consensusSpec).toContain('currentPackageSha256'); expect(consensusSpec).toContain('item.packageSha256'); expect(consensusSpec).toContain('packedPackageSha256(repositoryRoot)'); + expect(reviewSpec).toContain('window.mountInkspanReviewProbe()'); + expect(reviewSpec).toContain("data-review-status', 'stale'"); + expect(reviewSpec).toContain("emulateMedia({ media: 'print' })"); }); it('makes immutable publication depend on both artifacts and tagged browser evidence', () => { diff --git a/src/review/contract.test.ts b/src/review/contract.test.ts new file mode 100644 index 00000000..c6847d20 --- /dev/null +++ b/src/review/contract.test.ts @@ -0,0 +1,366 @@ +import { describe, expect, it } from 'vitest'; + +import { + DOCUMENT_ENVELOPE_SCHEMA_ID, + DOCUMENT_ENVELOPE_SCHEMA_VERSION, +} from '../documentEnvelope.js'; +import type { CwlEditorDocumentRevision } from '../documentEnvelopeRevision.js'; +import { + applyReviewSuggestionToTextProjection, + createReviewOperationResult, + DEFAULT_REVIEW_LIMITS, + REVIEW_CONTRACT_SCHEMA_ID, + REVIEW_CONTRACT_SCHEMA_VERSION, + ReviewContractError, + validateReviewSuggestion, + validateReviewTarget, + validateReviewThread, +} from './contract.js'; + +function revision(fill: number): CwlEditorDocumentRevision { + const digestHex = fill.toString(16).padStart(2, '0').repeat(32); + return Object.freeze({ + algorithm: 'SHA-256', + digestHex, + strongEntityTag: `"sha256-${digestHex}"`, + }); +} + +function target(start: number, end: number, current = revision(1)) { + return { + revision: current, + selector: { type: 'TextPositionSelector' as const, start, end }, + textProjection: { id: 'inkspan-prosemirror-text' as const, version: 1 as const }, + }; +} + +function insertSuggestion( + start: number, + end: number, + text = 'new', + current = revision(1), + state: 'pending' | 'accepted' | 'rejected' = 'pending', +) { + return { + suggestionId: 'suggestion-1', + kind: 'insert' as const, + state, + expectedRevision: current, + target: target(start, end, current), + text, + }; +} + +function deleteSuggestion( + start: number, + end: number, + current = revision(1), +) { + return { + suggestionId: 'suggestion-1', + kind: 'delete' as const, + state: 'pending' as const, + expectedRevision: current, + target: target(start, end, current), + }; +} + +function envelope(reviewed = false) { + return { + schemaId: DOCUMENT_ENVELOPE_SCHEMA_ID, + schemaVersion: DOCUMENT_ENVELOPE_SCHEMA_VERSION, + documentJson: reviewed + ? { type: 'doc', attrs: { reviewed: true } } + : { type: 'doc' }, + } as const; +} + +function sequenceProvider(...fills: number[]) { + let index = 0; + return { + async digest() { + const fill = fills[index++]; + if (fill === undefined) throw new Error('digest sequence exhausted'); + return new Uint8Array(32).fill(fill).buffer; + }, + }; +} + +describe('provider-neutral review contract', () => { + it('exports bounded schema identity and validates frozen target/thread metadata', () => { + expect(REVIEW_CONTRACT_SCHEMA_ID).toBe('https://inkspan.io/schemas/review/v1'); + expect(REVIEW_CONTRACT_SCHEMA_VERSION).toBe(1); + expect(DEFAULT_REVIEW_LIMITS.maxThreadCount).toBe(10_000); + + const validatedTarget = validateReviewTarget(target(1, 2)); + expect(Object.isFrozen(validatedTarget)).toBe(true); + expect(Object.isFrozen(validatedTarget.revision)).toBe(true); + expect(Object.isFrozen(validatedTarget.selector)).toBe(true); + expect(Object.isFrozen(validatedTarget.textProjection)).toBe(true); + + const thread = validateReviewThread({ + threadId: 'thread-1', + target: target(1, 2), + state: 'open', + replyCount: 2, + }); + expect(thread).toMatchObject({ + threadId: 'thread-1', + state: 'open', + replyCount: 2, + }); + expect(Object.isFrozen(thread)).toBe(true); + }); + + it('rejects malformed, accessor-backed, unsupported, and over-limit metadata', () => { + expect(() => validateReviewTarget(null)).toThrowError( + new ReviewContractError('invalid_review_contract'), + ); + expect(() => + validateReviewTarget({ + ...target(0, 0), + revision: { ...revision(1), digestHex: 'not-a-digest' }, + }), + ).toThrowError(new ReviewContractError('invalid_review_contract')); + const customPrototype = Object.create({ inherited: true }) as Record< + string, + unknown + >; + Object.assign(customPrototype, target(0, 0)); + expect(() => validateReviewTarget(customPrototype)).toThrowError( + new ReviewContractError('invalid_review_contract'), + ); + const throwingPrototype = new Proxy( + {}, + { getPrototypeOf: () => { throw new Error('untrusted input'); } }, + ); + expect(() => validateReviewTarget(throwingPrototype)).toThrowError( + new ReviewContractError('invalid_review_contract'), + ); + expect(() => validateReviewTarget({ ...target(2, 1) })).toThrowError( + new ReviewContractError('invalid_review_contract'), + ); + expect(() => + validateReviewTarget({ + ...target(0, 0), + textProjection: { id: 'other', version: 1 }, + }), + ).toThrowError(new ReviewContractError('unsupported_projection')); + + const accessorTarget = {} as Record; + Object.defineProperty(accessorTarget, 'revision', { + get: () => target(0, 0).revision, + }); + expect(() => validateReviewTarget(accessorTarget)).toThrowError( + new ReviewContractError('invalid_review_contract'), + ); + + expect(() => + validateReviewThread({ + threadId: '', + target: target(0, 0), + state: 'open', + replyCount: 0, + }), + ).toThrowError(new ReviewContractError('invalid_review_contract')); + expect(() => + validateReviewThread({ + threadId: 'thread-1', + target: target(0, 0), + state: 'other', + replyCount: 0, + }), + ).toThrowError(new ReviewContractError('invalid_review_contract')); + expect(() => + validateReviewThread( + { + threadId: 'thread-1', + target: target(0, 0), + state: 'resolved', + replyCount: 10_001, + }, + { ...DEFAULT_REVIEW_LIMITS, maxReplyCount: 10_000 }, + ), + ).toThrowError(new ReviewContractError('invalid_review_contract')); + }); + + it('validates insert/delete records and rejects ambiguous suggestion shapes', () => { + expect(validateReviewSuggestion(insertSuggestion(2, 2))).toMatchObject({ + kind: 'insert', + text: 'new', + }); + expect(validateReviewSuggestion(deleteSuggestion(1, 3))).toMatchObject({ + kind: 'delete', + }); + expect( + validateReviewSuggestion(insertSuggestion(0, 0, 'x', revision(1), 'accepted')), + ).toMatchObject({ state: 'accepted' }); + expect( + validateReviewSuggestion(insertSuggestion(0, 0, 'x', revision(1), 'rejected')), + ).toMatchObject({ state: 'rejected' }); + + expect(() => validateReviewSuggestion(insertSuggestion(0, 1))).toThrowError( + new ReviewContractError('invalid_review_contract'), + ); + expect(() => validateReviewSuggestion(insertSuggestion(0, 0, ''))).toThrowError( + new ReviewContractError('invalid_review_contract'), + ); + expect(() => validateReviewSuggestion(deleteSuggestion(1, 1))).toThrowError( + new ReviewContractError('invalid_review_contract'), + ); + expect(() => + validateReviewSuggestion({ + ...deleteSuggestion(1, 2), + kind: 'unknown', + }), + ).toThrowError(new ReviewContractError('invalid_review_contract')); + expect(() => + validateReviewSuggestion({ + ...deleteSuggestion(1, 2), + target: target(1, 2, revision(2)), + }), + ).toThrowError(new ReviewContractError('invalid_review_contract')); + expect(() => + validateReviewSuggestion({ + ...insertSuggestion(0, 0), + text: 'x'.repeat(DEFAULT_REVIEW_LIMITS.maxSuggestionTextCodeUnits + 1), + }), + ).toThrowError(new ReviewContractError('invalid_review_contract')); + }); + + it('applies Unicode-code-point insert/delete operations and fails closed', () => { + const current = revision(1); + expect( + applyReviewSuggestionToTextProjection( + 'A😀B', + current, + insertSuggestion(1, 1, 'x', current), + ), + ).toBe('Ax😀B'); + expect( + applyReviewSuggestionToTextProjection( + 'A😀B', + current, + deleteSuggestion(1, 2, current), + ), + ).toBe('AB'); + expect( + applyReviewSuggestionToTextProjection( + 'A😀B', + current, + insertSuggestion(1, 1, 'x', current), + 'reject', + ), + ).toBe('A😀B'); + expect(() => + applyReviewSuggestionToTextProjection( + 'A', + current, + insertSuggestion(0, 0, 'x', current), + 'other' as 'accept', + ), + ).toThrowError(new ReviewContractError('invalid_review_contract')); + expect(() => + applyReviewSuggestionToTextProjection( + 'A', + revision(2), + insertSuggestion(0, 0, 'x'), + ), + ).toThrowError(new ReviewContractError('stale_revision')); + expect(() => + applyReviewSuggestionToTextProjection( + 'A', + current, + insertSuggestion(4, 4, 'x'), + ), + ).toThrowError(new ReviewContractError('selector_out_of_range')); + expect(() => + applyReviewSuggestionToTextProjection( + 'A', + current, + insertSuggestion(0, 0, 'x', current, 'accepted'), + ), + ).toThrowError(new ReviewContractError('operation_already_final')); + }); + + it('returns exact accepted, rejected, and stale transition results without bodies', async () => { + await expect( + createReviewOperationResult( + { + suggestion: insertSuggestion(0, 0), + action: 'other' as 'accept', + }, + envelope(), + envelope(true), + ), + ).rejects.toThrowError(new ReviewContractError('invalid_review_contract')); + await expect( + createReviewOperationResult( + { + suggestion: insertSuggestion(0, 0, 'x', revision(1), 'accepted'), + action: 'accept', + }, + envelope(), + envelope(true), + ), + ).rejects.toThrowError(new ReviewContractError('operation_already_final')); + const accepted = await createReviewOperationResult( + { suggestion: insertSuggestion(0, 0), action: 'accept' }, + envelope(), + envelope(true), + undefined, + sequenceProvider(1, 2), + ); + expect(accepted).toMatchObject({ + schemaId: REVIEW_CONTRACT_SCHEMA_ID, + schemaVersion: REVIEW_CONTRACT_SCHEMA_VERSION, + status: 'accepted', + action: 'accept', + beforeRevision: revision(1), + resultingRevision: revision(2), + }); + expect(accepted.transitionEvidence?.changed).toBe(true); + expect(accepted).not.toHaveProperty('documentJson'); + + const rejected = await createReviewOperationResult( + { suggestion: deleteSuggestion(1, 2), action: 'reject' }, + envelope(), + envelope(), + undefined, + sequenceProvider(1, 1), + ); + expect(rejected.status).toBe('rejected'); + expect(rejected.transitionEvidence?.changed).toBe(false); + + const stale = await createReviewOperationResult( + { suggestion: insertSuggestion(0, 0, 'x', revision(9)), action: 'accept' }, + envelope(), + envelope(true), + undefined, + sequenceProvider(1, 2), + ); + expect(stale).toMatchObject({ status: 'stale', beforeRevision: revision(1) }); + expect(stale).not.toHaveProperty('transitionEvidence'); + + await expect( + createReviewOperationResult( + { suggestion: insertSuggestion(0, 0, 'x', revision(1)), action: 'accept' }, + envelope(), + envelope(), + undefined, + sequenceProvider(1, 1), + ), + ).rejects.toThrowError(new ReviewContractError('operation_must_change_document')); + await expect( + createReviewOperationResult( + { suggestion: deleteSuggestion(1, 2), action: 'reject' }, + envelope(), + envelope(true), + undefined, + sequenceProvider(1, 2), + ), + ).rejects.toThrowError( + new ReviewContractError('rejected_operation_changed_document'), + ); + }); +}); diff --git a/src/review/contract.ts b/src/review/contract.ts new file mode 100644 index 00000000..2a0b7394 --- /dev/null +++ b/src/review/contract.ts @@ -0,0 +1,459 @@ +import type { + CwlEditorDocumentEnvelope, + DocumentEnvelopeLimits, +} from '../documentEnvelope.js'; +import { + type CwlEditorDocumentRevision, + type DocumentEnvelopeDigestProvider, +} from '../documentEnvelopeRevision.js'; +import { createDocumentEnvelopeTransitionEvidence } from '../documentTransitionEvidence.js'; +import type { + CwlEditorTextPositionSelector, + CwlEditorTextProjectionIdentity, +} from '../textPositionSelectorEvidence.js'; + +/** Stable identifier for the first provider-neutral review contract. */ +export const REVIEW_CONTRACT_SCHEMA_ID = + 'https://inkspan.io/schemas/review/v1' as const; + +/** Current provider-neutral review contract version. */ +export const REVIEW_CONTRACT_SCHEMA_VERSION = 1 as const; + +/** Bounded limits for host-supplied review metadata and suggestion text. */ +export const DEFAULT_REVIEW_LIMITS = Object.freeze({ + maxIdentifierCodeUnits: 256, + maxSuggestionTextCodeUnits: 1_048_576, + maxThreadCount: 10_000, + maxReplyCount: 10_000, +}); + +/** Stable public failure classifications for review contract validation. */ +export type ReviewContractErrorCode = + | 'invalid_review_contract' + | 'unsupported_projection' + | 'selector_out_of_range' + | 'stale_revision' + | 'operation_already_final' + | 'operation_must_change_document' + | 'rejected_operation_changed_document'; + +/** Redacted error raised when a review value cannot satisfy the contract. */ +export class ReviewContractError extends TypeError { + /** Stable machine-readable review failure classification. */ + readonly code: ReviewContractErrorCode; + + /** Create one redacted review-contract error without retaining input data. */ + constructor(code: ReviewContractErrorCode) { + super(REVIEW_ERROR_MESSAGES[code]); + this.name = 'ReviewContractError'; + this.code = code; + } +} + +/** Exact revision and selector target for one review thread or suggestion. */ +export interface CwlEditorReviewTarget { + /** SHA-256 revision of the document state containing this target. */ + readonly revision: CwlEditorDocumentRevision; + /** W3C selector in the named deterministic Inkspan projection. */ + readonly selector: CwlEditorTextPositionSelector; + /** Projection identity required to interpret selector offsets. */ + readonly textProjection: CwlEditorTextProjectionIdentity; +} + +/** Host-supplied thread presentation state; comment bodies remain host-owned. */ +export interface CwlEditorReviewThread { + /** Opaque host-owned durable thread identifier. */ + readonly threadId: string; + /** Exact revision-scoped location rendered by the editor. */ + readonly target: CwlEditorReviewTarget; + /** Whether the host currently presents the thread as resolved. */ + readonly state: 'open' | 'resolved'; + /** Number of host-owned replies, without exposing their bodies. */ + readonly replyCount: number; +} + +/** Lifecycle state of one host-owned insert/delete suggestion. */ +export type ReviewSuggestionState = 'pending' | 'accepted' | 'rejected'; + +/** A deterministic text insertion suggestion at an empty target range. */ +export interface CwlEditorInsertSuggestion { + /** Opaque host-owned durable suggestion identifier. */ + readonly suggestionId: string; + readonly kind: 'insert'; + readonly state: ReviewSuggestionState; + /** Revision the host used when creating the suggestion. */ + readonly expectedRevision: CwlEditorDocumentRevision; + /** Exact insertion target; its selector must have equal start and end. */ + readonly target: CwlEditorReviewTarget; + /** Text proposed for deterministic insertion. */ + readonly text: string; +} + +/** A deterministic deletion suggestion over one non-empty target range. */ +export interface CwlEditorDeleteSuggestion { + /** Opaque host-owned durable suggestion identifier. */ + readonly suggestionId: string; + readonly kind: 'delete'; + readonly state: ReviewSuggestionState; + /** Revision the host used when creating the suggestion. */ + readonly expectedRevision: CwlEditorDocumentRevision; + /** Exact non-empty deletion target. */ + readonly target: CwlEditorReviewTarget; +} + +/** First review vertical slice: deterministic insert and delete only. */ +export type CwlEditorReviewSuggestion = + | CwlEditorInsertSuggestion + | CwlEditorDeleteSuggestion; + +/** One host-requested operation against a pending suggestion. */ +export interface CwlEditorReviewOperation { + /** Suggestion selected by the host/user. */ + readonly suggestion: CwlEditorReviewSuggestion; + /** Apply or discard the proposed operation. */ + readonly action: 'accept' | 'reject'; +} + +/** Result of one exact-revision review operation without document bodies. */ +export interface CwlEditorReviewOperationResult { + /** Stable contract identifier. */ + readonly schemaId: typeof REVIEW_CONTRACT_SCHEMA_ID; + /** Stable contract version. */ + readonly schemaVersion: typeof REVIEW_CONTRACT_SCHEMA_VERSION; + /** Opaque suggestion identifier copied from the host request. */ + readonly suggestionId: string; + /** Operation requested by the host/user. */ + readonly action: 'accept' | 'reject'; + /** Whether the operation was applied, rejected, or blocked as stale. */ + readonly status: 'accepted' | 'rejected' | 'stale'; + /** Actual revision observed before the operation. */ + readonly beforeRevision: CwlEditorDocumentRevision; + /** Resulting revision only when the operation was applied or rejected. */ + readonly resultingRevision?: CwlEditorDocumentRevision; + /** Compact before/after evidence, never the document or comment body. */ + readonly transitionEvidence?: Readonly<{ + readonly previousRevision: CwlEditorDocumentRevision; + readonly resultingRevision: CwlEditorDocumentRevision; + readonly changed: boolean; + }>; +} + +const REVIEW_ERROR_MESSAGES: Record = { + invalid_review_contract: 'Review input does not match the supported contract', + unsupported_projection: 'Review target uses an unsupported text projection', + selector_out_of_range: 'Review selector is outside the supplied text projection', + stale_revision: 'Review operation targets a stale document revision', + operation_already_final: 'Review suggestion has already reached a final state', + operation_must_change_document: 'Accepted review operation must change the document', + rejected_operation_changed_document: + 'Rejected review operation must preserve the document revision', +}; + +/** Validate and freeze one review target without exposing source values in errors. */ +export function validateReviewTarget( + value: unknown, +): CwlEditorReviewTarget { + return withRedactedReviewErrors(() => { + const record = requireRecord(value); + const revision = validateRevision(recordValue(record, 'revision')); + const selector = validateSelector(recordValue(record, 'selector')); + const textProjection = validateProjection( + recordValue(record, 'textProjection'), + ); + return Object.freeze({ revision, selector, textProjection }); + }); +} + +/** Validate and freeze host-owned thread presentation metadata. */ +export function validateReviewThread( + value: unknown, + limits = DEFAULT_REVIEW_LIMITS, +): CwlEditorReviewThread { + return withRedactedReviewErrors(() => { + const record = requireRecord(value); + const threadId = validateIdentifier( + recordValue(record, 'threadId'), + limits.maxIdentifierCodeUnits, + ); + const target = validateReviewTarget(recordValue(record, 'target')); + const state = recordValue(record, 'state'); + const replyCount = recordValue(record, 'replyCount'); + if ( + (state !== 'open' && state !== 'resolved') || + !isSafeInteger(replyCount) || + replyCount < 0 || + replyCount > limits.maxReplyCount + ) { + throw new ReviewContractError('invalid_review_contract'); + } + return Object.freeze({ + threadId, + target, + state, + replyCount, + }); + }); +} + +/** Validate and freeze one insert/delete suggestion record. */ +export function validateReviewSuggestion( + value: unknown, + limits = DEFAULT_REVIEW_LIMITS, +): CwlEditorReviewSuggestion { + return withRedactedReviewErrors(() => { + const record = requireRecord(value); + const suggestionId = validateIdentifier( + recordValue(record, 'suggestionId'), + limits.maxIdentifierCodeUnits, + ); + const kind = recordValue(record, 'kind'); + const state = recordValue(record, 'state'); + const expectedRevision = validateRevision( + recordValue(record, 'expectedRevision'), + ); + const target = validateReviewTarget(recordValue(record, 'target')); + if (!isSuggestionState(state) || !sameRevision(expectedRevision, target.revision)) { + throw new ReviewContractError('invalid_review_contract'); + } + if (kind === 'insert') { + const text = recordValue(record, 'text'); + if ( + typeof text !== 'string' || + text.length === 0 || + text.length > limits.maxSuggestionTextCodeUnits || + target.selector.start !== target.selector.end + ) { + throw new ReviewContractError('invalid_review_contract'); + } + return Object.freeze({ + suggestionId, + kind, + state, + expectedRevision, + target, + text, + }); + } + if ( + kind === 'delete' && + target.selector.start < target.selector.end + ) { + return Object.freeze({ + suggestionId, + kind, + state, + expectedRevision, + target, + }); + } + throw new ReviewContractError('invalid_review_contract'); + }); +} + +/** + * Apply one accepted/rejected suggestion to a text projection. + * + * This helper is intentionally projection-only: it does not pretend that a + * text result is an editor transaction or a durable revision. The interactive + * layer must apply the same operation through its editor transaction and use + * `createReviewOperationResult` for exact before/after evidence. + */ +export function applyReviewSuggestionToTextProjection( + source: string, + currentRevision: CwlEditorDocumentRevision, + value: unknown, + action: 'accept' | 'reject' = 'accept', +): string { + const suggestion = validateReviewSuggestion(value); + if (action !== 'accept' && action !== 'reject') { + throw new ReviewContractError('invalid_review_contract'); + } + if (suggestion.state !== 'pending') { + throw new ReviewContractError('operation_already_final'); + } + if (!sameRevision(currentRevision, suggestion.expectedRevision)) { + throw new ReviewContractError('stale_revision'); + } + const codePoints = Array.from(source); + const { start, end } = suggestion.target.selector; + if (start > codePoints.length || end > codePoints.length) { + throw new ReviewContractError('selector_out_of_range'); + } + if (action === 'reject') return source; + if (suggestion.kind === 'insert') { + return `${codePoints.slice(0, start).join('')}${suggestion.text}${codePoints + .slice(end) + .join('')}`; + } + return `${codePoints.slice(0, start).join('')}${codePoints + .slice(end) + .join('')}`; +} + +/** + * Bind an editor-produced before/after envelope pair to a review operation. + * + * The result contains only revisions and transition metadata. A stale expected + * revision is returned as a stable result so hosts can refetch or re-review; + * it is never silently re-anchored. + */ +export async function createReviewOperationResult( + operation: CwlEditorReviewOperation, + previousSource: CwlEditorDocumentEnvelope | string, + resultingSource: CwlEditorDocumentEnvelope | string, + limits?: DocumentEnvelopeLimits, + digestProvider?: DocumentEnvelopeDigestProvider | null, +): Promise { + const validatedOperation = validateReviewOperation(operation); + const suggestion = validatedOperation.suggestion; + if (suggestion.state !== 'pending') { + throw new ReviewContractError('operation_already_final'); + } + const transition = await createDocumentEnvelopeTransitionEvidence( + previousSource, + resultingSource, + limits, + digestProvider, + ); + if (!sameRevision(transition.previousRevision, suggestion.expectedRevision)) { + return Object.freeze({ + schemaId: REVIEW_CONTRACT_SCHEMA_ID, + schemaVersion: REVIEW_CONTRACT_SCHEMA_VERSION, + suggestionId: suggestion.suggestionId, + action: validatedOperation.action, + status: 'stale', + beforeRevision: transition.previousRevision, + }); + } + if (validatedOperation.action === 'accept' && !transition.changed) { + throw new ReviewContractError('operation_must_change_document'); + } + if (validatedOperation.action === 'reject' && transition.changed) { + throw new ReviewContractError('rejected_operation_changed_document'); + } + return Object.freeze({ + schemaId: REVIEW_CONTRACT_SCHEMA_ID, + schemaVersion: REVIEW_CONTRACT_SCHEMA_VERSION, + suggestionId: suggestion.suggestionId, + action: validatedOperation.action, + status: validatedOperation.action === 'accept' ? 'accepted' : 'rejected', + beforeRevision: transition.previousRevision, + resultingRevision: transition.resultingRevision, + transitionEvidence: transition, + }); +} + +function validateReviewOperation(value: unknown): CwlEditorReviewOperation { + return withRedactedReviewErrors(() => { + const record = requireRecord(value); + const action = recordValue(record, 'action'); + if (action !== 'accept' && action !== 'reject') { + throw new ReviewContractError('invalid_review_contract'); + } + return Object.freeze({ + suggestion: validateReviewSuggestion(recordValue(record, 'suggestion')), + action, + }); + }); +} + +function validateRevision(value: unknown): CwlEditorDocumentRevision { + const record = requireRecord(value); + const algorithm = recordValue(record, 'algorithm'); + const digestHex = recordValue(record, 'digestHex'); + const strongEntityTag = recordValue(record, 'strongEntityTag'); + if ( + algorithm !== 'SHA-256' || + typeof digestHex !== 'string' || + !/^[0-9a-f]{64}$/u.test(digestHex) || + strongEntityTag !== `"sha256-${digestHex}"` + ) { + throw new ReviewContractError('invalid_review_contract'); + } + return Object.freeze({ algorithm, digestHex, strongEntityTag }); +} + +function validateSelector(value: unknown): CwlEditorTextPositionSelector { + const record = requireRecord(value); + const type = recordValue(record, 'type'); + const start = recordValue(record, 'start'); + const end = recordValue(record, 'end'); + if ( + type !== 'TextPositionSelector' || + !isSafeInteger(start) || + !isSafeInteger(end) || + start < 0 || + end < start + ) { + throw new ReviewContractError('invalid_review_contract'); + } + return Object.freeze({ type, start, end }); +} + +function validateProjection(value: unknown): CwlEditorTextProjectionIdentity { + const record = requireRecord(value); + const id = recordValue(record, 'id'); + const version = recordValue(record, 'version'); + if (id !== 'inkspan-prosemirror-text' || version !== 1) { + throw new ReviewContractError('unsupported_projection'); + } + return Object.freeze({ id, version }); +} + +function validateIdentifier(value: unknown, maxLength: number): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > maxLength + ) { + throw new ReviewContractError('invalid_review_contract'); + } + return value; +} + +function requireRecord(value: unknown): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new ReviewContractError('invalid_review_contract'); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new ReviewContractError('invalid_review_contract'); + } + return value as Record; +} + +function recordValue(record: Record, key: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(record, key); + if (!descriptor || !('value' in descriptor)) { + throw new ReviewContractError('invalid_review_contract'); + } + return descriptor.value; +} + +function isSafeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value); +} + +function isSuggestionState(value: unknown): value is ReviewSuggestionState { + return value === 'pending' || value === 'accepted' || value === 'rejected'; +} + +function sameRevision( + left: CwlEditorDocumentRevision, + right: CwlEditorDocumentRevision, +): boolean { + return ( + left.algorithm === right.algorithm && + left.digestHex === right.digestHex && + left.strongEntityTag === right.strongEntityTag + ); +} + +function withRedactedReviewErrors(operation: () => T): T { + try { + return operation(); + } catch (error) { + if (error instanceof ReviewContractError) throw error; + throw new ReviewContractError('invalid_review_contract'); + } +} diff --git a/src/review/index.ts b/src/review/index.ts index 09d62982..bcf46470 100644 --- a/src/review/index.ts +++ b/src/review/index.ts @@ -336,3 +336,33 @@ export type { CwlEditorDocumentRevision, DocumentEnvelopeDigestProvider, } from '../documentEnvelopeRevision.js'; +/** + * Framework-independent Inkspan review contract. + * + * The package validates bounded revision-scoped target metadata and + * deterministic insert/delete operation results. Comment bodies, durable + * identifiers, authorization, persistence, and collaboration transport stay + * with the host. + */ +export { + DEFAULT_REVIEW_LIMITS, + REVIEW_CONTRACT_SCHEMA_ID, + REVIEW_CONTRACT_SCHEMA_VERSION, + ReviewContractError, + applyReviewSuggestionToTextProjection, + createReviewOperationResult, + validateReviewSuggestion, + validateReviewTarget, + validateReviewThread, +} from './contract.js'; +export type { + CwlEditorDeleteSuggestion, + CwlEditorInsertSuggestion, + CwlEditorReviewOperation, + CwlEditorReviewOperationResult, + CwlEditorReviewSuggestion, + CwlEditorReviewTarget, + CwlEditorReviewThread, + ReviewContractErrorCode, + ReviewSuggestionState, +} from './contract.js'; diff --git a/src/review/package.test.ts b/src/review/package.test.ts new file mode 100644 index 00000000..0aea1611 --- /dev/null +++ b/src/review/package.test.ts @@ -0,0 +1,57 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_REVIEW_LIMITS, + REVIEW_CONTRACT_SCHEMA_ID, + REVIEW_CONTRACT_SCHEMA_VERSION, +} from './index.js'; + +const repositoryFile = (path: string): string => + readFileSync(resolve(process.cwd(), path), 'utf8'); + +const packageMetadata = JSON.parse(repositoryFile('package.json')) as { + exports: Record; + scripts: Record; +}; + +describe('framework-independent review package contract', () => { + it('exposes the bounded versioned source entrypoint', () => { + expect(REVIEW_CONTRACT_SCHEMA_ID).toBe('https://inkspan.io/schemas/review/v1'); + expect(REVIEW_CONTRACT_SCHEMA_VERSION).toBe(1); + expect(DEFAULT_REVIEW_LIMITS.maxSuggestionTextCodeUnits).toBe(1_048_576); + }); + + it('declares independently consumable ESM CommonJS and TypeScript outputs', () => { + expect(packageMetadata.exports['./review']).toEqual({ + types: './dist/review/index.d.ts', + import: './dist/cwl-review.js', + require: './dist/cwl-review.cjs', + }); + expect(packageMetadata.scripts.build).toContain( + 'vite build --config vite.review.config.ts', + ); + expect(packageMetadata.scripts['verify:package']).toContain( + 'verify-review-package.mjs', + ); + expect(existsSync(resolve(process.cwd(), 'vite.review.config.ts'))).toBe(true); + expect(existsSync(resolve(process.cwd(), 'src/review/index.ts'))).toBe(true); + }); + + it('keeps the public distribution guide honest about active implementation', () => { + const guide = repositoryFile('docs/package-distribution.md'); + expect(guide).toContain('`@contextualwisdomlab/cwl-editor/review`'); + expect(guide).toMatch( + /review`\s*\|\s*`implemented_on_active_pr`[^\n]*framework-independent/iu, + ); + }); + + it('binds the packed verifier to a provider-neutral runtime boundary', () => { + const verifier = repositoryFile('scripts/verify-review-package.mjs'); + expect(verifier).toContain('externalRuntimeImportPattern'); + expect(verifier).toContain('ambientAuthorityPattern'); + expect(verifier).toContain('strict TypeScript'); + expect(verifier).toContain('createReviewOperationResult'); + }); +}); diff --git a/src/sourceTextHygiene.test.ts b/src/sourceTextHygiene.test.ts index 2f6e7af4..0f55f32e 100644 --- a/src/sourceTextHygiene.test.ts +++ b/src/sourceTextHygiene.test.ts @@ -6,6 +6,10 @@ const TEXT_HYGIENE_TARGETS = [ ['root public entrypoint', 'src/index.ts'], ['revision-evidence entrypoint test', 'src/revision-evidence/index.test.ts'], ['revision-evidence build config', 'vite.revision-evidence.config.ts'], + ['review contract', 'src/review/contract.ts'], + ['review entrypoint', 'src/review/index.ts'], + ['review build config', 'vite.review.config.ts'], + ['review panel fixture', 'src/components/ReviewPanel.fixture.tsx'], ] as const; describe('source text hygiene', () => { diff --git a/src/styles.css b/src/styles.css index 0970a931..381e647e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -228,6 +228,86 @@ font-weight: 600; } +.cwl-review-panel { + border-bottom: 1px solid var(--cwl-border); + background: var(--cwl-surface); + padding: 10px 12px; + font-size: 13px; +} + +.cwl-review-panel__heading, +.cwl-review-panel h3 { + margin: 0; + font-size: 14px; +} + +.cwl-review-panel__section + .cwl-review-panel__section { + margin-top: 8px; +} + +.cwl-review-panel ul { + display: grid; + gap: 6px; + margin: 6px 0 0; + padding: 0; + list-style: none; +} + +.cwl-review-panel li { + display: grid; + gap: 4px; +} + +.cwl-review-panel__target { + border: 0; + padding: 0; + background: transparent; + color: var(--cwl-fg); + text-align: left; + text-decoration: underline; + cursor: pointer; +} + +.cwl-review-panel__target:focus-visible, +.cwl-review-panel__actions button:focus-visible { + outline: 2px solid var(--cwl-accent); + outline-offset: 2px; +} + +.cwl-review-panel__detail { + color: var(--cwl-muted); +} + +.cwl-review-panel__actions { + display: flex; + gap: 6px; +} + +.cwl-review-panel__actions button { + min-height: 28px; + border: 1px solid var(--cwl-border); + border-radius: 4px; + padding: 3px 8px; + background: var(--cwl-bg); + color: var(--cwl-fg); + cursor: pointer; +} + +.cwl-review-panel__actions button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.cwl-review-marker--thread { + border-bottom: 2px solid var(--cwl-accent); + background: var(--cwl-accent-soft); +} + +.cwl-review-marker--suggestion { + border-bottom: 2px dotted var(--cwl-accent); + background: var(--cwl-accent-soft); +} + .collaboration-cursor__caret { position: relative; border-left: 2px solid; @@ -278,12 +358,27 @@ } .cwl-toolbar, + .cwl-review-panel, .cwl-collaboration-status, .collaboration-cursor__caret, .collaboration-cursor__label { display: none !important; } + .cwl-review-marker--thread, + .cwl-review-marker--suggestion { + border-bottom: 0; + background: transparent; + } + + .cwl-review-marker--widget { + display: none; + } + + .cwl-review-marker--suggestion::before { + content: none; + } + .cwl-editor__surface { overflow: visible; max-height: none; diff --git a/src/types.ts b/src/types.ts index 0292d4a2..96f672e0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,12 @@ import type { ClipboardConfig, ClipboardSanitizationError, } from './extensions/SafeClipboard.js'; +import type { + CwlEditorReviewOperationResult, + CwlEditorReviewSuggestion, + CwlEditorReviewThread, + ReviewContractError, +} from './review/contract.js'; /** Which document surface the editor reads from and writes to. */ export type EditorMode = 'markdown' | 'html'; @@ -253,6 +259,18 @@ export interface CwlEditorHandle { * safe-link, and inline-image transaction boundaries as other editor writes. */ insertDocumentJson(documentJson: JSONContent | JSONContent[]): void; + /** Accept one pending host-supplied review suggestion at its exact revision. */ + acceptReviewSuggestion( + suggestion: CwlEditorReviewSuggestion, + limits?: DocumentEnvelopeLimits, + digestProvider?: DocumentEnvelopeDigestProvider | null, + ): Promise; + /** Reject one pending host-supplied review suggestion without changing content. */ + rejectReviewSuggestion( + suggestion: CwlEditorReviewSuggestion, + limits?: DocumentEnvelopeLimits, + digestProvider?: DocumentEnvelopeDigestProvider | null, + ): Promise; /** Empty the document. */ clear(): void; /** `true` when the document has no meaningful content. */ @@ -291,6 +309,8 @@ export interface CwlEditorProps { * ephemeral document coordinates, not DOM offsets or durable identifiers. */ onSelectionChange?: (selectionEvent: CwlEditorSelectionEvent) => void; + /** Controlled host-owned review metadata and deterministic operation callbacks. */ + review?: CwlEditorReviewProps; /** * Fired when an image **paste, drop, or toolbar upload** fails (size guard, * decode error, etc.). Wired through both the toolbar file picker and the @@ -376,3 +396,15 @@ export interface CwlEditorProps { /** Whether the host form requires editor input before submission. */ ariaRequired?: boolean; } + +/** Host-controlled review metadata; comment bodies and persistence stay external. */ +export interface CwlEditorReviewProps { + /** Revision-scoped comment-thread presentation metadata without comment bodies. */ + threads?: readonly CwlEditorReviewThread[]; + /** Revision-scoped deterministic insert/delete suggestions. */ + suggestions?: readonly CwlEditorReviewSuggestion[]; + /** Receives one accepted, rejected, or stale local operation result. */ + onOperation?: (result: CwlEditorReviewOperationResult) => void | Promise; + /** Receives redacted validation, stale-selection, or operation failures. */ + onError?: (error: ReviewContractError) => void; +} diff --git a/tests/browser/harness.ts b/tests/browser/harness.ts index c00b47a7..2654300b 100644 --- a/tests/browser/harness.ts +++ b/tests/browser/harness.ts @@ -1,4 +1,7 @@ import { Editor } from '@tiptap/core'; +import type { Editor as TiptapEditor } from '@tiptap/core'; +import { createElement, useRef, useState } from 'react'; +import { createRoot } from 'react-dom/client'; import { ClipboardSanitizationError, buildExtensions, @@ -6,6 +9,13 @@ import { type ClipboardConfig, type ClipboardSanitizationErrorCode, } from 'inkspan-browser-under-test'; +import { + CwlEditor, + createDocumentEnvelope, + createDocumentEnvelopeRevision, + type CwlEditorHandle, + type CwlEditorReviewProps, +} from 'inkspan-browser-under-test'; interface BrowserClipboardProbeRequest { readonly sourceHtml: string; @@ -31,6 +41,7 @@ declare global { runInkspanHostileDocumentProbe( sourceHtml: string, ): BrowserHostileDocumentProbeResult; + mountInkspanReviewProbe(): Promise; } } @@ -86,4 +97,87 @@ window.runInkspanHostileDocumentProbe = ( } }; +window.mountInkspanReviewProbe = async (): Promise => { + const harness = document.querySelector('#harness'); + if (!harness) throw new Error('Review harness root is missing.'); + harness.innerHTML = ''; + const mount = document.createElement('div'); + mount.id = 'review-probe'; + harness.append(mount); + + const Probe = () => { + const ref = useRef(null); + const [review, setReview] = useState(); + const onReady = async (editor: TiptapEditor) => { + const revision = await createDocumentEnvelopeRevision( + createDocumentEnvelope(editor.getJSON()), + ); + const target = { + revision, + selector: { type: 'TextPositionSelector' as const, start: 0, end: 0 }, + textProjection: { id: 'inkspan-prosemirror-text' as const, version: 1 as const }, + }; + setReview({ + suggestions: [ + { + suggestionId: 'review-accepted', + kind: 'insert', + state: 'pending', + expectedRevision: revision, + target, + text: 'X', + }, + { + suggestionId: 'review-stale', + kind: 'insert', + state: 'pending', + expectedRevision: revision, + target, + text: 'Y', + }, + ], + onOperation: (result) => { + mount.dataset.reviewStatus = result.status; + }, + onError: (error) => { + mount.dataset.reviewError = error.code; + }, + }); + }; + const markStale = () => ref.current?.setValue('Changed'); + return createElement( + 'div', + null, + createElement(CwlEditor, { + ref, + defaultValue: 'Hi', + onReady, + review, + }), + createElement( + 'button', + { type: 'button', 'data-review-make-stale': 'true', onClick: markStale }, + 'Make review stale', + ), + ); + }; + createRoot(mount).render(createElement(Probe)); + + await new Promise((resolve, reject) => { + const started = performance.now(); + const check = () => { + if (mount.querySelector('[aria-label="Document review"]')) { + resolve(); + return; + } + if (performance.now() - started > 10_000) { + reject(new Error('Review panel did not mount.')); + return; + } + window.setTimeout(check, 25); + }; + check(); + }); +}; + export {}; diff --git a/tests/browser/playwright.config.ts b/tests/browser/playwright.config.ts index 375056da..ed0dda56 100644 --- a/tests/browser/playwright.config.ts +++ b/tests/browser/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig, devices } from '@playwright/test'; const HARNESS_ORIGIN = 'http://127.0.0.1:4173'; const HARNESS_URL = `${HARNESS_ORIGIN}/tests/browser/harness.html`; -const ENGINE_BROWSER_SPECS = /(?:clipboard|print)\.browser\.spec\.ts/u; +const ENGINE_BROWSER_SPECS = /(?:clipboard|print|review)\.browser\.spec\.ts/u; export default defineConfig({ testDir: './specs', @@ -44,4 +44,4 @@ export default defineConfig({ dependencies: ['chromium', 'firefox', 'webkit'], }, ], -}); \ No newline at end of file +}); diff --git a/tests/browser/specs/review.browser.spec.ts b/tests/browser/specs/review.browser.spec.ts new file mode 100644 index 00000000..46470fc5 --- /dev/null +++ b/tests/browser/specs/review.browser.spec.ts @@ -0,0 +1,58 @@ +import { expect, test } from '@playwright/test'; + +const HARNESS_URL = 'http://127.0.0.1:4173/tests/browser/harness.html'; +const STYLES_URL = 'http://127.0.0.1:4173/dist/cwl-editor.css'; + +const allowHarnessRequest = (requestUrl: string): boolean => { + const url = new URL(requestUrl); + return url.hostname === '127.0.0.1' && url.port === '4173'; +}; + +test.beforeEach(async ({ page }) => { + const rejectedExternalRequests: string[] = []; + await page.route('**/*', async (route) => { + if (allowHarnessRequest(route.request().url())) { + await route.continue(); + return; + } + rejectedExternalRequests.push(new URL(route.request().url()).origin); + await route.abort('blockedbyclient'); + }); + await page.goto(HARNESS_URL); + await page.addStyleTag({ url: STYLES_URL }); + await page.evaluate(() => window.mountInkspanReviewProbe()); + expect(rejectedExternalRequests).toEqual([]); +}); + +test('runs exact-revision review, keyboard history, stale protection, and print states', async ({ page }) => { + const region = page.getByRole('region', { name: 'Document review' }); + await expect(region).toBeVisible(); + const editor = page.getByRole('textbox', { name: 'Rich text editor' }); + await expect(editor).toContainText('Hi'); + + const acceptedRow = region.locator('li').filter({ hasText: 'review-accepted' }); + await acceptedRow.getByRole('button', { name: 'Accept', exact: true }).press('Enter'); + await expect(page.locator('#review-probe')).toHaveAttribute('data-review-status', 'accepted'); + await expect(editor).toContainText('XHi'); + + await page.getByRole('button', { name: /Undo/ }).click(); + await expect(editor).toContainText('Hi'); + await page.getByRole('button', { name: /Redo/ }).click(); + await expect(editor).toContainText('XHi'); + + await page.getByRole('button', { name: 'Make review stale' }).click(); + const staleRow = region.locator('li').filter({ hasText: 'review-stale' }); + await staleRow.getByRole('button', { name: 'Accept', exact: true }).click(); + await expect(page.locator('#review-probe')).toHaveAttribute('data-review-status', 'stale'); + await expect(editor).toContainText('Changed'); + + await page.emulateMedia({ media: 'print' }); + await expect(region).toBeHidden(); + await expect(editor).toBeVisible(); + expect(await page.locator('[data-review-id="review-stale"]').count()).toBeGreaterThan(0); + expect( + await page.locator('[data-review-id="review-stale"]').evaluate((element) => + getComputedStyle(element).display, + ), + ).toBe('none'); +}); diff --git a/tests/package/verify-framework-free-autosave-package.mjs b/tests/package/verify-framework-free-autosave-package.mjs index 807dcc05..422ae78d 100644 --- a/tests/package/verify-framework-free-autosave-package.mjs +++ b/tests/package/verify-framework-free-autosave-package.mjs @@ -45,7 +45,10 @@ function run(command, argumentsList, cwd = repositoryRoot) { /** Assert that a resolved path remains inside the isolated consumer tree. */ function assertInsideConsumer(resolvedPath, description) { - const relativePath = relative(consumerDirectory, realpathSync(resolvedPath)); + const relativePath = relative( + realpathSync(consumerDirectory), + realpathSync(resolvedPath), + ); assert.equal(isAbsolute(relativePath), false, description); assert.equal( relativePath === '..' || relativePath.startsWith(`..${sep}`), diff --git a/vite.review.config.ts b/vite.review.config.ts index 420c5ff7..c4ea5a6b 100644 --- a/vite.review.config.ts +++ b/vite.review.config.ts @@ -2,14 +2,19 @@ import { resolve } from 'node:path'; import { defineConfig } from 'vite'; import dts from 'vite-plugin-dts'; -// React-free review contract build: deterministic revision/selector evidence only. -// No React UI, network, credential, persistence, tenancy, model, or provider authority. +// Framework-independent review contract build: ZERO React, TipTap, ProseMirror +// view, Yjs, network, credential, persistence, or model imports. export default defineConfig({ plugins: [ dts({ include: [ 'src/review', + 'src/documentEnvelope.ts', + 'src/documentEnvelopeLimits.ts', + 'src/documentEnvelopeCanonical.ts', 'src/documentEnvelopeRevision.ts', + 'src/documentRevisionEvidence.ts', + 'src/documentTransitionEvidence.ts', 'src/textPositionSelectorEvidence.ts', ], exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts'], From 37dff1fc2d38cbc493152979b27e5468ce9dc555 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:11:11 -0700 Subject: [PATCH 02/19] test(review): require host-disabled action state --- src/components/ReviewPanel.test.tsx | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/components/ReviewPanel.test.tsx b/src/components/ReviewPanel.test.tsx index 1995ba74..aca9d444 100644 --- a/src/components/ReviewPanel.test.tsx +++ b/src/components/ReviewPanel.test.tsx @@ -143,4 +143,28 @@ describe('ReviewPanel', () => { resolveAction?.(); await waitFor(() => expect(accept).not.toBeDisabled()); }); + + it('fails closed when the host disables review actions', () => { + const onAction = vi.fn(); + panel({ + allowAccept: false, + allowReject: false, + suggestions: [{ + suggestionId: 'permission-disabled', + kind: 'insert', + state: 'pending', + expectedRevision: target().revision, + target: { ...target(), selector: { ...target().selector, end: 0 } }, + text: 'x', + }], + }, onAction); + + const accept = screen.getByRole('button', { name: 'Accept' }); + const reject = screen.getByRole('button', { name: 'Reject' }); + expect(accept).toBeDisabled(); + expect(reject).toBeDisabled(); + fireEvent.click(accept); + fireEvent.click(reject); + expect(onAction).not.toHaveBeenCalled(); + }); }); From 42e2d6db404f56ad9d92fe556a5fbf66aaa4a639 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:12:25 -0700 Subject: [PATCH 03/19] test(review): bind actions to read-only state --- src/components/ReviewPanel.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/ReviewPanel.test.tsx b/src/components/ReviewPanel.test.tsx index aca9d444..4d065a9c 100644 --- a/src/components/ReviewPanel.test.tsx +++ b/src/components/ReviewPanel.test.tsx @@ -20,10 +20,12 @@ function target(): CwlEditorReviewTarget { function panel( review: CwlEditorReviewProps, onAction: CwlEditorReviewProps['onOperation'] = vi.fn(), + editable = true, ) { return render( { await onAction?.({ schemaId: 'https://inkspan.io/schemas/review/v1', @@ -144,11 +146,9 @@ describe('ReviewPanel', () => { await waitFor(() => expect(accept).not.toBeDisabled()); }); - it('fails closed when the host disables review actions', () => { + it('disables document-mutating review actions when the editor is read-only', () => { const onAction = vi.fn(); panel({ - allowAccept: false, - allowReject: false, suggestions: [{ suggestionId: 'permission-disabled', kind: 'insert', @@ -157,7 +157,7 @@ describe('ReviewPanel', () => { target: { ...target(), selector: { ...target().selector, end: 0 } }, text: 'x', }], - }, onAction); + }, onAction, false); const accept = screen.getByRole('button', { name: 'Accept' }); const reject = screen.getByRole('button', { name: 'Reject' }); From ef87c6967de44e1b5d44e709a662d8945f713dd4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:13:06 -0700 Subject: [PATCH 04/19] fix(review): disable mutations on read-only surfaces --- src/components/ReviewPanel.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/components/ReviewPanel.tsx b/src/components/ReviewPanel.tsx index e9e55cb0..f03070b5 100644 --- a/src/components/ReviewPanel.tsx +++ b/src/components/ReviewPanel.tsx @@ -9,6 +9,8 @@ import type { interface ReviewPanelProps { readonly review: CwlEditorReviewProps; + /** Whether document-mutating review actions are currently permitted by the editor surface. */ + readonly editable?: boolean; readonly onAction: ( suggestion: CwlEditorReviewSuggestion, action: 'accept' | 'reject', @@ -17,7 +19,12 @@ interface ReviewPanelProps { } /** Render accessible host-controlled threads and deterministic suggestions. */ -export function ReviewPanel({ review, onAction, onSelect }: ReviewPanelProps) { +export function ReviewPanel({ + review, + editable = true, + onAction, + onSelect, +}: ReviewPanelProps) { const [busyIds, setBusyIds] = useState>(new Set()); const suggestions = review.suggestions ?? []; const threads = review.threads ?? []; @@ -58,6 +65,7 @@ export function ReviewPanel({ review, onAction, onSelect }: ReviewPanelProps) { {suggestions.map((suggestion) => { const busy = busyIds.has(suggestion.suggestionId); const final = suggestion.state !== 'pending'; + const actionDisabled = !editable || busy || final; return (
  • @@ -95,7 +103,7 @@ export function ReviewPanel({ review, onAction, onSelect }: ReviewPanelProps) { setBusy(suggestion.suggestionId, false); } }} - disabled={busy || final} + disabled={actionDisabled} > Reject From 0eb8de532b735cb9499609f8826c8d22592b2b39 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:13:39 -0700 Subject: [PATCH 05/19] fix(review): propagate editor read-only state --- src/components/EditorFrame.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/EditorFrame.tsx b/src/components/EditorFrame.tsx index f13f696c..92e6489e 100644 --- a/src/components/EditorFrame.tsx +++ b/src/components/EditorFrame.tsx @@ -117,6 +117,7 @@ export function EditorFrame({ {review && onReviewAction && onReviewSelect ? ( From bc5f9a921ba821796062b7b370e72d585df793e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:14:16 -0700 Subject: [PATCH 06/19] test(review): require permission-disabled fixture --- src/components/ReviewPanel.fixture.test.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/ReviewPanel.fixture.test.tsx b/src/components/ReviewPanel.fixture.test.tsx index 54af6ffb..33a031eb 100644 --- a/src/components/ReviewPanel.fixture.test.tsx +++ b/src/components/ReviewPanel.fixture.test.tsx @@ -9,6 +9,7 @@ describe('ReviewPanel repository-rendered fixture', () => { 'pending-insert', 'pending-delete', 'resolved', + 'permission-disabled', 'empty', ])('renders the %s state', async (state) => { render(); @@ -38,5 +39,10 @@ describe('ReviewPanel repository-rendered fixture', () => { expect(screen.getByText(/fixture-thread/)).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Accept' })).toBeDisabled(); } + if (state === 'permission-disabled') { + expect(screen.getByText(/permission-disabled/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Accept' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Reject' })).toBeDisabled(); + } }); }); From bd9ea37d7dbd6ad82bba0f4498ac842be3cdf21d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:14:43 -0700 Subject: [PATCH 07/19] feat(review): add permission-disabled fixture state --- src/components/ReviewPanel.fixture.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/components/ReviewPanel.fixture.tsx b/src/components/ReviewPanel.fixture.tsx index 9385cd77..0ece7e14 100644 --- a/src/components/ReviewPanel.fixture.tsx +++ b/src/components/ReviewPanel.fixture.tsx @@ -7,6 +7,7 @@ export type ReviewPanelFixtureState = | 'pending-insert' | 'pending-delete' | 'resolved' + | 'permission-disabled' | 'empty'; const FIXTURE_REVISION = Object.freeze({ @@ -59,6 +60,18 @@ function fixtureReview(state: ReviewPanelFixtureState): CwlEditorReviewProps { }], }; } + if (state === 'permission-disabled') { + return { + suggestions: [{ + suggestionId: 'permission-disabled', + kind: 'insert', + state: 'pending', + expectedRevision: FIXTURE_REVISION, + target: FIXTURE_TARGET, + text: 'permission text', + }], + }; + } return { suggestions: [{ suggestionId: 'fixture-insert', @@ -71,11 +84,12 @@ function fixtureReview(state: ReviewPanelFixtureState): CwlEditorReviewProps { }; } -/** Minimal Storybook-equivalent render fixture with pending/final/empty states. */ +/** Minimal Storybook-equivalent render fixture with pending/final/permission/empty states. */ export function ReviewPanelFixture({ state }: { readonly state: ReviewPanelFixtureState }) { return ( undefined} onSelect={() => undefined} /> From 5df0cc8d39ec04f789b504a8efa65b0100e1c72c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:15:50 -0700 Subject: [PATCH 08/19] test(a11y): require narrow and forced-color review evidence --- src/releaseBrowserEvidence.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/releaseBrowserEvidence.test.ts b/src/releaseBrowserEvidence.test.ts index 74ea0b34..6df85c05 100644 --- a/src/releaseBrowserEvidence.test.ts +++ b/src/releaseBrowserEvidence.test.ts @@ -133,6 +133,8 @@ describe('release cross-engine browser evidence contract', () => { expect(consensusSpec).toContain('packedPackageSha256(repositoryRoot)'); expect(reviewSpec).toContain('window.mountInkspanReviewProbe()'); expect(reviewSpec).toContain("data-review-status', 'stale'"); + expect(reviewSpec).toContain("setViewportSize({ width: 360, height: 740 })"); + expect(reviewSpec).toContain("emulateMedia({ forcedColors: 'active' })"); expect(reviewSpec).toContain("emulateMedia({ media: 'print' })"); }); From c60d22d53474ed99f4e121585177b0f0aeab2729 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:16:25 -0700 Subject: [PATCH 09/19] test(a11y): cover narrow and forced-color review states --- tests/browser/specs/review.browser.spec.ts | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/browser/specs/review.browser.spec.ts b/tests/browser/specs/review.browser.spec.ts index 46470fc5..74339e5f 100644 --- a/tests/browser/specs/review.browser.spec.ts +++ b/tests/browser/specs/review.browser.spec.ts @@ -56,3 +56,34 @@ test('runs exact-revision review, keyboard history, stale protection, and print ), ).toBe('none'); }); + +test('keeps review usable at narrow width and in forced-colors mode', async ({ page }) => { + await page.setViewportSize({ width: 360, height: 740 }); + const region = page.getByRole('region', { name: 'Document review' }); + await expect(region).toBeVisible(); + + const regionBox = await region.boundingBox(); + expect(regionBox).not.toBeNull(); + expect(regionBox!.width).toBeLessThanOrEqual(360); + expect( + await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth), + ).toBe(true); + + await page.emulateMedia({ forcedColors: 'active' }); + expect( + await page.evaluate(() => matchMedia('(forced-colors: active)').matches), + ).toBe(true); + + const target = region.locator('.cwl-review-panel__target').first(); + await target.focus(); + await expect(target).toBeFocused(); + const focusOutline = await target.evaluate((element) => { + const style = getComputedStyle(element); + return { + style: style.outlineStyle, + width: Number.parseFloat(style.outlineWidth), + }; + }); + expect(focusOutline.style).not.toBe('none'); + expect(focusOutline.width).toBeGreaterThan(0); +}); From db0e1375322d819b5dd6f10a344b419784a0f8ac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:09:37 -0700 Subject: [PATCH 10/19] docs(adr): allocate review contract ADR 0032 --- .../0032-provider-neutral-review-contract.md | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 docs/adr/0032-provider-neutral-review-contract.md diff --git a/docs/adr/0032-provider-neutral-review-contract.md b/docs/adr/0032-provider-neutral-review-contract.md new file mode 100644 index 00000000..f0dbb8db --- /dev/null +++ b/docs/adr/0032-provider-neutral-review-contract.md @@ -0,0 +1,86 @@ +# ADR 0032: Provider-neutral revision-scoped review contract + +- Status: Proposed +- Date: 2026-08-20 +- Scope: Inkspan JavaScript package and editor integration + +## Context + +Reviewers need inline targets, threads, and suggestions while hosts retain +identity, authorization, durable comments, collaboration, and persistence. +W3C text-position selectors provide interoperable range semantics but are +fragile when the underlying revision changes. Commercial editor products expose +review panels and individually accept/rejectable changes, but their hosted +services are outside Inkspan's provider-neutral boundary. + +## Alternatives considered + +1. Add a database-backed annotation and comments service to Inkspan. +2. Depend on a vendor comments or track-changes extension as the public model. +3. Expose a bounded revision-scoped contract and a controlled React adapter, + leaving durable records and provider integration to the host. + +## Decision + +Choose alternative 3. Version the core as +`https://inkspan.io/schemas/review/v1`. Validate immutable revision, selector, +projection, bounded IDs/counts/text, and insert/delete shape. Accept and reject +are exact-once local operations: accept must change the document, reject must +not, and an expected-revision mismatch returns `stale` without re-anchoring. +Return only revision and transition evidence; never return comment or document +bodies from the operation-result contract. + +## Consequences + +Inkspan gains a reusable React-free package surface and a deterministic editor +integration seam. Hosts supply thread/suggestion metadata, authorization, +durable storage, callbacks, notification policy, collaboration lifecycle, +audit, and cross-revision re-anchoring. The active review package must remain +usable without React, TipTap view, Yjs, network, credentials, or a database. + +## Failure and recovery + +Malformed or unsupported metadata fails closed with redacted error codes. A +stale revision returns a stale result so the host can refetch and decide what to +show. An invalid accept/reject revision transition is an error; the editor must +not mutate the document or mark the host record final. Undo/redo is delegated to +the editor transaction history after a successful local operation. + +## Security and privacy impact + +The contract bounds hostile input and rejects accessors/exotic objects. It does +not authorize an actor, tenant, thread, or suggestion. It carries no comment +body, selected quote, credential, network handle, or durable audit claim. + +## Compatibility and migration + +The `v1` schema and projection identity are explicit. Unknown review schema or +projection versions fail closed. Hosts own storage migrations and may retain +older records, but migrated values must pass the current validator before the +editor displays or operates on them. + +## Verification + +The active branch must provide core 100% statement/branch/function/line +coverage, strict TSDoc, packed ESM/CommonJS/TypeScript consumers, React editor +integration tests, keyboard and assistive metadata tests, Chromium/Firefox/ +WebKit interaction coverage, print-state coverage, and a rendered Storybook or +equivalent state fixture. Review/check evidence must be bound to the exact +current head before merge; active-PR evidence is not shipped behavior. + +## Rollback or supersession + +Before protected merge, delete or revise the active package surface and its +documentation if the acceptance contract changes. After acceptance, supersede +this ADR with a versioned decision before changing selector, lifecycle, or +ownership semantics. A host may disable the optional review UI without changing +the document-envelope or text-position contracts. + +## References + +World Wide Web Consortium. (2017, February 23). *Web Annotation Data Model*. +https://www.w3.org/TR/annotation-model/ + +CKSource. (n.d.). *Track changes overview*. CKEditor 5 documentation. Retrieved +August 20, 2026, from +https://ckeditor.com/docs/ckeditor5/latest/features/collaboration/track-changes/track-changes.html From ce2db520108944a8eab725433997238004c66d4e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:09:45 -0700 Subject: [PATCH 11/19] docs(adr): retire colliding review ADR 0027 --- .../0027-provider-neutral-review-contract.md | 86 ------------------- 1 file changed, 86 deletions(-) delete mode 100644 docs/adr/0027-provider-neutral-review-contract.md diff --git a/docs/adr/0027-provider-neutral-review-contract.md b/docs/adr/0027-provider-neutral-review-contract.md deleted file mode 100644 index f40f54c7..00000000 --- a/docs/adr/0027-provider-neutral-review-contract.md +++ /dev/null @@ -1,86 +0,0 @@ -# ADR 0027: Provider-neutral revision-scoped review contract - -- Status: Proposed -- Date: 2026-08-20 -- Scope: Inkspan JavaScript package and editor integration - -## Context - -Reviewers need inline targets, threads, and suggestions while hosts retain -identity, authorization, durable comments, collaboration, and persistence. -W3C text-position selectors provide interoperable range semantics but are -fragile when the underlying revision changes. Commercial editor products expose -review panels and individually accept/rejectable changes, but their hosted -services are outside Inkspan's provider-neutral boundary. - -## Alternatives considered - -1. Add a database-backed annotation and comments service to Inkspan. -2. Depend on a vendor comments or track-changes extension as the public model. -3. Expose a bounded revision-scoped contract and a controlled React adapter, - leaving durable records and provider integration to the host. - -## Decision - -Choose alternative 3. Version the core as -`https://inkspan.io/schemas/review/v1`. Validate immutable revision, selector, -projection, bounded IDs/counts/text, and insert/delete shape. Accept and reject -are exact-once local operations: accept must change the document, reject must -not, and an expected-revision mismatch returns `stale` without re-anchoring. -Return only revision and transition evidence; never return comment or document -bodies from the operation-result contract. - -## Consequences - -Inkspan gains a reusable React-free package surface and a deterministic editor -integration seam. Hosts supply thread/suggestion metadata, authorization, -durable storage, callbacks, notification policy, collaboration lifecycle, -audit, and cross-revision re-anchoring. The active review package must remain -usable without React, TipTap view, Yjs, network, credentials, or a database. - -## Failure and recovery - -Malformed or unsupported metadata fails closed with redacted error codes. A -stale revision returns a stale result so the host can refetch and decide what to -show. An invalid accept/reject revision transition is an error; the editor must -not mutate the document or mark the host record final. Undo/redo is delegated to -the editor transaction history after a successful local operation. - -## Security and privacy impact - -The contract bounds hostile input and rejects accessors/exotic objects. It does -not authorize an actor, tenant, thread, or suggestion. It carries no comment -body, selected quote, credential, network handle, or durable audit claim. - -## Compatibility and migration - -The `v1` schema and projection identity are explicit. Unknown review schema or -projection versions fail closed. Hosts own storage migrations and may retain -older records, but migrated values must pass the current validator before the -editor displays or operates on them. - -## Verification - -The active branch must provide core 100% statement/branch/function/line -coverage, strict TSDoc, packed ESM/CommonJS/TypeScript consumers, React editor -integration tests, keyboard and assistive metadata tests, Chromium/Firefox/ -WebKit interaction coverage, print-state coverage, and a rendered Storybook or -equivalent state fixture. Review/check evidence must be bound to the exact -current head before merge; active-PR evidence is not shipped behavior. - -## Rollback or supersession - -Before protected merge, delete or revise the active package surface and its -documentation if the acceptance contract changes. After acceptance, supersede -this ADR with a versioned decision before changing selector, lifecycle, or -ownership semantics. A host may disable the optional review UI without changing -the document-envelope or text-position contracts. - -## References - -World Wide Web Consortium. (2017, February 23). *Web Annotation Data Model*. -https://www.w3.org/TR/annotation-model/ - -CKSource. (n.d.). *Track changes overview*. CKEditor 5 documentation. Retrieved -August 20, 2026, from -https://ckeditor.com/docs/ckeditor5/latest/features/collaboration/track-changes/track-changes.html From 370aee5786cedbd6d18ed5710d8d60f4c94731c7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:14:03 -0700 Subject: [PATCH 12/19] docs(adr): index review contract as ADR 0032 --- docs/adr/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/README.md b/docs/adr/README.md index d2f736b7..f3860665 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -32,7 +32,7 @@ This index records durable architectural decisions. Protected-main implementatio | [0024](0024-bounded-docx-paragraph-alignment.md) | Accepted | Bounded paragraph alignment in deterministic DOCX output | | [0025](0025-bounded-docx-heading-alignment.md) | Accepted | Bounded heading alignment in deterministic DOCX output | | [0026](0026-bounded-docx-external-hyperlinks.md) | Accepted | Bounded external hyperlinks in deterministic DOCX rich text | -| [0027](0027-provider-neutral-review-contract.md) | Proposed | Provider-neutral revision-scoped review contract | +| [0032](0032-provider-neutral-review-contract.md) | Proposed | Provider-neutral revision-scoped review contract | ## Decision discipline From 2a73578876c49ada90761e1bef8fe49b2ea12e04 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:14:45 -0700 Subject: [PATCH 13/19] docs(review): bind fitness matrix to ADR 0032 --- docs/DOCUMENTATION_FITNESS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/DOCUMENTATION_FITNESS.md b/docs/DOCUMENTATION_FITNESS.md index 8fcdda96..76263cb9 100644 --- a/docs/DOCUMENTATION_FITNESS.md +++ b/docs/DOCUMENTATION_FITNESS.md @@ -50,7 +50,7 @@ Document fitness and implementation maturity are independent. A `present_current | Accessible editor placeholder semantics | accessibility guidance/doctoring and protected editor source from #131 | `present_current` | `implemented_on_protected_main` | Visual placeholder guidance and `aria-placeholder` remain synchronized without making placeholder text an accessible name or persistent document value. | | Revision-scoped selection evidence | selection lifecycle guide, doctoring and protected public handle/type contract | `present_current` | `implemented_on_protected_main` | Atomic selection+revision evidence, privacy minimization and host-owned re-anchoring are reconstructable. | | W3C text-position selector evidence / React-free text-position selector subpath | ADR 0018, protected handle/type contract, package subpath, selector implementation/verifier and doctoring | `present_current` | `implemented_on_protected_main` | ADR 0018 makes Unicode-code-point offsets, grapheme-boundary fail-closed semantics, projection identity, same-state revision binding, React-free text-position selector subpath reuse and text-free evidence reconstructable without confusing W3C offsets with ProseMirror positions. | -| Provider-neutral review contract / React-free review subpath | Proposed ADR 0027, `docs/review-contract.md`, package subpath, core tests and packed verifier | `present_current` | `implemented_on_active_pr` | Bounded revision-scoped targets and deterministic insert/delete transition semantics are reconstructable; editor UI, browser accessibility, durable comments, collaboration, and re-anchoring remain unshipped acceptance work. | +| Provider-neutral review contract / React-free review subpath | Proposed ADR 0032, `docs/review-contract.md`, package subpath, core tests and packed verifier | `present_current` | `implemented_on_active_pr` | Bounded revision-scoped targets and deterministic insert/delete transition semantics are reconstructable; editor UI, browser accessibility, durable comments, collaboration, and re-anchoring remain unshipped acceptance work. | | Document-transition evidence | transition doctoring, public framework-independent contract and protected revision-evidence package | `present_current` | `implemented_on_protected_main` | Previous/resulting revision lineage is reconstructable without implying actor/time/durable-write provenance. | | Envelope schema identity / migration routing | ADR 0015, PRD, TRD, DATA_MODEL, envelope guide/doctoring and protected identity-routing source | `present_current` | `implemented_on_protected_main` | Bounded schema identification remains separate from host-owned migration execution and persistence. | | Cross-engine browser-semantic release assurance | ADR 0016, doctoring, TEST_STRATEGY, OPERABILITY, TRACEABILITY and protected browser-evidence workflows | `present_current` | `implemented_on_protected_main` | SafeClipboard is also `implemented_on_protected_main`; Chromium/Firefox/WebKit evidence, exact source/lock/browser identity, packed-artifact digest binding and fail-closed divergence are protected release authority. | From 7839b5f11fdd2253b3ce9da430d6c38a810914da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:15:39 -0700 Subject: [PATCH 14/19] docs(review): bind data model to ADR 0032 --- docs/DATA_MODEL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 553c7817..720a5360 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -48,7 +48,7 @@ erDiagram - `document_transition`: previous/resulting revision pair plus changed classification. It deliberately omits the document body from ordinary evidence. - `selection_evidence`: ProseMirror structural coordinates bound to one exact revision. It is a local evidence value, not a durable cross-revision anchor. - `text_position_selector_evidence`: `implemented_on_protected_main` as a frozen revision-scoped W3C `TextPositionSelector` plus explicit `inkspan-prosemirror-text` projection identity. It satisfies `0 <= start <= end <= projectedCodePointLength`; inclusive `start` and exclusive `end` count Unicode code points, boundaries are grapheme-validated, and ordinary evidence contains no selected quote text. It is not a durable cross-revision anchor, annotation identity, authorization record, timestamp, signature, or persistence receipt. -- `review_target` / `review_suggestion`: `implemented_on_active_pr` under Proposed ADR 0027. These are bounded revision-scoped target and insert/delete lifecycle values; operation results contain only revisions and compact transition evidence. They do not contain comment bodies or transfer host identity, authorization, persistence, collaboration, audit, or cross-revision re-anchoring authority. +- `review_target` / `review_suggestion`: `implemented_on_active_pr` under Proposed ADR 0032. These are bounded revision-scoped target and insert/delete lifecycle values; operation results contain only revisions and compact transition evidence. They do not contain comment bodies or transfer host identity, authorization, persistence, collaboration, audit, or cross-revision re-anchoring authority. - `autosave_revision`: detached immutable revision evidence accepted by the local single-flight autosave coordinator. - `autosave_snapshot`: frozen document-free queue/session lifecycle metadata such as idle/saving/blocked/closing/closed and bounded pending state. The explicit in-process snapshot may also carry the bounded active/pending/last-saved strong-validator fields defined by the autosave contract; those fields are confidential local concurrency metadata rather than generic telemetry. - `clipboard_policy`: bounded local policy describing the supported semantic rich-paste boundary. It grants no host network or tenant authority. From 3605dcd37cf1fbce511671cc8ea0c14a376326b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:16:18 -0700 Subject: [PATCH 15/19] docs(review): bind contracts to ADR 0032 --- docs/CONTRACTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 2625ed1d..e65a747e 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -71,7 +71,7 @@ The host owns annotation identifiers/bodies, source-resource IRI policy, authent ADR 0018 is the durable authority decision. `docs/selection-lifecycle.md` and `docs/doctoring/w3c-text-position-selector-evidence.md` record the operator-facing semantics and APA-7 standards basis. Packed ESM/CommonJS/strict-TypeScript consumers verify both the root public API and the protected React-free `@contextualwisdomlab/cwl-editor/text-position-selector` subpath; no database, network, provider credential, model, naruon, or contextual-orchestrator dependency is introduced by the projection subpath or evidence operation. -The active review contract keeps comment bodies, durable thread/suggestion identity, authorization, persistence, audit, collaboration-provider state, and cross-revision re-anchoring with the host. Inkspan validates only bounded immutable revision/selector/projection metadata and deterministic insert/delete lifecycle/transition semantics. `docs/review-contract.md` and ADR 0027 define the proposed editor integration and its fail-closed stale-revision boundary; active-PR evidence must not be described as shipped behavior. +The active review contract keeps comment bodies, durable thread/suggestion identity, authorization, persistence, audit, collaboration-provider state, and cross-revision re-anchoring with the host. Inkspan validates only bounded immutable revision/selector/projection metadata and deterministic insert/delete lifecycle/transition semantics. `docs/review-contract.md` and ADR 0032 define the proposed editor integration and its fail-closed stale-revision boundary; active-PR evidence must not be described as shipped behavior. ## Autosave contract From e775e80db4b01e5a89f5fed21d6a81b82b2e2559 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:16:59 -0700 Subject: [PATCH 16/19] docs(review): bind PRD to ADR 0032 --- docs/PRD.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PRD.md b/docs/PRD.md index 76aba20b..6357a265 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -149,6 +149,6 @@ Protected `main` is the sole implemented baseline. Open PRs may describe Propose SafeClipboard, real Chromium/Firefox/WebKit release assurance, lifecycle observation, the root security disclosure lifecycle, toolbar shortcut accessibility metadata, SSR/native-form serialization, revision-scoped selection evidence, W3C text-position selector evidence, document-transition evidence, and envelope identity migration routing are implemented on protected `main`. The revision-scoped review contract and `@contextualwisdomlab/cwl-editor/review` -subpath are `implemented_on_active_pr` under Proposed ADR 0027. The interactive +subpath are `implemented_on_active_pr` under Proposed ADR 0032. The interactive review panel, editor transaction integration, Storybook/equivalent states, and cross-engine interaction evidence are not shipped until protected integration. From b69e521a3f836fc30e07c6a8b75e5a235653188c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:17:45 -0700 Subject: [PATCH 17/19] docs(review): bind traceability to ADR 0032 --- docs/TRACEABILITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 8c535ffb..85422da4 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -15,7 +15,7 @@ This record maps durable Inkspan product decisions to authoritative standards, p | Envelope version routing | A bounded identity-only inspector identifies `schemaId`/`schemaVersion` for dispatch while the current parser stays strict and the host owns migration execution | RFC 8259; RFC 7493; RFC 8785 for canonical current-schema bytes | ADR 0015, protected-main `documentEnvelopeIdentity` implementation/tests, envelope guide/doctoring and framework-independent packed consumers | Protected-main evidence proves only bounded routing metadata; identifying a schema generation does not validate that generation's document semantics, authorize migration, or prove durable persistence | | Canonical document bytes | Deterministic revision evidence is derived from canonicalized validated document content | RFC 8785, JSON Canonicalization Scheme | revision-evidence, transition-evidence, restore tests | A content digest proves equality only, not actor/time/authorization/durable write | | W3C text-position selector | Revision-scoped annotation interoperability uses a distinct versioned logical-text projection satisfying `0 <= start <= end <= projectedCodePointLength`, with inclusive `start`, exclusive `end`, Unicode-code-point offsets, grapheme-boundary validation, and same-state revision binding instead of relabeling ProseMirror coordinates | W3C Web Annotation Data Model; ProseMirror reference manual; ECMA-402 13th edition | ADR 0018, protected-main text-position selector implementation/tests, packed consumer verifier, selection lifecycle and doctoring | Protected-main evidence proves positions only for the named projection and exact revision; it does not prove actor, authorization, durable annotation acceptance, source IRI policy, or cross-revision re-anchoring | -| Provider-neutral review contract | Active-PR v1 bounds revision-scoped thread/suggestion targets and deterministic insert/delete operation results; accept changes, reject preserves, stale fails closed, and bodies/authorization/persistence remain host-owned | W3C Web Annotation Data Model; official Tiptap Comments and CKEditor 5 Track Changes documentation | Proposed ADR 0027, `docs/review-contract.md`, `src/review/contract.ts` tests, packed `./review` consumer verifier | `implemented_on_active_pr`; core package evidence does not prove shipped editor UI, browser accessibility, durable comments, collaboration, or cross-revision re-anchoring | +| Provider-neutral review contract | Active-PR v1 bounds revision-scoped thread/suggestion targets and deterministic insert/delete operation results; accept changes, reject preserves, stale fails closed, and bodies/authorization/persistence remain host-owned | W3C Web Annotation Data Model; official Tiptap Comments and CKEditor 5 Track Changes documentation | Proposed ADR 0032, `docs/review-contract.md`, `src/review/contract.ts` tests, packed `./review` consumer verifier | `implemented_on_active_pr`; core package evidence does not prove shipped editor UI, browser accessibility, durable comments, collaboration, or cross-revision re-anchoring | | Headless deterministic Markdown conversion | One serializer implementation and one framework-neutral safe-link/inline-raster policy are exposed through a self-contained ESM/CommonJS/TypeScript `./markdown` subpath | CommonMark 0.31.2; Node.js package `exports` documentation | protected-main #114 implementation, packed Node consumers, package-distribution contract, `docs/doctoring/headless-markdown-package.md` | `implemented_on_protected_main`; deterministic conversion does not grant MIME delivery, recipient, auth, tenant, persistence, network, credential, or model authority | | Provenance semantics | Local transition/release evidence keeps content lineage separate from actor/authorization/durable claims | W3C PROV family | transition evidence, release evidence, canonical data model | Inkspan does not claim complete PROV conformance or host audit provenance | | Accessibility | Native controls, keyboard semantics, shortcut metadata, semantic placeholder guidance, and host-facing status state support accessible embedding | W3C WCAG 2.2; WAI-ARIA 1.2 where used | protected toolbar/accessibility tests, SSR tests, autosave lifecycle data, protected #131 placeholder tests/packed consumer and `docs/doctoring/editor-placeholder-accessibility.md` | Component evidence alone is not a full host WCAG conformance claim; `aria-placeholder` supplements but never replaces the accessible name | From f5b271aa219966d91109abb09508b4851a9c5e5b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:18:45 -0700 Subject: [PATCH 18/19] docs(review): bind TRD to ADR 0032 --- docs/TRD.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/TRD.md b/docs/TRD.md index 1168de27..a15c5d2f 100644 --- a/docs/TRD.md +++ b/docs/TRD.md @@ -56,7 +56,7 @@ The React-free text-position-selector package surface is also protected-main beh ## Provider-neutral review contract -The active review line adds Proposed ADR 0027 and the React-free `./review` +The active review line adds Proposed ADR 0032 and the React-free `./review` subpath. Its v1 core validates bounded host-supplied thread/suggestion metadata against one immutable SHA-256 revision and the named W3C logical-text projection. Insert targets are empty ranges; delete targets are non-empty @@ -160,4 +160,4 @@ Queued, cancelled, skipped-required, absent, stale-head, predecessor-head, statu Protected `main` is the sole shipped implementation baseline. SafeClipboard, cross-engine browser assurance, the security disclosure lifecycle, autosave lifecycle observation, toolbar shortcut accessibility metadata, accessible editor placeholder semantics, SSR/native-form serialization, revision-scoped selection evidence, W3C text-position selector evidence, the React-free text-position-selector subpath, document-transition evidence, envelope identity routing, framework-neutral deterministic Markdown conversion, CSS paged-media print output, DOCX informative PNG figures, bounded rich-text runs, bounded paragraph alignment, bounded heading alignment, and the OIDC-backed unified stable registry release train are `implemented_on_protected_main`. -The bounded DOCX rich-run external hyperlink contract in #137 is `implemented_on_active_pr` under Proposed ADR 0026. Open branches may extend the protected boundary, but no active-PR capability becomes shipped merely because its design, tests, or documentation are complete. +The bounded DOCX rich-run external hyperlink contract in #137 is `implemented_on_active_pr` under Proposed ADR 0026. Open branches may extend the protected boundary, but no active-PR capability becomes shipped merely because its design, tests, or documentation are complete. \ No newline at end of file From b0193e648461d5d60e943d1d7adabd0fedfc0f67 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 00:21:04 -0700 Subject: [PATCH 19/19] test(docs): enforce allocated review ADR 0032 --- src/canonicalProductDocumentation.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/canonicalProductDocumentation.test.ts b/src/canonicalProductDocumentation.test.ts index e1eebd0b..90ea8a3a 100644 --- a/src/canonicalProductDocumentation.test.ts +++ b/src/canonicalProductDocumentation.test.ts @@ -48,7 +48,7 @@ const requiredFiles = [ 'docs/adr/0022-informative-docx-png-figures.md', 'docs/adr/0023-bounded-docx-rich-text-runs.md', 'docs/adr/0024-bounded-docx-paragraph-alignment.md', - 'docs/adr/0027-provider-neutral-review-contract.md', + 'docs/adr/0032-provider-neutral-review-contract.md', 'docs/review-contract.md', 'src/fonts/OFL.txt', 'src/fonts/NOTICE', @@ -341,4 +341,4 @@ describe('canonical product documentation graph', () => { } } }); -}); +}); \ No newline at end of file