From 2f4bfc0a8cf50ef00f667fcf467d63a0ee1229c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 10:19:34 +0900 Subject: [PATCH 01/21] fix(ux): customer copy states the next action and hides internal boundaries - Toolbar image title drops base64 jargon - Inline-image policy error names accepted formats instead of URI internals - Converter/blob errors give size limits, retry, or alternate-file guidance - Paste failures state what to try next (less content / plain text) - Doc-contract tests updated to the shipped phrasing Audit rules: no internal implementation boundaries in customer-visible text; every explanation guides the next action. --- src/components/CwlEditor.test.tsx | 6 +++--- src/components/Toolbar.test.tsx | 8 +++++--- src/components/Toolbar.tsx | 2 +- src/converter/base64.fallbacks.test.ts | 2 +- src/converter/base64.ts | 18 ++++++++++++++++-- src/extensions/Base64Image.ts | 4 +++- .../SafeClipboard.coverageContract.test.ts | 4 ++-- src/extensions/SafeClipboard.test.ts | 6 +++--- src/extensions/SafeClipboard.ts | 10 ++++++---- src/extensions/SafeClipboardExtension.test.ts | 2 +- src/policy/inlineImagePolicy.ts | 2 +- 11 files changed, 42 insertions(+), 22 deletions(-) diff --git a/src/components/CwlEditor.test.tsx b/src/components/CwlEditor.test.tsx index aa937f34..376b36d7 100644 --- a/src/components/CwlEditor.test.tsx +++ b/src/components/CwlEditor.test.tsx @@ -121,7 +121,7 @@ describe('inline image helper (used by paste/drop/upload)', () => { maxDimension: 0, quality: 0.85, }), - ).rejects.toThrow(/exceeds/); + ).rejects.toThrow(/too large to insert/); }); }); @@ -363,7 +363,7 @@ describe('CwlEditor onImageError (paste/drop commercial path)', () => { expect(handled).toBe(true); await waitFor(() => expect(onImageError).toHaveBeenCalled()); - expect(String(onImageError.mock.calls[0]![0])).toMatch(/exceeds/i); + expect(String(onImageError.mock.calls[0]![0])).toMatch(/too large to insert/i); expect(ed!.getHTML()).not.toContain('data:image'); }); @@ -448,7 +448,7 @@ describe('CwlEditor onImageError (paste/drop commercial path)', () => { expect(handled).toBe(true); await waitFor(() => expect(onImageError).toHaveBeenCalled()); - expect(String(onImageError.mock.calls[0]![0])).toMatch(/exceeds/i); + expect(String(onImageError.mock.calls[0]![0])).toMatch(/too large to insert/i); }); }); diff --git a/src/components/Toolbar.test.tsx b/src/components/Toolbar.test.tsx index aeaf6895..a79d5a71 100644 --- a/src/components/Toolbar.test.tsx +++ b/src/components/Toolbar.test.tsx @@ -112,7 +112,7 @@ describe('Toolbar', () => { const italic = screen.getByRole('button', { name: /Italic/ }); const insertTable = screen.getByRole('button', { name: /^Insert table$/ }); const insertImage = screen.getByRole('button', { - name: /Insert inline \(base64\) image/, + name: /Insert inline image/, }); const enabledButtons = ( screen.getAllByRole('button') as HTMLButtonElement[] @@ -149,7 +149,7 @@ describe('Toolbar', () => { const bold = screen.getByRole('button', { name: /Bold/ }); const insertImage = screen.getByRole('button', { - name: /Insert inline \(base64\) image/, + name: /Insert inline image/, }); fireEvent.focus(insertImage); expect(insertImage).toHaveAttribute('tabindex', '0'); @@ -276,7 +276,9 @@ describe('Toolbar', () => { fireEvent.change(fileInput(), { target: { files: [file] } }); await waitFor(() => expect(onImageError).toHaveBeenCalled()); expect(editor.getHTML()).not.toContain('data:image'); - expect(String(onImageError.mock.calls[0]![0])).toMatch(/exceeds/i); + expect(String(onImageError.mock.calls[0]![0])).toMatch( + /too large to insert/i, + ); }); it('does not throw when oversized and no onImageError is wired', async () => { diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 55136e55..08e7b12b 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -368,7 +368,7 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) { onClick={() => editor.chain().focus().deleteTable().run()} /> fileInputRef.current?.click()} /> diff --git a/src/converter/base64.fallbacks.test.ts b/src/converter/base64.fallbacks.test.ts index 13586cb2..1b62fe99 100644 --- a/src/converter/base64.fallbacks.test.ts +++ b/src/converter/base64.fallbacks.test.ts @@ -76,7 +76,7 @@ describe('readBlobBytes environment fallbacks', () => { vi.stubGlobal('FileReader', NullErrorReader); const fakeBlob = { type: 'image/png' } as unknown as Blob; await expect(blobToDataUri(fakeBlob)).rejects.toThrow( - /FileReader failed to read Blob/, + /This file couldn't be read/, ); }); diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 76313e28..c9a3435c 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -19,7 +19,7 @@ export class Base64SizeError extends Error { readonly maxBytes: number; constructor(bytes: number, maxBytes: number) { super( - `Payload of ${bytes} bytes exceeds the configured limit of ${maxBytes} bytes.`, + `This file is too large to insert. Choose a file under ${formatByteLimit(maxBytes)}.`, ); this.name = 'Base64SizeError'; this.bytes = bytes; @@ -27,6 +27,17 @@ export class Base64SizeError extends Error { } } +/** Render a byte limit in the largest exact unit users reason about. */ +function formatByteLimit(maxBytes: number): string { + if (maxBytes >= 1024 * 1024 && maxBytes % (1024 * 1024) === 0) { + return `${maxBytes / (1024 * 1024)} MB`; + } + if (maxBytes >= 1024 && maxBytes % 1024 === 0) { + return `${maxBytes / 1024} KB`; + } + return `${maxBytes} bytes`; +} + /** Error thrown when a string is not a well-formed data URI. */ export class DataUriParseError extends Error { constructor(message: string) { @@ -224,7 +235,10 @@ async function readBlobBytes(blob: Blob): Promise { reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer)); reader.onerror = () => - reject(reader.error ?? new Error('FileReader failed to read Blob.')); + reject( + reader.error ?? + new Error("This file couldn't be read. Try again or choose a different file."), + ); reader.readAsArrayBuffer(blob); }); } diff --git a/src/extensions/Base64Image.ts b/src/extensions/Base64Image.ts index e506d02c..4a94c8a1 100644 --- a/src/extensions/Base64Image.ts +++ b/src/extensions/Base64Image.ts @@ -118,7 +118,9 @@ export async function imageFileToInlineDataUri( /** Normalize a caught value to the Error contract exposed to hosts. */ function normalizeImageError(error: unknown): Error { /* v8 ignore next -- all shipped validation and conversion paths throw Error. */ - return error instanceof Error ? error : new Error('Image processing failed.'); + return error instanceof Error + ? error + : new Error("This image couldn't be inserted. Try a different image file."); } export const Base64Image = Image.extend({ diff --git a/src/extensions/SafeClipboard.coverageContract.test.ts b/src/extensions/SafeClipboard.coverageContract.test.ts index 5d3bcd0b..34f7db10 100644 --- a/src/extensions/SafeClipboard.coverageContract.test.ts +++ b/src/extensions/SafeClipboard.coverageContract.test.ts @@ -32,7 +32,7 @@ describe('SafeClipboard fail-closed coverage contract', () => { ).toThrowError( expect.objectContaining({ code: 'invalid_html', - message: 'Rich clipboard HTML could not be sanitized.', + message: "This content can't be inserted here. Try pasting as plain text instead.", }), ); }); @@ -59,7 +59,7 @@ describe('SafeClipboard fail-closed coverage contract', () => { it('keeps the redacted sanitizer error class stable', () => { expect(new ClipboardSanitizationError('invalid_html')).toMatchObject({ code: 'invalid_html', - message: 'Rich clipboard HTML could not be sanitized.', + message: "This content can't be inserted here. Try pasting as plain text instead.", name: 'ClipboardSanitizationError', }); }); diff --git a/src/extensions/SafeClipboard.test.ts b/src/extensions/SafeClipboard.test.ts index 95e1c73a..5b6bed9f 100644 --- a/src/extensions/SafeClipboard.test.ts +++ b/src/extensions/SafeClipboard.test.ts @@ -165,7 +165,7 @@ describe('sanitizeRichClipboardHtml', () => { ).toThrowError( expect.objectContaining({ code: 'input_too_large', - message: 'Rich clipboard HTML exceeds the configured byte limit.', + message: 'The pasted content is too large to insert. Try pasting less content at once.', }), ); }); @@ -176,7 +176,7 @@ describe('sanitizeRichClipboardHtml', () => { ).toThrowError( expect.objectContaining({ code: 'invalid_html', - message: 'Rich clipboard HTML could not be sanitized.', + message: "This content can't be inserted here. Try pasting as plain text instead.", }), ); }); @@ -373,7 +373,7 @@ describe('SafeClipboard extension', () => { expect(onError).toHaveBeenCalledWith( expect.objectContaining({ code: 'invalid_html', - message: 'Rich clipboard HTML could not be sanitized.', + message: "This content can't be inserted here. Try pasting as plain text instead.", }), ); diff --git a/src/extensions/SafeClipboard.ts b/src/extensions/SafeClipboard.ts index a3eaef4c..05c54f97 100644 --- a/src/extensions/SafeClipboard.ts +++ b/src/extensions/SafeClipboard.ts @@ -49,13 +49,15 @@ const ERROR_MESSAGES: Readonly> = Object.freeze({ dom_unavailable: 'Rich clipboard sanitization requires a DOM-capable document.', - input_too_large: 'Rich clipboard HTML exceeds the configured byte limit.', + input_too_large: + 'The pasted content is too large to insert. Try pasting less content at once.', node_limit_exceeded: - 'Rich clipboard HTML exceeds the configured node limit.', + 'The pasted content is too complex to insert. Try pasting less content at once.', depth_limit_exceeded: - 'Rich clipboard HTML exceeds the configured depth limit.', + 'The pasted content is too deeply nested to insert. Try pasting less content at once.', invalid_configuration: 'Rich clipboard configuration is invalid.', - invalid_html: 'Rich clipboard HTML could not be sanitized.', + invalid_html: + "This content can't be inserted here. Try pasting as plain text instead.", }); /** Error whose stable code and message never disclose clipboard content. */ diff --git a/src/extensions/SafeClipboardExtension.test.ts b/src/extensions/SafeClipboardExtension.test.ts index 6932326a..44df393d 100644 --- a/src/extensions/SafeClipboardExtension.test.ts +++ b/src/extensions/SafeClipboardExtension.test.ts @@ -114,7 +114,7 @@ describe('SafeClipboard TipTap v2 adapter', () => { expect(onError).toHaveBeenCalledWith( expect.objectContaining({ code: 'invalid_html', - message: 'Rich clipboard HTML could not be sanitized.', + message: "This content can't be inserted here. Try pasting as plain text instead.", }), ); expect(String(onError.mock.calls[0]?.[0])).not.toContain('private option'); diff --git a/src/policy/inlineImagePolicy.ts b/src/policy/inlineImagePolicy.ts index 7d50f0e4..20616296 100644 --- a/src/policy/inlineImagePolicy.ts +++ b/src/policy/inlineImagePolicy.ts @@ -25,7 +25,7 @@ export class Base64ImageSourceError extends Error { constructor(source: unknown) { const sourcePreview = redactImageSource(source); super( - `Image source must be a strict inline base64 raster data URI (${sourcePreview}).`, + "This image format can't be inserted. Use a PNG, JPEG, GIF, WebP, AVIF, BMP, or ICO image.", ); this.name = 'Base64ImageSourceError'; this.sourcePreview = sourcePreview; From 0efb51071947243ec837956ffb6621db3cb31d9f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 10:47:41 +0900 Subject: [PATCH 02/21] test(ux): cover every human-unit branch of the size guidance message --- src/converter/base64.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/converter/base64.test.ts b/src/converter/base64.test.ts index 15748b47..9035b46e 100644 --- a/src/converter/base64.test.ts +++ b/src/converter/base64.test.ts @@ -214,6 +214,18 @@ describe('data URI parsing & decoding', () => { const uri = bytesToDataUri(PNG_BYTES); expect(() => dataUriToBytes(uri, { maxBytes: 4 })).toThrow(Base64SizeError); }); + it('size guidance names the limit in exact human units', () => { + const renderLimit = (maxBytes: number): string => + new Base64SizeError(PNG_BYTES.byteLength, maxBytes).message; + // Sub-kilobyte limits stay in bytes; whole KB and MB limits use the unit + // users reason about, so the next action ("choose a file under N") is + // readable without mental arithmetic. + expect(renderLimit(4)).toContain('under 4 bytes'); + expect(renderLimit(2048)).toContain('under 2 KB'); + expect(renderLimit(3 * 1024 * 1024)).toContain('under 3 MB'); + // Non-aligned limits fall back to exact bytes rather than rounding. + expect(renderLimit(1500)).toContain('under 1500 bytes'); + }); }); describe('full round-trip', () => { From ee78bb40091d5dac04504e7cd76feb3adb8ab261 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 11:23:22 +0900 Subject: [PATCH 03/21] fix(ux): make the hostile non-Error image rejection fallback coverage-honest Early-return the Error path and scope the v8 ignore to the unreachable fallback arm only, keeping the global 100% threshold truthful. --- src/extensions/Base64Image.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/extensions/Base64Image.ts b/src/extensions/Base64Image.ts index 4a94c8a1..4b585fdd 100644 --- a/src/extensions/Base64Image.ts +++ b/src/extensions/Base64Image.ts @@ -117,10 +117,13 @@ export async function imageFileToInlineDataUri( /** Normalize a caught value to the Error contract exposed to hosts. */ function normalizeImageError(error: unknown): Error { - /* v8 ignore next -- all shipped validation and conversion paths throw Error. */ - return error instanceof Error - ? error - : new Error("This image couldn't be inserted. Try a different image file."); + if (error instanceof Error) { + return error; + } + /* v8 ignore next -- shipped validation and conversion paths always throw + * Error subclasses; this fallback only guards hostile non-Error values + * thrown across the host boundary. */ + return new Error("This image couldn't be inserted. Try a different image file."); } export const Base64Image = Image.extend({ From 5b8037d26a7527434b4aa11cbf8c2fdd92cbc059 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 11:34:07 +0900 Subject: [PATCH 04/21] fix(ux): single-line v8 ignore so the guarded fallback stays coverage-honest --- src/extensions/Base64Image.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/extensions/Base64Image.ts b/src/extensions/Base64Image.ts index 4b585fdd..560a85b5 100644 --- a/src/extensions/Base64Image.ts +++ b/src/extensions/Base64Image.ts @@ -120,9 +120,9 @@ function normalizeImageError(error: unknown): Error { if (error instanceof Error) { return error; } - /* v8 ignore next -- shipped validation and conversion paths always throw - * Error subclasses; this fallback only guards hostile non-Error values - * thrown across the host boundary. */ + // Hostile non-Error values can only cross from host callbacks; shipped + // validation and conversion paths always throw Error subclasses. + /* v8 ignore next */ return new Error("This image couldn't be inserted. Try a different image file."); } From 147321059ff04220a49db3f2318a767406239fac Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 11:45:44 +0900 Subject: [PATCH 05/21] fix(ux): use v8 ignore start/stop for the hostile-rejection fallback --- src/extensions/Base64Image.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/extensions/Base64Image.ts b/src/extensions/Base64Image.ts index 560a85b5..62f5fd17 100644 --- a/src/extensions/Base64Image.ts +++ b/src/extensions/Base64Image.ts @@ -122,8 +122,9 @@ function normalizeImageError(error: unknown): Error { } // Hostile non-Error values can only cross from host callbacks; shipped // validation and conversion paths always throw Error subclasses. - /* v8 ignore next */ + /* v8 ignore start */ return new Error("This image couldn't be inserted. Try a different image file."); + /* v8 ignore stop */ } export const Base64Image = Image.extend({ From 594a0aee3958a609710adb68ecd7fa5036c51f56 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 11:59:17 +0900 Subject: [PATCH 06/21] fix(ux): export normalizeImageError and cover both arms with real tests Replace suppression pragmas with truthful coverage: the Error passthrough and the hostile non-Error fallback are both exercised, and the fallback message is asserted to stay actionable without echoing hostile input. --- src/extensions/Base64Image.ts | 15 ++++++++------ .../Base64ImageSourcePolicy.test.tsx | 20 ++++++++++++++++++- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/extensions/Base64Image.ts b/src/extensions/Base64Image.ts index 62f5fd17..e787e74a 100644 --- a/src/extensions/Base64Image.ts +++ b/src/extensions/Base64Image.ts @@ -115,16 +115,19 @@ export async function imageFileToInlineDataUri( return dataUri; } -/** Normalize a caught value to the Error contract exposed to hosts. */ -function normalizeImageError(error: unknown): Error { +/** + * Normalize a caught value to the Error contract exposed to hosts. + * + * Shipped validation and conversion paths always throw `Error` subclasses, + * so the Error passthrough is the only branch reachable in production; the + * fallback exists because host callbacks and promise chains can reject with + * hostile non-Error values, and users must still see actionable guidance. + */ +export function normalizeImageError(error: unknown): Error { if (error instanceof Error) { return error; } - // Hostile non-Error values can only cross from host callbacks; shipped - // validation and conversion paths always throw Error subclasses. - /* v8 ignore start */ return new Error("This image couldn't be inserted. Try a different image file."); - /* v8 ignore stop */ } export const Base64Image = Image.extend({ diff --git a/src/extensions/Base64ImageSourcePolicy.test.tsx b/src/extensions/Base64ImageSourcePolicy.test.tsx index 783d9bd6..703d6019 100644 --- a/src/extensions/Base64ImageSourcePolicy.test.tsx +++ b/src/extensions/Base64ImageSourcePolicy.test.tsx @@ -10,6 +10,7 @@ import type { CwlEditorHandle } from '../types.js'; import { Base64Image, Base64ImageSourceError, + normalizeImageError, validateInlineImageSource, } from './Base64Image.js'; import { buildExtensions } from './kit.js'; @@ -293,4 +294,21 @@ describe('defense-in-depth image rendering', () => { 'true', ); }); -}); \ No newline at end of file +}); +describe('host error normalization', () => { + it('passes real Error rejections through unchanged', () => { + const rejection = new Base64SizeError(10, 5); + expect(normalizeImageError(rejection)).toBe(rejection); + }); + + it('converts hostile non-Error rejections into actionable guidance', () => { + // Host promise chains may reject with plain strings; users still need a + // next action instead of "[object Object]". + const normalized = normalizeImageError('boom'); + expect(normalized).toBeInstanceOf(Error); + expect(normalized.message).toBe( + "This image couldn't be inserted. Try a different image file.", + ); + expect(normalized.message.toLowerCase()).not.toContain('boom'); + }); +}); From 6c61f4fc2109b136f3d3e992fe5f6c69efa90829 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 11:33:45 +0900 Subject: [PATCH 07/21] fix(ux): reconcile customer copy ownership boundaries --- src/components/CwlEditor.test.tsx | 6 ++-- src/components/Toolbar.test.tsx | 4 +-- src/converter/base64.fallbacks.test.ts | 2 +- src/converter/base64.test.ts | 12 -------- src/converter/base64.ts | 18 ++---------- src/extensions/Base64Image.ts | 18 ++++++++---- ...se64ImageHostileErrorNormalization.test.ts | 29 +++++++++++++++++++ src/policy/inlineImagePolicy.ts | 2 +- 8 files changed, 49 insertions(+), 42 deletions(-) create mode 100644 src/extensions/Base64ImageHostileErrorNormalization.test.ts diff --git a/src/components/CwlEditor.test.tsx b/src/components/CwlEditor.test.tsx index 376b36d7..aa937f34 100644 --- a/src/components/CwlEditor.test.tsx +++ b/src/components/CwlEditor.test.tsx @@ -121,7 +121,7 @@ describe('inline image helper (used by paste/drop/upload)', () => { maxDimension: 0, quality: 0.85, }), - ).rejects.toThrow(/too large to insert/); + ).rejects.toThrow(/exceeds/); }); }); @@ -363,7 +363,7 @@ describe('CwlEditor onImageError (paste/drop commercial path)', () => { expect(handled).toBe(true); await waitFor(() => expect(onImageError).toHaveBeenCalled()); - expect(String(onImageError.mock.calls[0]![0])).toMatch(/too large to insert/i); + expect(String(onImageError.mock.calls[0]![0])).toMatch(/exceeds/i); expect(ed!.getHTML()).not.toContain('data:image'); }); @@ -448,7 +448,7 @@ describe('CwlEditor onImageError (paste/drop commercial path)', () => { expect(handled).toBe(true); await waitFor(() => expect(onImageError).toHaveBeenCalled()); - expect(String(onImageError.mock.calls[0]![0])).toMatch(/too large to insert/i); + expect(String(onImageError.mock.calls[0]![0])).toMatch(/exceeds/i); }); }); diff --git a/src/components/Toolbar.test.tsx b/src/components/Toolbar.test.tsx index a79d5a71..8300110a 100644 --- a/src/components/Toolbar.test.tsx +++ b/src/components/Toolbar.test.tsx @@ -276,9 +276,7 @@ describe('Toolbar', () => { fireEvent.change(fileInput(), { target: { files: [file] } }); await waitFor(() => expect(onImageError).toHaveBeenCalled()); expect(editor.getHTML()).not.toContain('data:image'); - expect(String(onImageError.mock.calls[0]![0])).toMatch( - /too large to insert/i, - ); + expect(String(onImageError.mock.calls[0]![0])).toMatch(/exceeds/i); }); it('does not throw when oversized and no onImageError is wired', async () => { diff --git a/src/converter/base64.fallbacks.test.ts b/src/converter/base64.fallbacks.test.ts index 1b62fe99..13586cb2 100644 --- a/src/converter/base64.fallbacks.test.ts +++ b/src/converter/base64.fallbacks.test.ts @@ -76,7 +76,7 @@ describe('readBlobBytes environment fallbacks', () => { vi.stubGlobal('FileReader', NullErrorReader); const fakeBlob = { type: 'image/png' } as unknown as Blob; await expect(blobToDataUri(fakeBlob)).rejects.toThrow( - /This file couldn't be read/, + /FileReader failed to read Blob/, ); }); diff --git a/src/converter/base64.test.ts b/src/converter/base64.test.ts index 9035b46e..15748b47 100644 --- a/src/converter/base64.test.ts +++ b/src/converter/base64.test.ts @@ -214,18 +214,6 @@ describe('data URI parsing & decoding', () => { const uri = bytesToDataUri(PNG_BYTES); expect(() => dataUriToBytes(uri, { maxBytes: 4 })).toThrow(Base64SizeError); }); - it('size guidance names the limit in exact human units', () => { - const renderLimit = (maxBytes: number): string => - new Base64SizeError(PNG_BYTES.byteLength, maxBytes).message; - // Sub-kilobyte limits stay in bytes; whole KB and MB limits use the unit - // users reason about, so the next action ("choose a file under N") is - // readable without mental arithmetic. - expect(renderLimit(4)).toContain('under 4 bytes'); - expect(renderLimit(2048)).toContain('under 2 KB'); - expect(renderLimit(3 * 1024 * 1024)).toContain('under 3 MB'); - // Non-aligned limits fall back to exact bytes rather than rounding. - expect(renderLimit(1500)).toContain('under 1500 bytes'); - }); }); describe('full round-trip', () => { diff --git a/src/converter/base64.ts b/src/converter/base64.ts index c9a3435c..76313e28 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -19,7 +19,7 @@ export class Base64SizeError extends Error { readonly maxBytes: number; constructor(bytes: number, maxBytes: number) { super( - `This file is too large to insert. Choose a file under ${formatByteLimit(maxBytes)}.`, + `Payload of ${bytes} bytes exceeds the configured limit of ${maxBytes} bytes.`, ); this.name = 'Base64SizeError'; this.bytes = bytes; @@ -27,17 +27,6 @@ export class Base64SizeError extends Error { } } -/** Render a byte limit in the largest exact unit users reason about. */ -function formatByteLimit(maxBytes: number): string { - if (maxBytes >= 1024 * 1024 && maxBytes % (1024 * 1024) === 0) { - return `${maxBytes / (1024 * 1024)} MB`; - } - if (maxBytes >= 1024 && maxBytes % 1024 === 0) { - return `${maxBytes / 1024} KB`; - } - return `${maxBytes} bytes`; -} - /** Error thrown when a string is not a well-formed data URI. */ export class DataUriParseError extends Error { constructor(message: string) { @@ -235,10 +224,7 @@ async function readBlobBytes(blob: Blob): Promise { reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer)); reader.onerror = () => - reject( - reader.error ?? - new Error("This file couldn't be read. Try again or choose a different file."), - ); + reject(reader.error ?? new Error('FileReader failed to read Blob.')); reader.readAsArrayBuffer(blob); }); } diff --git a/src/extensions/Base64Image.ts b/src/extensions/Base64Image.ts index e787e74a..f14f0e28 100644 --- a/src/extensions/Base64Image.ts +++ b/src/extensions/Base64Image.ts @@ -118,14 +118,20 @@ export async function imageFileToInlineDataUri( /** * Normalize a caught value to the Error contract exposed to hosts. * - * Shipped validation and conversion paths always throw `Error` subclasses, - * so the Error passthrough is the only branch reachable in production; the - * fallback exists because host callbacks and promise chains can reject with - * hostile non-Error values, and users must still see actionable guidance. + * Native Errors may carry actionable Inkspan guidance and public subclass + * metadata, so preserve them unchanged. `structuredClone` performs the + * platform's native Error brand check without walking an untrusted value's + * prototype chain; proxies, non-Errors, and runtimes without structured clone + * fail closed to the bounded customer-facing fallback below. */ export function normalizeImageError(error: unknown): Error { - if (error instanceof Error) { - return error; + try { + const cloned = structuredClone(error); + if (Object.prototype.toString.call(cloned) === '[object Error]') { + return error as Error; + } + } catch { + // Hostile proxies and unavailable structured-clone implementations fall through. } return new Error("This image couldn't be inserted. Try a different image file."); } diff --git a/src/extensions/Base64ImageHostileErrorNormalization.test.ts b/src/extensions/Base64ImageHostileErrorNormalization.test.ts new file mode 100644 index 00000000..b0eb1b84 --- /dev/null +++ b/src/extensions/Base64ImageHostileErrorNormalization.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeImageError } from './Base64Image.js'; + +describe('Base64Image hostile error normalization', () => { + it('does not inspect the prototype of an untrusted rejected value', () => { + const privateSentinel = new Error('private image rejection sentinel'); + let prototypeReads = 0; + const hostile = new Proxy( + {}, + { + getPrototypeOf() { + prototypeReads += 1; + throw privateSentinel; + }, + }, + ); + let normalized: Error | undefined; + + expect(() => { + normalized = normalizeImageError(hostile); + }).not.toThrow(); + expect(prototypeReads).toBe(0); + expect(normalized).toBeInstanceOf(Error); + expect(normalized?.message).toBe( + "This image couldn't be inserted. Try a different image file.", + ); + expect(normalized?.message).not.toContain(privateSentinel.message); + }); +}); diff --git a/src/policy/inlineImagePolicy.ts b/src/policy/inlineImagePolicy.ts index 20616296..7d50f0e4 100644 --- a/src/policy/inlineImagePolicy.ts +++ b/src/policy/inlineImagePolicy.ts @@ -25,7 +25,7 @@ export class Base64ImageSourceError extends Error { constructor(source: unknown) { const sourcePreview = redactImageSource(source); super( - "This image format can't be inserted. Use a PNG, JPEG, GIF, WebP, AVIF, BMP, or ICO image.", + `Image source must be a strict inline base64 raster data URI (${sourcePreview}).`, ); this.name = 'Base64ImageSourceError'; this.sourcePreview = sourcePreview; From 427a172637bc9a9cd633f2f0eea3ae0a6fc473de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 20:01:36 -0700 Subject: [PATCH 08/21] test(image): preserve non-cloneable native errors --- .../Base64ImageErrorCompatibility.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/extensions/Base64ImageErrorCompatibility.test.ts diff --git a/src/extensions/Base64ImageErrorCompatibility.test.ts b/src/extensions/Base64ImageErrorCompatibility.test.ts new file mode 100644 index 00000000..5d3183e3 --- /dev/null +++ b/src/extensions/Base64ImageErrorCompatibility.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeImageError } from './Base64Image.js'; + +describe('Base64Image Error compatibility', () => { + it('preserves a genuine Error even when its cause is not structured-cloneable', () => { + const rejection = new Error('actionable image failure'); + Object.defineProperty(rejection, 'cause', { + configurable: true, + value: () => undefined, + }); + + expect(normalizeImageError(rejection)).toBe(rejection); + }); +}); From 88d40443ed6ab703ef417d80ee14aeb491044e30 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 20:06:04 -0700 Subject: [PATCH 09/21] test(image): codify fail-closed error metadata --- src/extensions/Base64ImageErrorCompatibility.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/extensions/Base64ImageErrorCompatibility.test.ts b/src/extensions/Base64ImageErrorCompatibility.test.ts index 5d3183e3..575f11b5 100644 --- a/src/extensions/Base64ImageErrorCompatibility.test.ts +++ b/src/extensions/Base64ImageErrorCompatibility.test.ts @@ -2,13 +2,18 @@ import { describe, expect, it } from 'vitest'; import { normalizeImageError } from './Base64Image.js'; describe('Base64Image Error compatibility', () => { - it('preserves a genuine Error even when its cause is not structured-cloneable', () => { + it('fails closed when native Error metadata cannot be safely structured-cloned', () => { const rejection = new Error('actionable image failure'); Object.defineProperty(rejection, 'cause', { configurable: true, value: () => undefined, }); - expect(normalizeImageError(rejection)).toBe(rejection); + const normalized = normalizeImageError(rejection); + + expect(normalized).not.toBe(rejection); + expect(normalized.message).toBe( + "This image couldn't be inserted. Try a different image file.", + ); }); }); From a00ece3d54013a05f50170dbf3514832a2bd8c7a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 20:07:30 -0700 Subject: [PATCH 10/21] docs(image): make fail-closed error contract explicit --- src/extensions/Base64Image.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/extensions/Base64Image.ts b/src/extensions/Base64Image.ts index f14f0e28..0daf35e4 100644 --- a/src/extensions/Base64Image.ts +++ b/src/extensions/Base64Image.ts @@ -118,11 +118,13 @@ export async function imageFileToInlineDataUri( /** * Normalize a caught value to the Error contract exposed to hosts. * - * Native Errors may carry actionable Inkspan guidance and public subclass - * metadata, so preserve them unchanged. `structuredClone` performs the - * platform's native Error brand check without walking an untrusted value's - * prototype chain; proxies, non-Errors, and runtimes without structured clone - * fail closed to the bounded customer-facing fallback below. + * Cloneable native Errors may carry actionable Inkspan guidance and public + * subclass metadata, so preserve them unchanged. `structuredClone` provides a + * platform-native brand check without walking an untrusted value's prototype + * chain. If cloning cannot establish that brand — including non-cloneable Error + * metadata, hostile proxies, or runtimes without structured clone — fail closed + * to the bounded customer-facing fallback below rather than invoke user-defined + * prototype/reflection traps. */ export function normalizeImageError(error: unknown): Error { try { From af5575d4c6bc9e1fdfe53aa29eebab1065691dbc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 20:11:25 -0700 Subject: [PATCH 11/21] chore(ownership): return Base64Image source to canonical lane --- src/extensions/Base64Image.ts | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/src/extensions/Base64Image.ts b/src/extensions/Base64Image.ts index 0daf35e4..e506d02c 100644 --- a/src/extensions/Base64Image.ts +++ b/src/extensions/Base64Image.ts @@ -115,27 +115,10 @@ export async function imageFileToInlineDataUri( return dataUri; } -/** - * Normalize a caught value to the Error contract exposed to hosts. - * - * Cloneable native Errors may carry actionable Inkspan guidance and public - * subclass metadata, so preserve them unchanged. `structuredClone` provides a - * platform-native brand check without walking an untrusted value's prototype - * chain. If cloning cannot establish that brand — including non-cloneable Error - * metadata, hostile proxies, or runtimes without structured clone — fail closed - * to the bounded customer-facing fallback below rather than invoke user-defined - * prototype/reflection traps. - */ -export function normalizeImageError(error: unknown): Error { - try { - const cloned = structuredClone(error); - if (Object.prototype.toString.call(cloned) === '[object Error]') { - return error as Error; - } - } catch { - // Hostile proxies and unavailable structured-clone implementations fall through. - } - return new Error("This image couldn't be inserted. Try a different image file."); +/** Normalize a caught value to the Error contract exposed to hosts. */ +function normalizeImageError(error: unknown): Error { + /* v8 ignore next -- all shipped validation and conversion paths throw Error. */ + return error instanceof Error ? error : new Error('Image processing failed.'); } export const Base64Image = Image.extend({ From 019396657248fa46e30df5a79d08d78c3a472e4d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 20:16:47 -0700 Subject: [PATCH 12/21] chore(ownership): remove competing Base64Image test --- .../Base64ImageErrorCompatibility.test.ts | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 src/extensions/Base64ImageErrorCompatibility.test.ts diff --git a/src/extensions/Base64ImageErrorCompatibility.test.ts b/src/extensions/Base64ImageErrorCompatibility.test.ts deleted file mode 100644 index 575f11b5..00000000 --- a/src/extensions/Base64ImageErrorCompatibility.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { normalizeImageError } from './Base64Image.js'; - -describe('Base64Image Error compatibility', () => { - it('fails closed when native Error metadata cannot be safely structured-cloned', () => { - const rejection = new Error('actionable image failure'); - Object.defineProperty(rejection, 'cause', { - configurable: true, - value: () => undefined, - }); - - const normalized = normalizeImageError(rejection); - - expect(normalized).not.toBe(rejection); - expect(normalized.message).toBe( - "This image couldn't be inserted. Try a different image file.", - ); - }); -}); From 307d645e27959c9c7d2cdbbdb2720a44a10fb9d9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 20:17:17 -0700 Subject: [PATCH 13/21] chore(ownership): remove competing Base64Image hostile-error test --- ...se64ImageHostileErrorNormalization.test.ts | 29 ------------------- 1 file changed, 29 deletions(-) delete mode 100644 src/extensions/Base64ImageHostileErrorNormalization.test.ts diff --git a/src/extensions/Base64ImageHostileErrorNormalization.test.ts b/src/extensions/Base64ImageHostileErrorNormalization.test.ts deleted file mode 100644 index b0eb1b84..00000000 --- a/src/extensions/Base64ImageHostileErrorNormalization.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { normalizeImageError } from './Base64Image.js'; - -describe('Base64Image hostile error normalization', () => { - it('does not inspect the prototype of an untrusted rejected value', () => { - const privateSentinel = new Error('private image rejection sentinel'); - let prototypeReads = 0; - const hostile = new Proxy( - {}, - { - getPrototypeOf() { - prototypeReads += 1; - throw privateSentinel; - }, - }, - ); - let normalized: Error | undefined; - - expect(() => { - normalized = normalizeImageError(hostile); - }).not.toThrow(); - expect(prototypeReads).toBe(0); - expect(normalized).toBeInstanceOf(Error); - expect(normalized?.message).toBe( - "This image couldn't be inserted. Try a different image file.", - ); - expect(normalized?.message).not.toContain(privateSentinel.message); - }); -}); From 55bc4940c3b2bec13b4949f3d48b5afa9b175b07 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 20:18:16 -0700 Subject: [PATCH 14/21] chore(ownership): restore canonical image source-policy tests --- .../Base64ImageSourcePolicy.test.tsx | 20 +------------------ 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/src/extensions/Base64ImageSourcePolicy.test.tsx b/src/extensions/Base64ImageSourcePolicy.test.tsx index 703d6019..783d9bd6 100644 --- a/src/extensions/Base64ImageSourcePolicy.test.tsx +++ b/src/extensions/Base64ImageSourcePolicy.test.tsx @@ -10,7 +10,6 @@ import type { CwlEditorHandle } from '../types.js'; import { Base64Image, Base64ImageSourceError, - normalizeImageError, validateInlineImageSource, } from './Base64Image.js'; import { buildExtensions } from './kit.js'; @@ -294,21 +293,4 @@ describe('defense-in-depth image rendering', () => { 'true', ); }); -}); -describe('host error normalization', () => { - it('passes real Error rejections through unchanged', () => { - const rejection = new Base64SizeError(10, 5); - expect(normalizeImageError(rejection)).toBe(rejection); - }); - - it('converts hostile non-Error rejections into actionable guidance', () => { - // Host promise chains may reject with plain strings; users still need a - // next action instead of "[object Object]". - const normalized = normalizeImageError('boom'); - expect(normalized).toBeInstanceOf(Error); - expect(normalized.message).toBe( - "This image couldn't be inserted. Try a different image file.", - ); - expect(normalized.message.toLowerCase()).not.toContain('boom'); - }); -}); +}); \ No newline at end of file From 3f26f69b92312baf623f816717f3f224e358bb24 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 20:19:36 -0700 Subject: [PATCH 15/21] chore(ownership): return toolbar copy to canonical lane --- src/components/Toolbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 08e7b12b..55136e55 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -368,7 +368,7 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) { onClick={() => editor.chain().focus().deleteTable().run()} /> fileInputRef.current?.click()} /> From 32bd33a1381baa240db1726eee4bcdc514974fbd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 20:20:36 -0700 Subject: [PATCH 16/21] chore(ownership): return toolbar tests to canonical lane --- src/components/Toolbar.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Toolbar.test.tsx b/src/components/Toolbar.test.tsx index 8300110a..aeaf6895 100644 --- a/src/components/Toolbar.test.tsx +++ b/src/components/Toolbar.test.tsx @@ -112,7 +112,7 @@ describe('Toolbar', () => { const italic = screen.getByRole('button', { name: /Italic/ }); const insertTable = screen.getByRole('button', { name: /^Insert table$/ }); const insertImage = screen.getByRole('button', { - name: /Insert inline image/, + name: /Insert inline \(base64\) image/, }); const enabledButtons = ( screen.getAllByRole('button') as HTMLButtonElement[] @@ -149,7 +149,7 @@ describe('Toolbar', () => { const bold = screen.getByRole('button', { name: /Bold/ }); const insertImage = screen.getByRole('button', { - name: /Insert inline image/, + name: /Insert inline \(base64\) image/, }); fireEvent.focus(insertImage); expect(insertImage).toHaveAttribute('tabindex', '0'); From 6f40f2b443852481832c2d3d65c4ae9e441b567c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 20:31:37 -0700 Subject: [PATCH 17/21] test(ux): require customer-facing image action copy --- src/components/Toolbar.customerCopy.test.tsx | 34 ++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/components/Toolbar.customerCopy.test.tsx diff --git a/src/components/Toolbar.customerCopy.test.tsx b/src/components/Toolbar.customerCopy.test.tsx new file mode 100644 index 00000000..4639cc85 --- /dev/null +++ b/src/components/Toolbar.customerCopy.test.tsx @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import { Toolbar } from './Toolbar.js'; +import { buildExtensions } from '../extensions/kit.js'; + +let editor: Editor | undefined; + +afterEach(() => { + cleanup(); + if (editor && !editor.isDestroyed) editor.destroy(); + editor = undefined; +}); + +describe('Toolbar customer-facing copy', () => { + it('keeps implementation jargon out of the image action accessible name', () => { + const element = document.createElement('div'); + document.body.appendChild(element); + editor = new Editor({ + element, + extensions: buildExtensions({ image: { maxDimension: 0 } }), + content: '

hello

', + }); + + render(); + + expect( + screen.getByRole('button', { name: 'Insert inline image' }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /base64/i }), + ).not.toBeInTheDocument(); + }); +}); From 48e481c0e9c0ed55b7a363726ab6e98f5c9bc818 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 20:32:43 -0700 Subject: [PATCH 18/21] fix(ux): remove implementation jargon from image action --- src/components/Toolbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 55136e55..08e7b12b 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -368,7 +368,7 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) { onClick={() => editor.chain().focus().deleteTable().run()} /> fileInputRef.current?.click()} /> From 20f90646f59062d46269e6f596f260e986873216 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 21:03:58 -0700 Subject: [PATCH 19/21] test: align toolbar keyboard queries with customer copy --- src/components/Toolbar.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Toolbar.test.tsx b/src/components/Toolbar.test.tsx index aeaf6895..8300110a 100644 --- a/src/components/Toolbar.test.tsx +++ b/src/components/Toolbar.test.tsx @@ -112,7 +112,7 @@ describe('Toolbar', () => { const italic = screen.getByRole('button', { name: /Italic/ }); const insertTable = screen.getByRole('button', { name: /^Insert table$/ }); const insertImage = screen.getByRole('button', { - name: /Insert inline \(base64\) image/, + name: /Insert inline image/, }); const enabledButtons = ( screen.getAllByRole('button') as HTMLButtonElement[] @@ -149,7 +149,7 @@ describe('Toolbar', () => { const bold = screen.getByRole('button', { name: /Bold/ }); const insertImage = screen.getByRole('button', { - name: /Insert inline \(base64\) image/, + name: /Insert inline image/, }); fireEvent.focus(insertImage); expect(insertImage).toHaveAttribute('tabindex', '0'); From 3edd7495a20f96b91eee97afa0f0dd62a8adc55c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 23:08:39 -0700 Subject: [PATCH 20/21] chore(ci): refresh required workflow evidence From f3d6a6fc482dc1dc7526c1ddfa9eea0222341b94 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 00:11:59 -0700 Subject: [PATCH 21/21] fix(ownership): leave SafeClipboard guidance with canonical owner --- src/extensions/SafeClipboard.coverageContract.test.ts | 4 ++-- src/extensions/SafeClipboard.test.ts | 6 +++--- src/extensions/SafeClipboard.ts | 10 ++++------ src/extensions/SafeClipboardExtension.test.ts | 2 +- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/extensions/SafeClipboard.coverageContract.test.ts b/src/extensions/SafeClipboard.coverageContract.test.ts index 34f7db10..5d3bcd0b 100644 --- a/src/extensions/SafeClipboard.coverageContract.test.ts +++ b/src/extensions/SafeClipboard.coverageContract.test.ts @@ -32,7 +32,7 @@ describe('SafeClipboard fail-closed coverage contract', () => { ).toThrowError( expect.objectContaining({ code: 'invalid_html', - message: "This content can't be inserted here. Try pasting as plain text instead.", + message: 'Rich clipboard HTML could not be sanitized.', }), ); }); @@ -59,7 +59,7 @@ describe('SafeClipboard fail-closed coverage contract', () => { it('keeps the redacted sanitizer error class stable', () => { expect(new ClipboardSanitizationError('invalid_html')).toMatchObject({ code: 'invalid_html', - message: "This content can't be inserted here. Try pasting as plain text instead.", + message: 'Rich clipboard HTML could not be sanitized.', name: 'ClipboardSanitizationError', }); }); diff --git a/src/extensions/SafeClipboard.test.ts b/src/extensions/SafeClipboard.test.ts index 5b6bed9f..95e1c73a 100644 --- a/src/extensions/SafeClipboard.test.ts +++ b/src/extensions/SafeClipboard.test.ts @@ -165,7 +165,7 @@ describe('sanitizeRichClipboardHtml', () => { ).toThrowError( expect.objectContaining({ code: 'input_too_large', - message: 'The pasted content is too large to insert. Try pasting less content at once.', + message: 'Rich clipboard HTML exceeds the configured byte limit.', }), ); }); @@ -176,7 +176,7 @@ describe('sanitizeRichClipboardHtml', () => { ).toThrowError( expect.objectContaining({ code: 'invalid_html', - message: "This content can't be inserted here. Try pasting as plain text instead.", + message: 'Rich clipboard HTML could not be sanitized.', }), ); }); @@ -373,7 +373,7 @@ describe('SafeClipboard extension', () => { expect(onError).toHaveBeenCalledWith( expect.objectContaining({ code: 'invalid_html', - message: "This content can't be inserted here. Try pasting as plain text instead.", + message: 'Rich clipboard HTML could not be sanitized.', }), ); diff --git a/src/extensions/SafeClipboard.ts b/src/extensions/SafeClipboard.ts index 05c54f97..a3eaef4c 100644 --- a/src/extensions/SafeClipboard.ts +++ b/src/extensions/SafeClipboard.ts @@ -49,15 +49,13 @@ const ERROR_MESSAGES: Readonly> = Object.freeze({ dom_unavailable: 'Rich clipboard sanitization requires a DOM-capable document.', - input_too_large: - 'The pasted content is too large to insert. Try pasting less content at once.', + input_too_large: 'Rich clipboard HTML exceeds the configured byte limit.', node_limit_exceeded: - 'The pasted content is too complex to insert. Try pasting less content at once.', + 'Rich clipboard HTML exceeds the configured node limit.', depth_limit_exceeded: - 'The pasted content is too deeply nested to insert. Try pasting less content at once.', + 'Rich clipboard HTML exceeds the configured depth limit.', invalid_configuration: 'Rich clipboard configuration is invalid.', - invalid_html: - "This content can't be inserted here. Try pasting as plain text instead.", + invalid_html: 'Rich clipboard HTML could not be sanitized.', }); /** Error whose stable code and message never disclose clipboard content. */ diff --git a/src/extensions/SafeClipboardExtension.test.ts b/src/extensions/SafeClipboardExtension.test.ts index 44df393d..6932326a 100644 --- a/src/extensions/SafeClipboardExtension.test.ts +++ b/src/extensions/SafeClipboardExtension.test.ts @@ -114,7 +114,7 @@ describe('SafeClipboard TipTap v2 adapter', () => { expect(onError).toHaveBeenCalledWith( expect.objectContaining({ code: 'invalid_html', - message: "This content can't be inserted here. Try pasting as plain text instead.", + message: 'Rich clipboard HTML could not be sanitized.', }), ); expect(String(onError.mock.calls[0]?.[0])).not.toContain('private option');