From 8b657da9c53bdf0c29312254508dca1f2cb26d4a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 08:41:39 +0900 Subject: [PATCH 01/16] test(review): define W3C text-position evidence contract --- ...itor.textPositionSelectorEvidence.test.tsx | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/components/CwlEditor.textPositionSelectorEvidence.test.tsx diff --git a/src/components/CwlEditor.textPositionSelectorEvidence.test.tsx b/src/components/CwlEditor.textPositionSelectorEvidence.test.tsx new file mode 100644 index 00000000..6b420132 --- /dev/null +++ b/src/components/CwlEditor.textPositionSelectorEvidence.test.tsx @@ -0,0 +1,83 @@ +import { act, cleanup, render, waitFor } from '@testing-library/react'; +import { createRef } from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { CwlEditorHandle } from '../types.js'; +import { CwlEditor } from './CwlEditor.js'; + +afterEach(cleanup); + +describe('CwlEditor W3C text-position selector evidence', () => { + it('counts Unicode code points rather than ProseMirror UTF-16 structural positions', async () => { + const editorRef = createRef(); + render(); + await waitFor(() => expect(editorRef.current?.getEditor()).not.toBeNull()); + + const handle = editorRef.current!; + act(() => { + // ProseMirror text positions use JavaScript string offsets: the astral emoji + // occupies two UTF-16 code units between structural positions 2 and 4. + handle.getEditor()!.commands.setTextSelection({ from: 2, to: 4 }); + }); + + const evidence = await handle.getTextPositionSelectorEvidence(undefined, { + digest: async () => new Uint8Array(32).fill(0x2a).buffer, + }); + + expect(evidence).toEqual({ + revision: expect.objectContaining({ digestHex: '2a'.repeat(32) }), + selector: { type: 'TextPositionSelector', start: 1, end: 2 }, + textProjection: { + id: 'inkspan-prosemirror-text', + version: 1, + }, + }); + expect(Object.isFrozen(evidence)).toBe(true); + expect(Object.isFrozen(evidence!.selector)).toBe(true); + expect(Object.isFrozen(evidence!.textProjection)).toBe(true); + expect(JSON.stringify(evidence)).not.toContain('๐Ÿ˜€'); + }); + + it('keeps selector positions and revision bound to the same state while hashing is delayed', async () => { + const editorRef = createRef(); + render(); + await waitFor(() => expect(editorRef.current?.getEditor()).not.toBeNull()); + + const handle = editorRef.current!; + act(() => { + handle.getEditor()!.commands.setTextSelection({ from: 7, to: 11 }); + }); + + let releaseDigest!: () => void; + const digestRelease = new Promise((resolve) => { + releaseDigest = resolve; + }); + let announceDigest!: () => void; + const digestStarted = new Promise((resolve) => { + announceDigest = resolve; + }); + + const evidencePromise = handle.getTextPositionSelectorEvidence(undefined, { + digest: async () => { + announceDigest(); + await digestRelease; + return new Uint8Array(32).fill(0x11).buffer; + }, + }); + await digestStarted; + act(() => { + handle.setValue('Replacement'); + handle.getEditor()!.commands.setTextSelection(1); + }); + releaseDigest(); + + const evidence = await evidencePromise; + expect(evidence?.selector).toEqual({ + type: 'TextPositionSelector', + start: 6, + end: 10, + }); + expect(evidence?.revision.digestHex).toBe('11'.repeat(32)); + expect(handle.getHTML()).toContain('Replacement'); + expect(JSON.stringify(evidence)).not.toContain('Alpha beta'); + }); +}); From 4f223532a200433fbc6bd984cbfcd5b2125a8169 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 08:44:11 +0900 Subject: [PATCH 02/16] feat(review): add deterministic text-position projection --- src/textPositionSelectorEvidence.ts | 125 ++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 src/textPositionSelectorEvidence.ts diff --git a/src/textPositionSelectorEvidence.ts b/src/textPositionSelectorEvidence.ts new file mode 100644 index 00000000..49bf43e8 --- /dev/null +++ b/src/textPositionSelectorEvidence.ts @@ -0,0 +1,125 @@ +import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; +import type { Selection } from '@tiptap/pm/state'; +import type { CwlEditorDocumentRevision } from './documentEnvelopeRevision.js'; + +/** Stable identity of Inkspan's first W3C-compatible logical text projection. */ +export const TEXT_POSITION_PROJECTION_ID = 'inkspan-prosemirror-text' as const; + +/** Version of the logical text projection used by text-position evidence. */ +export const TEXT_POSITION_PROJECTION_VERSION = 1 as const; + +const BLOCK_SEPARATOR = '\n'; +const LEAF_TEXT = '\uFFFC'; + +/** W3C Web Annotation text-position selector for one projected text range. */ +export interface CwlEditorTextPositionSelector { + /** W3C selector class name. */ + readonly type: 'TextPositionSelector'; + /** Inclusive Unicode-code-point offset in the versioned text projection. */ + readonly start: number; + /** Exclusive Unicode-code-point offset in the versioned text projection. */ + readonly end: number; +} + +/** Identity of the deterministic text stream indexed by selector offsets. */ +export interface CwlEditorTextProjectionIdentity { + /** Stable projection family identifier. */ + readonly id: typeof TEXT_POSITION_PROJECTION_ID; + /** Version whose separator and leaf-node semantics define this text stream. */ + readonly version: typeof TEXT_POSITION_PROJECTION_VERSION; +} + +/** Privacy-minimized W3C selector evidence bound to one exact Inkspan revision. */ +export interface CwlEditorTextPositionSelectorEvidence { + /** SHA-256 content revision for the same immutable editor state. */ + readonly revision: CwlEditorDocumentRevision; + /** Text range expressed in Unicode code points for `textProjection`. */ + readonly selector: CwlEditorTextPositionSelector; + /** Projection identity required to interpret `selector` positions. */ + readonly textProjection: CwlEditorTextProjectionIdentity; +} + +/** Raised when a structural selection cannot safely become text-position evidence. */ +export class TextPositionSelectorEvidenceError extends Error { + /** Stable failure code for unsupported grapheme-splitting selection boundaries. */ + readonly code = 'grapheme_boundary' as const; + + constructor() { + super('Text-position evidence requires grapheme-cluster selection boundaries.'); + this.name = 'TextPositionSelectorEvidenceError'; + } +} + +interface GraphemeSegment { + readonly index: number; +} + +interface GraphemeSegmenter { + segment(input: string): Iterable; +} + +interface GraphemeSegmenterConstructor { + new ( + locales?: string | readonly string[], + options?: { readonly granularity: 'grapheme' }, + ): GraphemeSegmenter; +} + +/** Project a prefix of one ProseMirror document under the versioned v1 rules. */ +function projectDocumentPrefix(documentNode: ProseMirrorNode, to: number): string { + return documentNode.textBetween(0, to, BLOCK_SEPARATOR, LEAF_TEXT); +} + +/** Count Unicode code points without exposing JavaScript UTF-16 code-unit offsets. */ +function codePointLength(value: string): number { + return Array.from(value).length; +} + +/** Require a position to coincide with a Unicode grapheme-cluster boundary. */ +function assertGraphemeBoundary(text: string, codeUnitOffset: number): void { + const Segmenter = (Intl as unknown as { Segmenter: GraphemeSegmenterConstructor }) + .Segmenter; + const boundaries = new Set([0, text.length]); + for (const segment of new Segmenter(undefined, { granularity: 'grapheme' }).segment( + text, + )) { + boundaries.add(segment.index); + } + if (!boundaries.has(codeUnitOffset)) { + throw new TextPositionSelectorEvidenceError(); + } +} + +/** + * Convert one ProseMirror structural selection into a deterministic W3C text range. + * + * Projection version 1 uses logical ProseMirror document order, `\n` between + * blocks, and U+FFFC OBJECT REPLACEMENT CHARACTER for non-text leaf nodes. The + * returned offsets count Unicode code points. The caller must bind the result to + * the same immutable document revision; this helper contains no selected text. + */ +export function createTextPositionSelector( + documentNode: ProseMirrorNode, + selection: Selection, +): Readonly<{ + selector: CwlEditorTextPositionSelector; + textProjection: CwlEditorTextProjectionIdentity; +}> { + const fullText = projectDocumentPrefix(documentNode, documentNode.content.size); + const startPrefix = projectDocumentPrefix(documentNode, selection.from); + const endPrefix = projectDocumentPrefix(documentNode, selection.to); + + assertGraphemeBoundary(fullText, startPrefix.length); + assertGraphemeBoundary(fullText, endPrefix.length); + + const selector = Object.freeze({ + type: 'TextPositionSelector' as const, + start: codePointLength(startPrefix), + end: codePointLength(endPrefix), + }); + const textProjection = Object.freeze({ + id: TEXT_POSITION_PROJECTION_ID, + version: TEXT_POSITION_PROJECTION_VERSION, + }); + return Object.freeze({ selector, textProjection }); +} From c381dce366a9e5a006d5a4a75fa1fb5ecf065410 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 08:44:48 +0900 Subject: [PATCH 03/16] feat(review): type text-position handle capture --- src/textPositionSelectorEvidenceHandle.ts | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/textPositionSelectorEvidenceHandle.ts diff --git a/src/textPositionSelectorEvidenceHandle.ts b/src/textPositionSelectorEvidenceHandle.ts new file mode 100644 index 00000000..8bea883c --- /dev/null +++ b/src/textPositionSelectorEvidenceHandle.ts @@ -0,0 +1,29 @@ +import type { DocumentEnvelopeLimits } from './documentEnvelope.js'; +import type { DocumentEnvelopeDigestProvider } from './documentEnvelopeRevision.js'; +import type { CwlEditorTextPositionSelectorEvidence } from './textPositionSelectorEvidence.js'; + +/** Framework-neutral signature of the text-position evidence capture operation. */ +export interface CwlEditorTextPositionSelectorEvidenceCapture { + /** + * Capture a W3C text-position selector and exact revision from one immutable + * editor state. Returns `null` before the interactive editor exists. + */ + ( + limits?: DocumentEnvelopeLimits, + digestProvider?: DocumentEnvelopeDigestProvider | null, + ): Promise; +} + +declare module './types.js' { + interface CwlEditorHandle { + /** + * Capture privacy-minimized W3C text-position evidence for the current + * selection and exact same document state. + * + * Positions count Unicode code points in the versioned Inkspan logical-text + * projection, not ProseMirror structural positions, DOM offsets, Markdown + * indexes, or durable cross-revision anchors. Selected text is not included. + */ + getTextPositionSelectorEvidence: CwlEditorTextPositionSelectorEvidenceCapture; + } +} From b940bbf743efd963c43ebc0999ad249c55ade19a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 08:45:13 +0900 Subject: [PATCH 04/16] feat(review): capture W3C text-position evidence --- src/components/useEditorHandle.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/components/useEditorHandle.ts b/src/components/useEditorHandle.ts index 0ee0a1d3..710fe883 100644 --- a/src/components/useEditorHandle.ts +++ b/src/components/useEditorHandle.ts @@ -29,6 +29,7 @@ import { parseDocumentJsonForEditor, validateDocumentJson, } from '../documentSchema.js'; +import { createTextPositionSelector } from '../textPositionSelectorEvidence.js'; import type { CwlEditorHandle, EditorMode } from '../types.js'; import { createEditorDocumentSnapshot } from './editorDocumentSnapshot.js'; import { editorHtmlToValue, editorValueToHtml } from './editorSerialization.js'; @@ -113,6 +114,20 @@ export function useEditorHandle( ); return Object.freeze({ revision, selection }); }, + getTextPositionSelectorEvidence: async (limits, digestProvider) => { + if (!editor) return null; + const state = editor.state; + const { selector, textProjection } = createTextPositionSelector( + state.doc, + state.selection, + ); + const envelope = createDocumentEnvelope(state.doc.toJSON(), limits); + const revision = await createValidatedDocumentEnvelopeRevision( + envelope, + digestProvider, + ); + return Object.freeze({ revision, selector, textProjection }); + }, setValue: (next: string) => { if (!editor) return; editor.commands.setContent( From 71791abb11022a4ed3f0e0c5aece55c3162747d9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 08:45:41 +0900 Subject: [PATCH 05/16] feat(review): export text-position evidence types --- src/index.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 85db7240..c737fd4f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,9 +11,10 @@ * ``` */ -// Register editor-only imperative-handle type augmentation without coupling the +// Register editor-only imperative-handle type augmentations without coupling the // framework-independent revision-evidence subpath to the interactive graph. import './documentRevisionEvidenceHandle.js'; +import './textPositionSelectorEvidenceHandle.js'; // React component surface. export { CwlEditor, default as Editor } from './components/CwlEditor.js'; @@ -35,6 +36,18 @@ export type { ImageConfig, } from './types.js'; export type { CwlEditorDocumentRevisionEvidenceCapture } from './documentRevisionEvidenceHandle.js'; +export type { CwlEditorTextPositionSelectorEvidenceCapture } from './textPositionSelectorEvidenceHandle.js'; +export { + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, + TextPositionSelectorEvidenceError, + createTextPositionSelector, +} from './textPositionSelectorEvidence.js'; +export type { + CwlEditorTextPositionSelector, + CwlEditorTextPositionSelectorEvidence, + CwlEditorTextProjectionIdentity, +} from './textPositionSelectorEvidence.js'; // Versioned, lossless persistence boundary. export { From c930061e29b16ae0a4056341e5ad463822aec44e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 08:46:58 +0900 Subject: [PATCH 06/16] test(review): cover Unicode and grapheme selector semantics --- ...itor.textPositionSelectorEvidence.test.tsx | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/components/CwlEditor.textPositionSelectorEvidence.test.tsx b/src/components/CwlEditor.textPositionSelectorEvidence.test.tsx index 6b420132..7d1db235 100644 --- a/src/components/CwlEditor.textPositionSelectorEvidence.test.tsx +++ b/src/components/CwlEditor.textPositionSelectorEvidence.test.tsx @@ -37,6 +37,55 @@ describe('CwlEditor W3C text-position selector evidence', () => { expect(JSON.stringify(evidence)).not.toContain('๐Ÿ˜€'); }); + it('indexes multi-block bidirectional text in logical document order', async () => { + const editorRef = createRef(); + render(); + await waitFor(() => expect(editorRef.current?.getEditor()).not.toBeNull()); + + const handle = editorRef.current!; + let secondBlockTextPosition: number | null = null; + handle.getEditor()!.state.doc.descendants((node, position) => { + if (node.isText && node.text === 'XYZ') secondBlockTextPosition = position; + }); + expect(secondBlockTextPosition).not.toBeNull(); + act(() => { + handle.getEditor()!.commands.setTextSelection({ + from: secondBlockTextPosition!, + to: secondBlockTextPosition! + 1, + }); + }); + + const evidence = await handle.getTextPositionSelectorEvidence(undefined, { + digest: async () => new Uint8Array(32).fill(0x33).buffer, + }); + expect(evidence?.selector).toEqual({ + type: 'TextPositionSelector', + start: 4, + end: 5, + }); + }); + + it('rejects a structural selection boundary that splits a grapheme cluster', async () => { + const editorRef = createRef(); + render(); + await waitFor(() => expect(editorRef.current?.getEditor()).not.toBeNull()); + + const handle = editorRef.current!; + act(() => { + // Position 2 falls between the base A and its combining acute accent. + handle.getEditor()!.commands.setTextSelection(2); + }); + + await expect( + handle.getTextPositionSelectorEvidence(undefined, { + digest: async () => new Uint8Array(32).buffer, + }), + ).rejects.toMatchObject({ + name: 'TextPositionSelectorEvidenceError', + code: 'grapheme_boundary', + }); + }); + it('keeps selector positions and revision bound to the same state while hashing is delayed', async () => { const editorRef = createRef(); render(); From 21535abbae1918a92e714fa749d69ca19a88e8c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 08:47:17 +0900 Subject: [PATCH 07/16] test(review): cover pre-editor text-position fallback --- src/components/useEditorHandle.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/useEditorHandle.test.tsx b/src/components/useEditorHandle.test.tsx index e56e1c66..912ce499 100644 --- a/src/components/useEditorHandle.test.tsx +++ b/src/components/useEditorHandle.test.tsx @@ -49,6 +49,7 @@ describe('useEditorHandle', () => { handle.getDocumentEnvelopeRevisionEvidence(), ).resolves.toBeNull(); await expect(handle.getSelectionRevisionEvidence()).resolves.toBeNull(); + await expect(handle.getTextPositionSelectorEvidence()).resolves.toBeNull(); expect(handle.validateDocumentEnvelope({})).toBe(false); expect(handle.validateDocumentEnvelopeBytes(new Uint8Array())).toBe(false); expect(handle.restoreDocumentEnvelope({})).toBeNull(); From 7916c316a5c6fb1386b75dff7b533185b9ec2ac8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 08:51:49 +0900 Subject: [PATCH 08/16] test(review): define leaf and segmenter failure contracts --- ...itor.textPositionSelectorEvidence.test.tsx | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/components/CwlEditor.textPositionSelectorEvidence.test.tsx b/src/components/CwlEditor.textPositionSelectorEvidence.test.tsx index 7d1db235..d70df3b1 100644 --- a/src/components/CwlEditor.textPositionSelectorEvidence.test.tsx +++ b/src/components/CwlEditor.textPositionSelectorEvidence.test.tsx @@ -65,6 +65,39 @@ describe('CwlEditor W3C text-position selector evidence', () => { }); }); + it('projects supported non-text leaf nodes as one object-replacement code point', async () => { + const editorRef = createRef(); + render(); + await waitFor(() => expect(editorRef.current?.getEditor()).not.toBeNull()); + + const handle = editorRef.current!; + act(() => { + handle.setDocumentJson({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'A' }, + { type: 'hardBreak' }, + { type: 'text', text: 'B' }, + ], + }, + ], + }); + handle.getEditor()!.commands.setTextSelection({ from: 3, to: 4 }); + }); + + const evidence = await handle.getTextPositionSelectorEvidence(undefined, { + digest: async () => new Uint8Array(32).fill(0x44).buffer, + }); + expect(evidence?.selector).toEqual({ + type: 'TextPositionSelector', + start: 2, + end: 3, + }); + }); + it('rejects a structural selection boundary that splits a grapheme cluster', async () => { const editorRef = createRef(); render(); @@ -86,6 +119,35 @@ describe('CwlEditor W3C text-position selector evidence', () => { }); }); + it('fails closed with a stable code when grapheme segmentation is unavailable', async () => { + const editorRef = createRef(); + render(); + await waitFor(() => expect(editorRef.current?.getEditor()).not.toBeNull()); + + const handle = editorRef.current!; + const intlWithSegmenter = Intl as typeof Intl & { Segmenter?: unknown }; + const originalSegmenter = intlWithSegmenter.Segmenter; + try { + Object.defineProperty(intlWithSegmenter, 'Segmenter', { + configurable: true, + value: undefined, + }); + await expect( + handle.getTextPositionSelectorEvidence(undefined, { + digest: async () => new Uint8Array(32).buffer, + }), + ).rejects.toMatchObject({ + name: 'TextPositionSelectorEvidenceError', + code: 'segmenter_unavailable', + }); + } finally { + Object.defineProperty(intlWithSegmenter, 'Segmenter', { + configurable: true, + value: originalSegmenter, + }); + } + }); + it('keeps selector positions and revision bound to the same state while hashing is delayed', async () => { const editorRef = createRef(); render(); From 7528edb217532688593724effb77bf9bd7ecb7b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 08:54:52 +0900 Subject: [PATCH 09/16] fix(review): fail closed when grapheme segmentation is unavailable --- src/textPositionSelectorEvidence.ts | 38 +++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/src/textPositionSelectorEvidence.ts b/src/textPositionSelectorEvidence.ts index 49bf43e8..25af8340 100644 --- a/src/textPositionSelectorEvidence.ts +++ b/src/textPositionSelectorEvidence.ts @@ -11,6 +11,11 @@ export const TEXT_POSITION_PROJECTION_VERSION = 1 as const; const BLOCK_SEPARATOR = '\n'; const LEAF_TEXT = '\uFFFC'; +/** Stable failure codes for text-position evidence construction. */ +export type TextPositionSelectorEvidenceErrorCode = + | 'grapheme_boundary' + | 'segmenter_unavailable'; + /** W3C Web Annotation text-position selector for one projected text range. */ export interface CwlEditorTextPositionSelector { /** W3C selector class name. */ @@ -41,12 +46,17 @@ export interface CwlEditorTextPositionSelectorEvidence { /** Raised when a structural selection cannot safely become text-position evidence. */ export class TextPositionSelectorEvidenceError extends Error { - /** Stable failure code for unsupported grapheme-splitting selection boundaries. */ - readonly code = 'grapheme_boundary' as const; - - constructor() { - super('Text-position evidence requires grapheme-cluster selection boundaries.'); + /** Stable public failure classification. */ + readonly code: TextPositionSelectorEvidenceErrorCode; + + constructor(code: TextPositionSelectorEvidenceErrorCode) { + super( + code === 'grapheme_boundary' + ? 'Text-position evidence requires grapheme-cluster selection boundaries.' + : 'Text-position evidence requires Unicode grapheme segmentation support.', + ); this.name = 'TextPositionSelectorEvidenceError'; + this.code = code; } } @@ -77,8 +87,13 @@ function codePointLength(value: string): number { /** Require a position to coincide with a Unicode grapheme-cluster boundary. */ function assertGraphemeBoundary(text: string, codeUnitOffset: number): void { - const Segmenter = (Intl as unknown as { Segmenter: GraphemeSegmenterConstructor }) - .Segmenter; + const Segmenter = ( + Intl as unknown as { Segmenter?: GraphemeSegmenterConstructor } + ).Segmenter; + if (typeof Segmenter !== 'function') { + throw new TextPositionSelectorEvidenceError('segmenter_unavailable'); + } + const boundaries = new Set([0, text.length]); for (const segment of new Segmenter(undefined, { granularity: 'grapheme' }).segment( text, @@ -86,7 +101,7 @@ function assertGraphemeBoundary(text: string, codeUnitOffset: number): void { boundaries.add(segment.index); } if (!boundaries.has(codeUnitOffset)) { - throw new TextPositionSelectorEvidenceError(); + throw new TextPositionSelectorEvidenceError('grapheme_boundary'); } } @@ -95,8 +110,11 @@ function assertGraphemeBoundary(text: string, codeUnitOffset: number): void { * * Projection version 1 uses logical ProseMirror document order, `\n` between * blocks, and U+FFFC OBJECT REPLACEMENT CHARACTER for non-text leaf nodes. The - * returned offsets count Unicode code points. The caller must bind the result to - * the same immutable document revision; this helper contains no selected text. + * returned offsets count Unicode code points. Selection boundaries must also be + * Unicode grapheme-cluster boundaries; runtimes without `Intl.Segmenter` fail + * closed instead of publishing ambiguous evidence. The caller must bind the + * result to the same immutable document revision; this helper contains no + * selected text. */ export function createTextPositionSelector( documentNode: ProseMirrorNode, From 609394e90a76dffe96a57e49c32b99fe34dcabc3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 08:55:45 +0900 Subject: [PATCH 10/16] docs(review): record text-position selector authority --- .../w3c-text-position-selector-evidence.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 docs/doctoring/w3c-text-position-selector-evidence.md diff --git a/docs/doctoring/w3c-text-position-selector-evidence.md b/docs/doctoring/w3c-text-position-selector-evidence.md new file mode 100644 index 00000000..b78495fd --- /dev/null +++ b/docs/doctoring/w3c-text-position-selector-evidence.md @@ -0,0 +1,70 @@ +# W3C text-position selector evidence + +Status: Implemented on active PR + +## Purpose + +Inkspan already exposes revision-scoped ProseMirror selection evidence. That contract is intentionally local to one editor state: ProseMirror positions are structural positions, not portable W3C text offsets. This active change adds a second, privacy-minimized interoperability representation that binds a W3C `TextPositionSelector` to the exact same immutable document revision without copying selected text into ordinary evidence metadata. + +## Standards authority + +The W3C *Web Annotation Data Model* Recommendation defines `TextPositionSelector` using an inclusive `start` and exclusive `end` in a normalized text representation. Its text-position processing model counts Unicode code points rather than implementation code units and cautions that selection boundaries should not split grapheme clusters. Position-only selectors avoid copying quote text into the annotation graph, but they are sensitive to source changes; Inkspan therefore binds every selector to an exact revision rather than claiming durable cross-revision anchoring. ๎ˆ€cite๎ˆ‚turn651590search0๎ˆ + +ProseMirror remains the editor-structure authority. Its document positions are tree-structural coordinates, and `Node.textBetween(from, to, blockSeparator, leafText)` is the primitive used by the versioned Inkspan projection. A ProseMirror position is never relabeled as a W3C position by identity. ๎ˆ€cite๎ˆ‚turn651590search2๎ˆ + +ECMA-402 13th edition, June 2026 is the current published ECMAScript internationalization standard. Inkspan uses `Intl.Segmenter` with `granularity: 'grapheme'` to reject selection boundaries that do not coincide with grapheme-cluster boundaries. A runtime lacking that capability fails closed with the stable `segmenter_unavailable` classification instead of silently weakening the evidence contract. ๎ˆ€cite๎ˆ‚turn694137search1๎ˆ + +## Projection version 1 + +`textProjection` is part of the public evidence because selector offsets are meaningless without a deterministic projection identity. + +Projection v1 is: + +- `id = "inkspan-prosemirror-text"`; +- `version = 1`; +- logical ProseMirror document order, independent of visual bidirectional rendering order; +- U+000A LINE FEED between block boundaries where ProseMirror `textBetween` inserts the configured block separator; +- U+FFFC OBJECT REPLACEMENT CHARACTER for supported non-text leaf nodes; +- Unicode-code-point counting for W3C `start` and `end`; +- inclusive `start` and exclusive `end`; +- grapheme-cluster boundary validation before evidence is returned. + +Array/tree order and actual text content remain authoritative. The projection does not normalize Unicode text, reorder bidirectional text visually, or invent source quote text. + +## Atomicity + +`getTextPositionSelectorEvidence()` captures one `editor.state` before asynchronous digest work begins. The projection and selector are derived from that captured `state.doc` and `state.selection`; the document envelope used for SHA-256 revision derivation is produced from the same captured `state.doc`. A live editor mutation after digest work starts cannot change the pending evidence object. + +The returned top-level evidence, `selector`, and `textProjection` are frozen. Ordinary evidence contains no selected text, surrounding quote, complete document envelope, actor, tenant, timestamp, model identity, authorization decision, transport result, signature, or durable-write claim. + +## Failure semantics + +- Before editor creation, the handle resolves to `null`, matching the existing revision-scoped selection fallback. +- A selection boundary inside a grapheme cluster fails with `TextPositionSelectorEvidenceError.code = "grapheme_boundary"`. +- Absence of supported `Intl.Segmenter` grapheme segmentation fails with `code = "segmenter_unavailable"`. +- Existing document-envelope and digest validation failures retain their own fail-closed behavior. +- The API never silently adjusts an invalid boundary to a nearby grapheme boundary because doing so would change the user's selected range without explicit authority. + +## Privacy and ownership + +Inkspan owns only the deterministic projection and exact-revision selector evidence. Hosts own annotation identifiers and bodies, source-resource IRI policy, authentication, authorization, tenant isolation, durable persistence, retention, audit, collaborative anchors, re-anchoring after revisions, publication, and any W3C Annotation graph stored or transmitted outside the editor. + +A revision digest plus text-position selector proves neither who selected the range, when it was selected, whether it was authorized, nor whether an annotation was durably accepted. Hosts must compare the bound revision before reusing the positions. If the document changed, the host chooses compare, merge, fork, reload, or a separately designed collaborative re-anchoring strategy. + +## Compatibility and rollback + +Projection semantics are versioned. A future change to block separators, leaf representations, normalization, code-point interpretation, or grapheme policy must publish a new projection version rather than silently reinterpret stored v1 offsets. Unknown projection versions must fail closed in any future parser/consumer. + +Rollback removes the new selector API while leaving the pre-existing ProseMirror revision-scoped selection evidence intact. Rollback does not authorize a host to reinterpret existing v1 W3C selectors as ProseMirror coordinates. + +## Verification + +Permanent tests cover astral Unicode code points, bidirectional multi-block logical order, U+FFFC leaf-node projection, combining-mark grapheme rejection, unavailable-segmenter failure, same-state atomicity under delayed hashing, frozen evidence, pre-editor null behavior, and absence of source text in ordinary evidence. The repository's exact 100% owned production coverage gate applies to the implementation. + +## References โ€” APA 7th + +Ecma International. (2026). *ECMA-402: ECMAScript 2026 internationalization API specification* (13th ed.). https://ecma-international.org/publications-and-standards/standards/ecma-402/ + +ProseMirror. (n.d.). *ProseMirror reference manual*. Retrieved August 10, 2026, from https://prosemirror.net/docs/ref/ + +World Wide Web Consortium. (2017, February 23). *Web Annotation Data Model*. https://www.w3.org/TR/annotation-model/ From 12274e609351f774fb8faa4917c6ec0f062435b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 08:56:18 +0900 Subject: [PATCH 11/16] docs(review): remove connector-only citation markers --- docs/doctoring/w3c-text-position-selector-evidence.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/doctoring/w3c-text-position-selector-evidence.md b/docs/doctoring/w3c-text-position-selector-evidence.md index b78495fd..04cb6ad0 100644 --- a/docs/doctoring/w3c-text-position-selector-evidence.md +++ b/docs/doctoring/w3c-text-position-selector-evidence.md @@ -8,11 +8,11 @@ Inkspan already exposes revision-scoped ProseMirror selection evidence. That con ## Standards authority -The W3C *Web Annotation Data Model* Recommendation defines `TextPositionSelector` using an inclusive `start` and exclusive `end` in a normalized text representation. Its text-position processing model counts Unicode code points rather than implementation code units and cautions that selection boundaries should not split grapheme clusters. Position-only selectors avoid copying quote text into the annotation graph, but they are sensitive to source changes; Inkspan therefore binds every selector to an exact revision rather than claiming durable cross-revision anchoring. ๎ˆ€cite๎ˆ‚turn651590search0๎ˆ +The W3C *Web Annotation Data Model* Recommendation defines `TextPositionSelector` using an inclusive `start` and exclusive `end` in a normalized text representation. Its text-position processing model counts Unicode code points rather than implementation code units and cautions that selection boundaries should not split grapheme clusters. Position-only selectors avoid copying quote text into the annotation graph, but they are sensitive to source changes; Inkspan therefore binds every selector to an exact revision rather than claiming durable cross-revision anchoring. -ProseMirror remains the editor-structure authority. Its document positions are tree-structural coordinates, and `Node.textBetween(from, to, blockSeparator, leafText)` is the primitive used by the versioned Inkspan projection. A ProseMirror position is never relabeled as a W3C position by identity. ๎ˆ€cite๎ˆ‚turn651590search2๎ˆ +ProseMirror remains the editor-structure authority. Its document positions are tree-structural coordinates, and `Node.textBetween(from, to, blockSeparator, leafText)` is the primitive used by the versioned Inkspan projection. A ProseMirror position is never relabeled as a W3C position by identity. -ECMA-402 13th edition, June 2026 is the current published ECMAScript internationalization standard. Inkspan uses `Intl.Segmenter` with `granularity: 'grapheme'` to reject selection boundaries that do not coincide with grapheme-cluster boundaries. A runtime lacking that capability fails closed with the stable `segmenter_unavailable` classification instead of silently weakening the evidence contract. ๎ˆ€cite๎ˆ‚turn694137search1๎ˆ +ECMA-402 13th edition, June 2026 is the current published ECMAScript internationalization standard. Inkspan uses `Intl.Segmenter` with `granularity: 'grapheme'` to reject selection boundaries that do not coincide with grapheme-cluster boundaries. A runtime lacking that capability fails closed with the stable `segmenter_unavailable` classification instead of silently weakening the evidence contract. ## Projection version 1 From e4b617095e41e47e2fc067b73b990e20ddb18b12 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 08:57:06 +0900 Subject: [PATCH 12/16] feat(review): export selector error code type --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index c737fd4f..f7905449 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,6 +47,7 @@ export type { CwlEditorTextPositionSelector, CwlEditorTextPositionSelectorEvidence, CwlEditorTextProjectionIdentity, + TextPositionSelectorEvidenceErrorCode, } from './textPositionSelectorEvidence.js'; // Versioned, lossless persistence boundary. From 0e09458d3650046c09fc3c3a5464d520d22612f9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 08:57:55 +0900 Subject: [PATCH 13/16] docs(review): document W3C selector projection --- docs/selection-lifecycle.md | 96 ++++++++++++++++++++++++++++--------- 1 file changed, 74 insertions(+), 22 deletions(-) diff --git a/docs/selection-lifecycle.md b/docs/selection-lifecycle.md index 3a88b742..cc49a0f4 100644 --- a/docs/selection-lifecycle.md +++ b/docs/selection-lifecycle.md @@ -73,26 +73,69 @@ any explicit re-anchor, compare, merge, fork, durable comment, or collaborative relative-position workflow and must revalidate authorization and the target revision before mutation. -This API is deliberately revision-scoped rather than a generic web-annotation -selector. ProseMirror positions are structural positions inside one ProseMirror -document tree. They are not W3C `TextPositionSelector` values, which identify -Unicode code-point offsets in a normalized textual representation, and Inkspan -does not silently convert between those coordinate systems. Hosts that need W3C -Web Annotation interoperability must define and test a separate text projection, -selector conversion, state/provenance model, and re-anchoring policy. - The local SHA-256 revision is equality evidence only. It is not a signature, authorization grant, user or tenant identifier, server-selected durable ETag, proof that a review was accepted, or proof that a persistence transaction committed. Durable services remain responsible for authenticated atomic concurrency and audit semantics. +## W3C text-position selector evidence + +The active W3C interoperability line adds a separate imperative capture rather +than relabeling ProseMirror structural positions: + +```tsx +const evidence = + await editorRef.current?.getTextPositionSelectorEvidence(); + +if (evidence) { + publishAnnotationProposal({ + expectedDocumentRevision: evidence.revision.strongEntityTag, + selector: evidence.selector, + textProjection: evidence.textProjection, + }); +} +``` + +`getTextPositionSelectorEvidence()` captures one immutable editor state, derives +the selector projection and document envelope from that same state, and only +then performs asynchronous SHA-256 revision derivation. The returned evidence is +frozen and contains no selected quote text. + +Projection version 1 is explicitly identified as +`inkspan-prosemirror-text` version `1`. It uses logical ProseMirror document +order, U+000A LINE FEED as the configured block separator, and U+FFFC OBJECT +REPLACEMENT CHARACTER for supported non-text leaf nodes. `selector.start` is an +inclusive Unicode-code-point offset and `selector.end` is exclusive. Visual +bidirectional reordering does not alter the logical text stream. + +Selection boundaries must coincide with grapheme-cluster boundaries. Inkspan +uses `Intl.Segmenter` grapheme segmentation for this check. A boundary inside a +grapheme cluster fails with `grapheme_boundary`; a runtime without the required +segmenter fails with `segmenter_unavailable`. Inkspan never silently moves an +invalid boundary to make an annotation appear valid. + +This selector remains revision-scoped. It is not a durable cross-revision +anchor, `TextQuoteSelector`, actor identity, authorization record, timestamp, +signature, or persistence receipt. Hosts own source-resource identifiers, +annotation bodies and identifiers, publication, storage, authorization, tenant +policy, and any re-anchoring after the document revision changes. + +The exact rationale, projection contract, privacy boundary, rollback policy, and +APA 7 references are recorded in +`docs/doctoring/w3c-text-position-selector-evidence.md`. + ## Position semantics -Selection values are ProseMirror document positions, not DOM offsets, Markdown -character indexes, HTML byte offsets, or durable annotation identifiers. A -snapshot describes the editor state at the time of the callback. Any subsequent -transaction can remap or invalidate those coordinates. +`CwlEditorSelectionSnapshot` values are ProseMirror document positions, not DOM +offsets, Markdown character indexes, HTML byte offsets, W3C text positions, or +durable annotation identifiers. A snapshot describes the editor state at the +time of the callback. Any subsequent transaction can remap or invalidate those +coordinates. + +W3C text-position evidence is a distinct coordinate system with an explicit +projection identity. Consumers must not mix the two systems even when numerical +values happen to be equal for a simple document. Hosts that perform work synchronously can inspect or transform the current selection through the supplied editor. Hosts that defer work across document @@ -127,7 +170,7 @@ or trusted audit record. Hosts remain responsible for document authorization, operation-level permission checks, content classification, telemetry minimization, and validating any later mutation against the current authorized document. -The callback and revision-scoped capture perform no network request, read no +The callback and revision-scoped captures perform no network request, read no environment variable, and introduce no transport, persistence, database, or naruon-specific runtime dependency. They preserve Inkspan's modular MSA boundary and require no database object or identifier. @@ -150,11 +193,14 @@ accessible name, and return focus predictably when the control closes. - [ProseMirror guide: document positions and selection](https://prosemirror.net/docs/guide/) โ€” immutable editor state, document-relative positions, and selection coordinates. -- [ProseMirror reference: Selection](https://prosemirror.net/docs/ref/#state.Selection) - โ€” `anchor`, `head`, `from`, `to`, and mapping behavior. +- [ProseMirror reference](https://prosemirror.net/docs/ref/) โ€” `Selection`, + immutable document nodes, and `Node.textBetween` projection semantics. - [W3C Web Annotation Data Model](https://www.w3.org/TR/annotation-model/) - โ€” interoperable selector semantics, including the materially different - Unicode-code-point `TextPositionSelector` model and selector-state guidance. + โ€” interoperable selector semantics including Unicode-code-point + `TextPositionSelector` positions and selector-state guidance. +- [ECMA-402](https://ecma-international.org/publications-and-standards/standards/ecma-402/) + โ€” the current published ECMAScript internationalization specification that + defines `Intl.Segmenter`. - [RFC 9110, HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110) โ€” strong and weak entity-tag semantics and conditional-request boundaries. @@ -163,9 +209,15 @@ accessible name, and return focus predictably when the control closes. The TypeScript suite verifies absent callbacks, callbacks attached after mount, live callback replacement, stable editor identity, caret and range snapshots, and parity between standalone and provider-neutral collaborative editors. It -also verifies that revision-scoped range and caret evidence is frozen, contains -no selected text or complete envelope, remains bound to the pre-hash document -state while later edits and selection moves occur, and returns `null` before an -editor exists. The public types are compiled through the packed-package consumer -gate under the repository-wide 100% statement, branch, function, and line +also verifies that revision-scoped range/caret evidence is frozen, contains no +selected text or complete envelope, remains bound to the pre-hash document state +while later edits and selection moves occur, and returns `null` before an editor +exists. + +The W3C selector suite additionally verifies astral Unicode code points, +multi-block bidirectional logical order, supported leaf-node projection, +grapheme-boundary rejection, deterministic failure when grapheme segmentation is +unavailable, same-state revision atomicity, frozen evidence, and source-text +omission. Public declarations and packed-package consumers remain subject to the +repository-wide exact 100% production statement, branch, function, and line coverage policy. From b417d8d306d18dd0d06c392cc1be2f6cd93ac389 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 08:59:04 +0900 Subject: [PATCH 14/16] test(docs): bind text-position selector authority --- src/textPositionSelectorDocumentation.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/textPositionSelectorDocumentation.test.ts diff --git a/src/textPositionSelectorDocumentation.test.ts b/src/textPositionSelectorDocumentation.test.ts new file mode 100644 index 00000000..1f52a5d5 --- /dev/null +++ b/src/textPositionSelectorDocumentation.test.ts @@ -0,0 +1,56 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const repositoryFile = (path: string): string => + readFileSync(resolve(process.cwd(), path), 'utf8'); + +const normalizedDocument = (path: string): string => + repositoryFile(path).replace(/\s+/gu, ' ').trim(); + +describe('W3C text-position selector documentation contract', () => { + it('documents one explicit versioned logical-text projection', () => { + const lifecycle = normalizedDocument('docs/selection-lifecycle.md'); + const doctoring = normalizedDocument( + 'docs/doctoring/w3c-text-position-selector-evidence.md', + ); + + for (const document of [lifecycle, doctoring]) { + expect(document).toContain('inkspan-prosemirror-text'); + expect(document).toContain('U+000A'); + expect(document).toContain('U+FFFC'); + expect(document).toContain('Unicode code point'); + expect(document).toContain('grapheme'); + expect(document).toContain('segmenter_unavailable'); + } + }); + + it('keeps W3C evidence revision-scoped and privacy-minimized', () => { + const lifecycle = normalizedDocument('docs/selection-lifecycle.md'); + const doctoring = normalizedDocument( + 'docs/doctoring/w3c-text-position-selector-evidence.md', + ); + + for (const document of [lifecycle, doctoring]) { + expect(document).toContain('revision-scoped'); + expect(document).toContain('selected text'); + expect(document).toContain('host'); + expect(document).toMatch(/re-anch/i); + expect(document).not.toMatch(/W3C[^.]{0,80}(?:authorization|durable write) proof/iu); + } + }); + + it('records primary standards in APA-style doctoring', () => { + const doctoring = repositoryFile( + 'docs/doctoring/w3c-text-position-selector-evidence.md', + ); + + expect(doctoring).toContain('World Wide Web Consortium. (2017, February 23).'); + expect(doctoring).toContain('https://www.w3.org/TR/annotation-model/'); + expect(doctoring).toContain('ProseMirror. (n.d.).'); + expect(doctoring).toContain('https://prosemirror.net/docs/ref/'); + expect(doctoring).toContain('Ecma International. (2026).'); + expect(doctoring).toContain('13th ed.'); + }); +}); From 3abcfb873cc16e85e4a7b23b47ec1301a8e66edd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 08:59:24 +0900 Subject: [PATCH 15/16] test(package): verify text-position selector consumer --- .../verify-text-position-selector-package.mjs | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 scripts/verify-text-position-selector-package.mjs diff --git a/scripts/verify-text-position-selector-package.mjs b/scripts/verify-text-position-selector-package.mjs new file mode 100644 index 00000000..3ae95f51 --- /dev/null +++ b/scripts/verify-text-position-selector-package.mjs @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packageJson = JSON.parse( + readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), +); +const packageName = packageJson.name; +const verificationDirectory = mkdtempSync( + join(repositoryRoot, '.text-position-selector-verification-'), +); + +/** Execute one strict package-consumer verification command. */ +function run(command, argumentsList) { + return execFileSync(command, argumentsList, { + cwd: repositoryRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }); +} + +try { + const esmPackage = await import(packageName); + assert.equal( + esmPackage.TEXT_POSITION_PROJECTION_ID, + 'inkspan-prosemirror-text', + ); + assert.equal(esmPackage.TEXT_POSITION_PROJECTION_VERSION, 1); + assert.equal(typeof esmPackage.TextPositionSelectorEvidenceError, 'function'); + assert.equal(typeof esmPackage.createTextPositionSelector, 'function'); + + const require = createRequire(import.meta.url); + const commonJsPackage = require(packageName); + assert.equal( + commonJsPackage.TEXT_POSITION_PROJECTION_ID, + 'inkspan-prosemirror-text', + ); + assert.equal(commonJsPackage.TEXT_POSITION_PROJECTION_VERSION, 1); + assert.equal( + typeof commonJsPackage.TextPositionSelectorEvidenceError, + 'function', + ); + assert.equal(typeof commonJsPackage.createTextPositionSelector, 'function'); + + const consumerPath = join(verificationDirectory, 'consumer.ts'); + writeFileSync( + consumerPath, + `import { + TEXT_POSITION_PROJECTION_ID, + TEXT_POSITION_PROJECTION_VERSION, + TextPositionSelectorEvidenceError, + type CwlEditorHandle, + type CwlEditorTextPositionSelectorEvidence, + type CwlEditorTextProjectionIdentity, + type TextPositionSelectorEvidenceErrorCode, +} from '${packageName}'; + +declare const handle: CwlEditorHandle; +const captured: Promise = + handle.getTextPositionSelectorEvidence(); +const projection: CwlEditorTextProjectionIdentity = { + id: TEXT_POSITION_PROJECTION_ID, + version: TEXT_POSITION_PROJECTION_VERSION, +}; +const failureCode: TextPositionSelectorEvidenceErrorCode = + 'segmenter_unavailable'; +const failure = new TextPositionSelectorEvidenceError(failureCode); +const checked: Promise = captured.then((evidence) => { + if (evidence === null) return; + const start: number = evidence.selector.start; + const end: number = evidence.selector.end; + const tag: string = evidence.revision.strongEntityTag; + void [start, end, tag, projection]; +}); +void [failure.code, checked]; +`, + 'utf8', + ); + + run('pnpm', [ + 'exec', + 'tsc', + '--noEmit', + '--strict', + '--skipLibCheck', + 'false', + '--module', + 'NodeNext', + '--moduleResolution', + 'NodeNext', + '--target', + 'ES2022', + '--lib', + 'ES2022,DOM,DOM.Iterable', + consumerPath, + ]); + + console.log( + `Verified ${packageName}: W3C text-position selector ESM, CommonJS, and strict TypeScript consumer contracts.`, + ); +} finally { + rmSync(verificationDirectory, { recursive: true, force: true }); +} From 16185c8ecfa8371379e5e44d5cc898e33f31d8d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 08:59:59 +0900 Subject: [PATCH 16/16] test(package): include selector consumer verification --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f0287f2e..521fbd4b 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ "test:watch": "vitest", "coverage": "vitest run --coverage", "test:package-config": "node --test ./scripts/revision-evidence-consumer-config.test.mjs ./scripts/release-metadata.test.mjs", - "verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs" + "verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0",