Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3f3afc3
test(toolbar): fail closed after image upload lifecycle changes
seonghobae Aug 10, 2026
6da5eab
fix(toolbar): stop stale image upload continuations
seonghobae Aug 10, 2026
6e76761
test(reliability): prove toolbar leaks hostile image failures
seonghobae Aug 16, 2026
84cb2b1
fix(reliability): redact hostile toolbar image failures
seonghobae Aug 16, 2026
98ee151
test(toolbar): reject unsafe links before editor commands
seonghobae Aug 16, 2026
0693ba3
fix(toolbar): enforce safe-link policy before commands
seonghobae Aug 16, 2026
041e726
test(reliability): contain toolbar image observer failures
seonghobae Aug 16, 2026
d448a9d
fix(reliability): contain toolbar image observer failures
seonghobae Aug 16, 2026
26af04e
chore: synchronize toolbar reliability lane with protected main
seonghobae Aug 17, 2026
9de2063
test(toolbar): reject implementation jargon in image action
seonghobae Aug 26, 2026
eb08e9a
fix(toolbar): hide base64 implementation jargon
seonghobae Aug 28, 2026
1f5b6f5
test(toolbar): align image action assertion
seonghobae Aug 28, 2026
4f4f7db
chore: synchronize toolbar owner with protected main
seonghobae Aug 28, 2026
2a18168
Merge remote-tracking branch 'origin/main' into fix/toolbar-image-lif…
seonghobae Sep 4, 2026
8586394
test(ci): cover event-specific Python matrix
seonghobae Sep 4, 2026
adefee6
test(ci): bind Python matrix to event
seonghobae Sep 4, 2026
a8fa055
revert(ci): restore Office contract owner
seonghobae Sep 4, 2026
e70071a
merge: inherit canonical accessible toolbar presentation
seonghobae Sep 6, 2026
697868a
test(accessibility): require readable image action text
seonghobae Sep 6, 2026
3217f4a
fix(accessibility): make image action readable without emoji fonts
seonghobae Sep 6, 2026
7c405f9
test(accessibility): exercise readable image picker across engines
seonghobae Sep 6, 2026
a4e4583
merge: inherit parent browser archive evidence guard
seonghobae Sep 6, 2026
00d628a
test(toolbar): cover editor changes during image prompting
seonghobae Sep 6, 2026
01826ff
test(toolbar): observe command attempts after editor destruction
seonghobae Sep 6, 2026
f83d1d9
fix(toolbar): recheck editor state after image prompting
seonghobae Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/doctoring/toolbar-image-label.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions src/components/Toolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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');
Expand Down
47 changes: 42 additions & 5 deletions src/components/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
type FocusEvent,
type KeyboardEvent,
} 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 {
Expand All @@ -30,6 +32,27 @@ 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;
}

/** 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(
Expand Down Expand Up @@ -173,6 +196,7 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
if (!isSafeLinkHref(url)) return;
editor
.chain()
.focus()
Expand Down Expand Up @@ -203,22 +227,35 @@ 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) {
reportImageError(
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 {
reportImageError(onImageError, new Error('Image processing failed.'));
return;
}

if (editor.isDestroyed || !editor.isEditable) return;

const alternativeText = window.prompt(
'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();
Expand Down Expand Up @@ -368,8 +405,8 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) {
onClick={() => editor.chain().focus().deleteTable().run()}
/>
<ToolbarButton
title="Insert inline (base64) image"
label="🖼"
title="Insert inline image"
label="Image"
onClick={() => fileInputRef.current?.click()}
/>
<ToolbarButton
Expand Down
37 changes: 37 additions & 0 deletions src/components/ToolbarCustomerCopy.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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: '<p>before</p>',
});

render(<Toolbar editor={editor} />);

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();
});
});
190 changes: 190 additions & 0 deletions src/components/ToolbarImageLifecycle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
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: '<p>before</p>',
});
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<ArrayBuffer>((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<void> {
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.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);
chain.mockClear();
return 'stale image';
});
render(<Toolbar editor={editor} image={{ maxDimension: 0 }} />);
const chain = vi.spyOn(editor, 'chain');

fireEvent.change(fileInput(), { target: { files: [delayedPngFile()] } });
await settleConversion();

expect(prompt).toHaveBeenCalledOnce();
expect(chain).not.toHaveBeenCalled();
if (nextState === 'destroyed') expect(editor.isDestroyed).toBe(true);
else 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();
const prompt = vi.spyOn(window, 'prompt').mockReturnValue('stale image');
render(<Toolbar editor={editor} image={{ maxDimension: 0 }} />);

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(<Toolbar editor={editor} image={{ maxDimension: 0 }} />);

fireEvent.change(fileInput(), { target: { files: [delayedPngFile()] } });
editor.destroy();
await settleConversion();

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<object>([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(
<Toolbar
editor={editor}
image={{ maxSizeBytes: 1024 * 1024, maxDimension: 0 }}
onImageError={onImageError}
/>,
);

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');
});

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(
<Toolbar
editor={editor}
image={{ maxSizeBytes: 1024 * 1024, maxDimension: 0 }}
onImageError={onImageError}
/>,
);

fireEvent.change(fileInput(), { target: { files: [failedFile] } });
await settleConversion();

expect(onImageError).toHaveBeenCalledOnce();
expect(prompt).not.toHaveBeenCalled();
expect(editor.getHTML()).toBe(before);
});
});
Loading