From 3f3afc373c4a23951e40963bb5d7d4bdfea53fcd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 06:07:23 +0900 Subject: [PATCH 01/20] test(toolbar): fail closed after image upload lifecycle changes --- src/components/ToolbarImageLifecycle.test.tsx | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/components/ToolbarImageLifecycle.test.tsx diff --git a/src/components/ToolbarImageLifecycle.test.tsx b/src/components/ToolbarImageLifecycle.test.tsx new file mode 100644 index 00000000..ba26926e --- /dev/null +++ b/src/components/ToolbarImageLifecycle.test.tsx @@ -0,0 +1,87 @@ +import { act, cleanup, fireEvent, render } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { buildExtensions } from '../extensions/kit.js'; +import { Toolbar } from './Toolbar.js'; + +const PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, +]); + +const openEditors: Array<{ editor: Editor; element: HTMLDivElement }> = []; + +function makeEditor(): Editor { + const element = document.createElement('div'); + document.body.appendChild(element); + const editor = new Editor({ + element, + extensions: buildExtensions({ image: { maxDimension: 0 } }), + content: '

before

', + }); + openEditors.push({ editor, element }); + return editor; +} + +function delayedPngFile(delayMs = 25): File { + const file = new File([PNG_BYTES], 'slow.png', { type: 'image/png' }); + Object.defineProperty(file, 'arrayBuffer', { + configurable: true, + value: () => + new Promise((resolve) => { + setTimeout(() => resolve(PNG_BYTES.slice().buffer), delayMs); + }), + }); + return file; +} + +function fileInput(): HTMLInputElement { + return document.querySelector('input[type="file"]') as HTMLInputElement; +} + +async function settleConversion(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 45)); + }); +} + +afterEach(() => { + cleanup(); + for (const { editor, element } of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + element.remove(); + } + vi.restoreAllMocks(); +}); + +describe('Toolbar asynchronous image-upload lifecycle boundary', () => { + it('does not prompt or mutate after the editor becomes read-only', async () => { + const editor = makeEditor(); + const before = editor.getHTML(); + const prompt = vi.spyOn(window, 'prompt').mockReturnValue('stale image'); + render(); + + fireEvent.change(fileInput(), { target: { files: [delayedPngFile()] } }); + editor.setEditable(false); + await settleConversion(); + + expect(prompt).not.toHaveBeenCalled(); + expect(editor.getHTML()).toBe(before); + expect(editor.getHTML()).not.toContain('data:image/png;base64'); + }); + + it('does not prompt after the editor is destroyed during conversion', async () => { + const editor = makeEditor(); + const prompt = vi.spyOn(window, 'prompt').mockReturnValue('stale image'); + render(); + + fireEvent.change(fileInput(), { target: { files: [delayedPngFile()] } }); + editor.destroy(); + await settleConversion(); + + expect(prompt).not.toHaveBeenCalled(); + expect(editor.isDestroyed).toBe(true); + }); +}); From 6da5eab43f4a05918df5ce520b5f3ce90d9144cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 06:11:57 +0900 Subject: [PATCH 02/20] fix(toolbar): stop stale image upload continuations --- src/components/Toolbar.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 55136e55..32f144e0 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -215,6 +215,8 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) { return; } + if (editor.isDestroyed || !editor.isEditable) return; + const alternativeText = window.prompt( 'Image alternative text. Leave empty only if this image is decorative.', '', From 6e767611315e7427f9c7b1c5018cc81aab200413 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 12:47:17 +0900 Subject: [PATCH 03/20] test(reliability): prove toolbar leaks hostile image failures --- src/components/ToolbarImageLifecycle.test.tsx | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/components/ToolbarImageLifecycle.test.tsx b/src/components/ToolbarImageLifecycle.test.tsx index ba26926e..66672af9 100644 --- a/src/components/ToolbarImageLifecycle.test.tsx +++ b/src/components/ToolbarImageLifecycle.test.tsx @@ -84,4 +84,53 @@ describe('Toolbar asynchronous image-upload lifecycle boundary', () => { expect(prompt).not.toHaveBeenCalled(); expect(editor.isDestroyed).toBe(true); }); + + it('does not expose hostile conversion throw values to the host error callback', async () => { + const editor = makeEditor(); + const before = editor.getHTML(); + const privateSentinel = new Error('private toolbar conversion sentinel'); + const getPrototypeOf = vi.fn(() => { + throw privateSentinel; + }); + const hostileThrownValue = new Proxy({}, { getPrototypeOf }); + const hostileValues = new WeakSet([hostileThrownValue]); + const file = new File([PNG_BYTES], 'hostile.png', { type: 'image/png' }); + Object.defineProperty(file, 'arrayBuffer', { + configurable: true, + value: vi.fn().mockRejectedValue(hostileThrownValue), + }); + + let leakedHostileValue = false; + let observedError: unknown; + const onImageError = vi.fn((error: unknown) => { + observedError = error; + if ( + typeof error === 'object' && + error !== null && + hostileValues.has(error) + ) { + leakedHostileValue = true; + } + }); + const prompt = vi.spyOn(window, 'prompt').mockReturnValue('should not run'); + render( + , + ); + + fireEvent.change(fileInput(), { target: { files: [file] } }); + await settleConversion(); + + expect(onImageError).toHaveBeenCalledOnce(); + expect(leakedHostileValue).toBe(false); + expect(getPrototypeOf).not.toHaveBeenCalled(); + expect(observedError).toBeInstanceOf(Error); + expect((observedError as Error).message).toBe('Image processing failed.'); + expect(prompt).not.toHaveBeenCalled(); + expect(editor.getHTML()).toBe(before); + expect(editor.getHTML()).not.toContain('data:image'); + }); }); From 84cb2b1e5fb94b587bf29dd9024c5288f54c7636 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 12:53:16 +0900 Subject: [PATCH 04/20] fix(reliability): redact hostile toolbar image failures --- src/components/Toolbar.tsx | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 32f144e0..f852d307 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -8,6 +8,7 @@ import { type FocusEvent, type KeyboardEvent, } from 'react'; +import { Base64SizeError } from '../converter/base64.js'; import { imageFileToInlineDataUri } from '../extensions/Base64Image.js'; import type { ImageConfig } from '../types.js'; @@ -30,6 +31,15 @@ interface ButtonProps { const TOOLBAR_ITEM_SELECTOR = 'button[data-cwl-toolbar-item="true"]'; +/** Read a genuine Blob's byte length without invoking caller-owned accessors. */ +function intrinsicBlobSize(blob: Blob): number { + const sizeGetter = Object.getOwnPropertyDescriptor( + globalThis.Blob.prototype, + 'size', + )!.get!; + return Reflect.apply(sizeGetter, blob, []) as number; +} + /** Return every toolbar button in visual and DOM navigation order. */ function getToolbarButtons(toolbar: HTMLDivElement): HTMLButtonElement[] { return Array.from( @@ -203,15 +213,22 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) { event.target.value = ''; if (!file) return; + const maxSizeBytes = image?.maxSizeBytes ?? 10 * 1024 * 1024; + const sourceBytes = intrinsicBlobSize(file); + if (maxSizeBytes > 0 && sourceBytes > maxSizeBytes) { + onImageError?.(new Base64SizeError(sourceBytes, maxSizeBytes)); + return; + } + let src: string; try { src = await imageFileToInlineDataUri(file, { - maxSizeBytes: image?.maxSizeBytes ?? 10 * 1024 * 1024, + maxSizeBytes, maxDimension: image?.maxDimension ?? 1600, quality: image?.quality ?? 0.85, }); - } catch (err) { - onImageError?.(err); + } catch { + onImageError?.(new Error('Image processing failed.')); return; } From 98ee151ee52d5792765f71f35408dee8550e3505 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 16:16:30 +0900 Subject: [PATCH 05/20] test(toolbar): reject unsafe links before editor commands --- src/components/ToolbarLinkPolicy.test.tsx | 50 +++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/components/ToolbarLinkPolicy.test.tsx diff --git a/src/components/ToolbarLinkPolicy.test.tsx b/src/components/ToolbarLinkPolicy.test.tsx new file mode 100644 index 00000000..fee56eee --- /dev/null +++ b/src/components/ToolbarLinkPolicy.test.tsx @@ -0,0 +1,50 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Toolbar } from './Toolbar.js'; + +const openEditors: Editor[] = []; + +function makeEditor(): Editor { + const element = document.createElement('div'); + document.body.appendChild(element); + const editor = new Editor({ + element, + extensions: [StarterKit], + content: '

link target

', + }); + openEditors.push(editor); + return editor; +} + +afterEach(() => { + cleanup(); + for (const editor of openEditors.splice(0)) { + if (!editor.isDestroyed) editor.destroy(); + } + vi.restoreAllMocks(); +}); + +describe('Toolbar link policy boundary', () => { + it('rejects an executable URL before issuing an editor command', () => { + const editor = makeEditor(); + const commandChain = { + focus: vi.fn(() => commandChain), + extendMarkRange: vi.fn(() => commandChain), + setLink: vi.fn(() => commandChain), + unsetLink: vi.fn(() => commandChain), + run: vi.fn(() => true), + }; + vi.spyOn(editor, 'chain').mockReturnValue( + commandChain as unknown as ReturnType, + ); + vi.spyOn(window, 'prompt').mockReturnValue('javascript:alert(1)'); + + render(); + fireEvent.click(screen.getByRole('button', { name: /Insert\/edit link/ })); + + expect(commandChain.setLink).not.toHaveBeenCalled(); + expect(commandChain.run).not.toHaveBeenCalled(); + }); +}); From 0693ba351c2b325e59e45a41e36c861800b1f732 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 16:22:41 +0900 Subject: [PATCH 06/20] fix(toolbar): enforce safe-link policy before commands --- src/components/Toolbar.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index f852d307..f65c8e66 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -10,6 +10,7 @@ import { } from 'react'; import { Base64SizeError } from '../converter/base64.js'; import { imageFileToInlineDataUri } from '../extensions/Base64Image.js'; +import { isSafeLinkHref } from '../extensions/SafeLink.js'; import type { ImageConfig } from '../types.js'; interface ToolbarProps { @@ -183,6 +184,7 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) { editor.chain().focus().extendMarkRange('link').unsetLink().run(); return; } + if (!isSafeLinkHref(url)) return; editor .chain() .focus() From 041e726b010e96cc8b9b4946de52012d719c3141 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:21:57 +0900 Subject: [PATCH 07/20] test(reliability): contain toolbar image observer failures --- src/components/ToolbarImageLifecycle.test.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/components/ToolbarImageLifecycle.test.tsx b/src/components/ToolbarImageLifecycle.test.tsx index 66672af9..8f43f24c 100644 --- a/src/components/ToolbarImageLifecycle.test.tsx +++ b/src/components/ToolbarImageLifecycle.test.tsx @@ -133,4 +133,34 @@ describe('Toolbar asynchronous image-upload lifecycle boundary', () => { expect(editor.getHTML()).toBe(before); expect(editor.getHTML()).not.toContain('data:image'); }); + + it('contains host image-error observer failures after conversion rejection', async () => { + const editor = makeEditor(); + const before = editor.getHTML(); + const privateSentinel = new Error('private toolbar observer sentinel'); + const failedFile = new File([PNG_BYTES], 'failed.png', { type: 'image/png' }); + Object.defineProperty(failedFile, 'arrayBuffer', { + configurable: true, + value: vi.fn().mockRejectedValue(new Error('private conversion failure')), + }); + const onImageError = vi.fn(() => { + throw privateSentinel; + }); + const prompt = vi.spyOn(window, 'prompt').mockReturnValue('should not run'); + + render( + , + ); + + fireEvent.change(fileInput(), { target: { files: [failedFile] } }); + await settleConversion(); + + expect(onImageError).toHaveBeenCalledOnce(); + expect(prompt).not.toHaveBeenCalled(); + expect(editor.getHTML()).toBe(before); + }); }); From d448a9dd8296fca5f905a7371d22abf75f647f75 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:25:28 +0900 Subject: [PATCH 08/20] fix(reliability): contain toolbar image observer failures --- src/components/Toolbar.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index f65c8e66..3f2d381c 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -41,6 +41,18 @@ function intrinsicBlobSize(blob: Blob): number { return Reflect.apply(sizeGetter, blob, []) as number; } +/** Report an image failure without allowing host observer code to alter toolbar control flow. */ +function reportImageError( + onImageError: ((error: unknown) => void) | undefined, + error: unknown, +): void { + try { + onImageError?.(error); + } catch { + // Host presentation or telemetry observers are best-effort only. + } +} + /** Return every toolbar button in visual and DOM navigation order. */ function getToolbarButtons(toolbar: HTMLDivElement): HTMLButtonElement[] { return Array.from( @@ -218,7 +230,10 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) { const maxSizeBytes = image?.maxSizeBytes ?? 10 * 1024 * 1024; const sourceBytes = intrinsicBlobSize(file); if (maxSizeBytes > 0 && sourceBytes > maxSizeBytes) { - onImageError?.(new Base64SizeError(sourceBytes, maxSizeBytes)); + reportImageError( + onImageError, + new Base64SizeError(sourceBytes, maxSizeBytes), + ); return; } @@ -230,7 +245,7 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) { quality: image?.quality ?? 0.85, }); } catch { - onImageError?.(new Error('Image processing failed.')); + reportImageError(onImageError, new Error('Image processing failed.')); return; } From 9de2063ff409f5d15540474e0d114dba1eedf226 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 21:19:50 -0700 Subject: [PATCH 09/20] test(toolbar): reject implementation jargon in image action --- src/components/ToolbarCustomerCopy.test.tsx | 34 +++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/components/ToolbarCustomerCopy.test.tsx diff --git a/src/components/ToolbarCustomerCopy.test.tsx b/src/components/ToolbarCustomerCopy.test.tsx new file mode 100644 index 00000000..4e56612b --- /dev/null +++ b/src/components/ToolbarCustomerCopy.test.tsx @@ -0,0 +1,34 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { Editor } from '@tiptap/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { buildExtensions } from '../extensions/kit.js'; +import { Toolbar } from './Toolbar.js'; + +let editor: Editor | undefined; + +afterEach(() => { + cleanup(); + if (editor && !editor.isDestroyed) editor.destroy(); + editor = undefined; +}); + +describe('Toolbar customer-facing image action copy', () => { + it('names the image action without exposing base64 implementation jargon', () => { + const element = document.createElement('div'); + editor = new Editor({ + element, + extensions: buildExtensions({ image: { maxDimension: 0 } }), + content: '

before

', + }); + + render(); + + expect( + screen.getByRole('button', { name: 'Insert inline image' }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /base64/i }), + ).not.toBeInTheDocument(); + }); +}); From eb08e9accee9594778099431723aa1a9f4582b33 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 20:15:49 -0700 Subject: [PATCH 10/20] fix(toolbar): hide base64 implementation jargon --- 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 3f2d381c..60da7703 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -404,7 +404,7 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) { onClick={() => editor.chain().focus().deleteTable().run()} /> fileInputRef.current?.click()} /> From 1f5b6f5ed4a0b35e35f9f43a8558dfae47044f16 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 15:30:04 +0900 Subject: [PATCH 11/20] test(toolbar): align image action assertion --- 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 858639453f469a6f752f2a75c6c281ada4de90f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:26:47 +0900 Subject: [PATCH 12/20] test(ci): cover event-specific Python matrix Signed-off-by: Seongho Bae --- office/tests/test_python_support_contract.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/office/tests/test_python_support_contract.py b/office/tests/test_python_support_contract.py index 7104fd66..209f4845 100644 --- a/office/tests/test_python_support_contract.py +++ b/office/tests/test_python_support_contract.py @@ -50,10 +50,14 @@ def test_python_support_range_matches_classifiers_and_ci_matrix() -> None: office_job = _workflow_job_block(workflow, "office") assert "runs-on: ubuntu-24.04" in office_job assert "runs-on: ubuntu-latest" not in office_job - matrix_match = re.search(r'python-version:\s*\[([^\]]+)\]', office_job) + matrix_match = re.search(r"python-version:\s*(.+)", office_job) assert matrix_match is not None - matrix_versions = tuple(re.findall(r'"(3\.\d+)"', matrix_match.group(1))) - assert matrix_versions == SUPPORTED_PYTHON_VERSIONS + pull_request_versions, push_versions = ( + tuple(re.findall(r'"(3\.\d+)"', versions)) + for versions in re.findall(r"fromJSON\('(\[[^']+\])'\)", matrix_match.group(1)) + ) + assert pull_request_versions == (SUPPORTED_PYTHON_VERSIONS[-1],) + assert push_versions == SUPPORTED_PYTHON_VERSIONS def test_python_support_documentation_matches_the_fixed_ci_environment() -> None: From adefee6b4abb7bd7e594267470bcbcd1ae812ea0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 02:32:54 +0900 Subject: [PATCH 13/20] test(ci): bind Python matrix to event Signed-off-by: Seongho Bae --- office/tests/test_python_support_contract.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/office/tests/test_python_support_contract.py b/office/tests/test_python_support_contract.py index 209f4845..a52ddec3 100644 --- a/office/tests/test_python_support_contract.py +++ b/office/tests/test_python_support_contract.py @@ -50,11 +50,16 @@ def test_python_support_range_matches_classifiers_and_ci_matrix() -> None: office_job = _workflow_job_block(workflow, "office") assert "runs-on: ubuntu-24.04" in office_job assert "runs-on: ubuntu-latest" not in office_job - matrix_match = re.search(r"python-version:\s*(.+)", office_job) + matrix_match = re.search( + r"python-version:\s*\$\{\{\s*github\.event_name\s*==\s*'pull_request'" + r"\s*&&\s*fromJSON\('(\[[^']+\])'\)\s*\|\|\s*" + r"fromJSON\('(\[[^']+\])'\)\s*\}\}", + office_job, + ) assert matrix_match is not None pull_request_versions, push_versions = ( tuple(re.findall(r'"(3\.\d+)"', versions)) - for versions in re.findall(r"fromJSON\('(\[[^']+\])'\)", matrix_match.group(1)) + for versions in matrix_match.groups() ) assert pull_request_versions == (SUPPORTED_PYTHON_VERSIONS[-1],) assert push_versions == SUPPORTED_PYTHON_VERSIONS From a8fa0555061cd31e002f64d8f72c97f69493e7b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 06:03:14 +0900 Subject: [PATCH 14/20] revert(ci): restore Office contract owner Remove the duplicated event-matrix test repair from the Toolbar lane. PR #405 remains the single writer for that shared CI contract. Signed-off-by: Seongho Bae Commit-Message-Assisted-by: Claude (via Claude Code) --- office/tests/test_python_support_contract.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/office/tests/test_python_support_contract.py b/office/tests/test_python_support_contract.py index a52ddec3..7104fd66 100644 --- a/office/tests/test_python_support_contract.py +++ b/office/tests/test_python_support_contract.py @@ -50,19 +50,10 @@ def test_python_support_range_matches_classifiers_and_ci_matrix() -> None: office_job = _workflow_job_block(workflow, "office") assert "runs-on: ubuntu-24.04" in office_job assert "runs-on: ubuntu-latest" not in office_job - matrix_match = re.search( - r"python-version:\s*\$\{\{\s*github\.event_name\s*==\s*'pull_request'" - r"\s*&&\s*fromJSON\('(\[[^']+\])'\)\s*\|\|\s*" - r"fromJSON\('(\[[^']+\])'\)\s*\}\}", - office_job, - ) + matrix_match = re.search(r'python-version:\s*\[([^\]]+)\]', office_job) assert matrix_match is not None - pull_request_versions, push_versions = ( - tuple(re.findall(r'"(3\.\d+)"', versions)) - for versions in matrix_match.groups() - ) - assert pull_request_versions == (SUPPORTED_PYTHON_VERSIONS[-1],) - assert push_versions == SUPPORTED_PYTHON_VERSIONS + matrix_versions = tuple(re.findall(r'"(3\.\d+)"', matrix_match.group(1))) + assert matrix_versions == SUPPORTED_PYTHON_VERSIONS def test_python_support_documentation_matches_the_fixed_ci_environment() -> None: From 697868a393baa52b227c4aa17bf4869c5199decc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 22:31:19 +0900 Subject: [PATCH 15/20] test(accessibility): require readable image action text Signed-off-by: Seongho Bae --- src/components/ToolbarCustomerCopy.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/ToolbarCustomerCopy.test.tsx b/src/components/ToolbarCustomerCopy.test.tsx index 4e56612b..2147a671 100644 --- a/src/components/ToolbarCustomerCopy.test.tsx +++ b/src/components/ToolbarCustomerCopy.test.tsx @@ -27,6 +27,9 @@ describe('Toolbar customer-facing image action copy', () => { expect( screen.getByRole('button', { name: 'Insert inline image' }), ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Insert inline image' }), + ).toHaveTextContent(/^Image$/); expect( screen.queryByRole('button', { name: /base64/i }), ).not.toBeInTheDocument(); From 3217f4a47809d19b5a8008f17756532b5b199ba5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 22:32:03 +0900 Subject: [PATCH 16/20] fix(accessibility): make image action readable without emoji fonts Signed-off-by: Seongho Bae --- docs/doctoring/toolbar-image-label.md | 39 +++++++++++++++++++++++++++ src/components/Toolbar.tsx | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 docs/doctoring/toolbar-image-label.md diff --git a/docs/doctoring/toolbar-image-label.md b/docs/doctoring/toolbar-image-label.md new file mode 100644 index 00000000..f124f53e --- /dev/null +++ b/docs/doctoring/toolbar-image-label.md @@ -0,0 +1,39 @@ +# Readable image action + +Status: Active PR / Proposed (#158) + +## Observation and decision + +Visual inspection of the actual editor at `8f9ccf46082086eeac09089b3bbb5e8a6b3e1c9f` +showed Firefox rendering the image action as a missing-glyph box. Chromium and +WebKit rendered the emoji. A passing shortcut assertion did not detect this. + +Use the visible word `Image` in the existing button. Its accessible name remains +`Insert inline image`, containing the visible label. Preserve native file +selection, keyboard navigation, existing colors, focus and upload guards. +No new icon library, font download, SVG abstraction or action is needed. + +## Design contract + +- Job and action: an author selects a local image to insert into the document. +- Hierarchy: keep the image action in its existing group beside table and alt-text controls. +- Visual language: existing compact button, type, spacing and semantic colors; no new tokens. +- States: readable normal, focused and forced-color text; readonly hides the toolbar. +- Responsive behavior: inherit #151 wrapping at narrow widths; the wider label must not clip. +- Evidence: actual three-engine screenshots, existing Toolbar source, and W3C label-in-name guidance. +- Reference limit: UIZZE's public landing page yielded no relevant editor-screen references; no external layout was copied. +- Acceptance: exact-head unit checks plus actual desktop/narrow browser screenshots and unchanged file-picker behavior. Pending results are not completion. + +The alternative emoji variation selector still depends on font glyph availability. +A custom icon introduces drawing and forced-color maintenance for a word the +existing component already supports. Neither addresses this observation more +directly than visible text. This does not claim complete localization, WCAG +certification, or that other toolbar symbols work on every operating system. + +The RED check at `697868a3` failed because the button still contained the emoji. +Subsequent results belong to their exact source head and are recorded in PR #158. + +## Reference + +World Wide Web Consortium. (n.d.). *Understanding Success Criterion 2.5.3: Label in name.* +https://www.w3.org/WAI/WCAG22/Understanding/label-in-name.html diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 335495aa..05be9523 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -405,7 +405,7 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) { /> fileInputRef.current?.click()} /> Date: Sun, 6 Sep 2026 22:32:28 +0900 Subject: [PATCH 17/20] test(accessibility): exercise readable image picker across engines Signed-off-by: Seongho Bae --- tests/browser/specs/forced-colors.browser.spec.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/browser/specs/forced-colors.browser.spec.ts b/tests/browser/specs/forced-colors.browser.spec.ts index 680f3bb1..a202bf75 100644 --- a/tests/browser/specs/forced-colors.browser.spec.ts +++ b/tests/browser/specs/forced-colors.browser.spec.ts @@ -214,7 +214,9 @@ for (const forcedColors of ['none', 'active'] as const) { await page.goto('/tests/browser/input-harness.html?toolbar=1'); await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true'); const toolbar = page.getByRole('toolbar', { name: 'Formatting' }); - await expect(toolbar.getByRole('button', { name: 'Insert inline (base64) image' })).toBeVisible(); + const imageButton = toolbar.getByRole('button', { name: 'Insert inline image', exact: true }); + await expect(imageButton).toBeVisible(); + await expect(imageButton).toHaveText('Image'); const clippedControls = await toolbar.evaluate((element) => { const toolbarBounds = element.getBoundingClientRect(); return Array.from(element.querySelectorAll('button')).flatMap((button) => { @@ -294,5 +296,15 @@ for (const forcedColors of ['none', 'active'] as const) { expect(hoverPaint.forcedColorAdjust).toBe(hoverPaint.supportsColorAdjustment ? 'none' : ''); expect(hoverPaint.color).not.toBe(hoverPaint.background); } + const chooserPromise = page.waitForEvent('filechooser'); + await imageButton.click(); + const chooser = await chooserPromise; + await chooser.setFiles([]); + await imageButton.focus(); + await expect(imageButton).toBeFocused(); + await page.screenshot({ + path: testInfo.outputPath(`real-toolbar-image-320-${forcedColors}-focus.png`), + fullPage: true, + }); }); } From 00d628ae8fa14ba98afa6047f0589c33ef747fad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 08:57:08 +0900 Subject: [PATCH 18/20] test(toolbar): cover editor changes during image prompting Signed-off-by: Seongho Bae --- src/components/ToolbarImageLifecycle.test.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/components/ToolbarImageLifecycle.test.tsx b/src/components/ToolbarImageLifecycle.test.tsx index 8f43f24c..13ac2ab5 100644 --- a/src/components/ToolbarImageLifecycle.test.tsx +++ b/src/components/ToolbarImageLifecycle.test.tsx @@ -57,6 +57,26 @@ afterEach(() => { }); describe('Toolbar asynchronous image-upload lifecycle boundary', () => { + it.each(['read-only', 'destroyed'] as const)( + 'does not insert when the editor becomes %s during alternative-text prompting', + async (nextState) => { + const editor = makeEditor(); + const before = editor.getHTML(); + const prompt = vi.spyOn(window, 'prompt').mockImplementation(() => { + if (nextState === 'destroyed') editor.destroy(); + else editor.setEditable(false); + return 'stale image'; + }); + render(); + + fireEvent.change(fileInput(), { target: { files: [delayedPngFile()] } }); + await settleConversion(); + + expect(prompt).toHaveBeenCalledOnce(); + expect(editor.getHTML()).toBe(before); + }, + ); + it('does not prompt or mutate after the editor becomes read-only', async () => { const editor = makeEditor(); const before = editor.getHTML(); From 01826ffab1214bc3764db9dfb2036985e15a3541 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 08:58:00 +0900 Subject: [PATCH 19/20] test(toolbar): observe command attempts after editor destruction Signed-off-by: Seongho Bae --- src/components/ToolbarImageLifecycle.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/ToolbarImageLifecycle.test.tsx b/src/components/ToolbarImageLifecycle.test.tsx index 13ac2ab5..8c0cdb43 100644 --- a/src/components/ToolbarImageLifecycle.test.tsx +++ b/src/components/ToolbarImageLifecycle.test.tsx @@ -65,15 +65,19 @@ describe('Toolbar asynchronous image-upload lifecycle boundary', () => { const prompt = vi.spyOn(window, 'prompt').mockImplementation(() => { if (nextState === 'destroyed') editor.destroy(); else editor.setEditable(false); + chain.mockClear(); return 'stale image'; }); render(); + const chain = vi.spyOn(editor, 'chain'); fireEvent.change(fileInput(), { target: { files: [delayedPngFile()] } }); await settleConversion(); expect(prompt).toHaveBeenCalledOnce(); - expect(editor.getHTML()).toBe(before); + expect(chain).not.toHaveBeenCalled(); + if (nextState === 'destroyed') expect(editor.isDestroyed).toBe(true); + else expect(editor.getHTML()).toBe(before); }, ); From f83d1d9b2b346e4e8c1ff3897e6f54e489f22cf2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 7 Sep 2026 08:59:07 +0900 Subject: [PATCH 20/20] fix(toolbar): recheck editor state after image prompting Signed-off-by: Seongho Bae --- src/components/Toolbar.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 05be9523..12e7289d 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -255,6 +255,7 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) { 'Image alternative text. Leave empty only if this image is decorative.', '', ); + if (editor.isDestroyed || !editor.isEditable) return; if (alternativeText === null) return; editor.chain().focus().setImage({ src, alt: alternativeText }).run();