diff --git a/docs/TRD.md b/docs/TRD.md
index 80cec07a..989019b1 100644
--- a/docs/TRD.md
+++ b/docs/TRD.md
@@ -117,6 +117,8 @@ Identity inspection returns no partial routing object on malformed input. An unk
Toolbar shortcut metadata is implemented on protected `main`. Shipped keyboard behavior, focus behavior, native controls, `aria-pressed`, `aria-keyshortcuts`, programmatic save/conflict state, and visible shortcut documentation must agree. Repository-level keyboard behavior outranks extension-local defaults when determining metadata. Status must not depend on color alone. Inkspan exposes machine state sufficient for host WCAG-oriented messaging while leaving localization and application-specific live-region policy to the host.
+Active PR #152 repairs the shared link-editing shortcut so read-only editors do not open an editing prompt or issue a link command. Both Ctrl+K and Meta+K follow live editability changes without replacing the editor or discarding its document. Editable shortcut behavior remains unchanged. This proposal is not protected-main implementation or a new host authorization boundary.
+
Accessible editor placeholder semantics are implemented on protected `main`. Standalone and collaborative textbox surfaces expose the same normalized non-blank host-supplied visual placeholder through `aria-placeholder`; whitespace-only guidance is omitted, and placeholder updates do not replace the current TipTap editor or host-owned Yjs binding. `aria-labelledby`/`aria-label` remain the accessible-name authority and placeholder guidance never grants editability.
Protected paged-media behavior extends the same principle to print output: Inkspan-owned interactive UI does not become document content, placeholder instruction is suppressed rather than printed as authored text, and link semantics remain visually distinguishable without relying on color alone.
diff --git a/src/components/CwlEditor.accessibility.test.tsx b/src/components/CwlEditor.accessibility.test.tsx
index c6b34863..d430fa19 100644
--- a/src/components/CwlEditor.accessibility.test.tsx
+++ b/src/components/CwlEditor.accessibility.test.tsx
@@ -1,8 +1,17 @@
-import { cleanup, render, screen, waitFor } from '@testing-library/react';
-import { afterEach, describe, expect, it } from 'vitest';
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import { CwlEditor } from './CwlEditor.js';
-afterEach(cleanup);
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
describe('CwlEditor form accessibility metadata', () => {
it('binds language, direction, labels, validation, and live prop updates', async () => {
@@ -67,4 +76,42 @@ describe('CwlEditor form accessibility metadata', () => {
expect(readOnlyEditor).not.toHaveAttribute('aria-errormessage');
});
});
+
+ it('does not invoke editing shortcuts while the editor is read-only', async () => {
+ const prompt = vi.spyOn(window, 'prompt').mockReturnValue('https://example.com');
+
+ render(
+ ,
+ );
+
+ const editor = await screen.findByRole('textbox', { name: 'Archived body' });
+ fireEvent.keyDown(editor, { key: 'k', ctrlKey: true });
+ fireEvent.keyDown(editor, { key: 'k', metaKey: true });
+
+ expect(prompt).not.toHaveBeenCalled();
+ });
+
+ it.each([{ ctrlKey: true }, { metaKey: true }])('updates shortcut authority when editability changes: %j', async (modifier) => {
+ const prompt = vi.spyOn(window, 'prompt').mockReturnValue(null);
+ const { rerender } = render();
+ const editor = await screen.findByRole('textbox', { name: 'Decision' });
+ fireEvent.keyDown(editor, { key: 'k', ...modifier });
+ expect(prompt).toHaveBeenCalledTimes(1);
+
+ rerender();
+ await waitFor(() => expect(editor).toHaveAttribute('aria-readonly', 'true'));
+ fireEvent.keyDown(editor, { key: 'k', ...modifier });
+ expect(prompt).toHaveBeenCalledTimes(1);
+ expect(editor).toHaveTextContent('Retained decision');
+
+ rerender();
+ await waitFor(() => expect(editor).toHaveAttribute('aria-readonly', 'false'));
+ fireEvent.keyDown(editor, { key: 'k', ...modifier });
+ expect(prompt).toHaveBeenCalledTimes(2);
+ expect(screen.getByRole('textbox', { name: 'Decision' })).toBe(editor);
+ });
});
diff --git a/src/components/EditorFrame.tsx b/src/components/EditorFrame.tsx
index ebd1e85d..db9a3a8f 100644
--- a/src/components/EditorFrame.tsx
+++ b/src/components/EditorFrame.tsx
@@ -46,6 +46,7 @@ export function EditorFrame({
(event: KeyboardEvent) => {
/* v8 ignore next -- keyboard events cannot reach an unmounted editor. */
if (!editor) return;
+ if (!editable) return;
const modifier = event.metaKey || event.ctrlKey;
if (modifier && event.key.toLowerCase() === 'k') {
event.preventDefault();
@@ -66,7 +67,7 @@ export function EditorFrame({
}
}
},
- [editor],
+ [editable, editor],
);
return (
diff --git a/tests/browser/specs/read-only-shortcut.browser.spec.ts b/tests/browser/specs/read-only-shortcut.browser.spec.ts
new file mode 100644
index 00000000..3f7121a4
--- /dev/null
+++ b/tests/browser/specs/read-only-shortcut.browser.spec.ts
@@ -0,0 +1,59 @@
+import { expect, test } from '@playwright/test';
+
+for (const modifier of ['Control', 'Meta'] as const) {
+ test(`keeps ${modifier}+K aligned with live read-only state`, async ({ page }, testInfo) => {
+ const rejectedOrigins: string[] = [];
+ await page.route('**/*', async (route) => {
+ const url = new URL(route.request().url());
+ if (url.hostname === '127.0.0.1' && url.port === '4173') {
+ await route.continue();
+ } else {
+ rejectedOrigins.push(url.origin);
+ await route.abort('blockedbyclient');
+ }
+ });
+ const prompts: string[] = [];
+ page.on('dialog', async (dialog) => {
+ prompts.push(dialog.message());
+ await dialog.dismiss();
+ });
+ await page.goto('/tests/browser/input-harness.html?toolbar=1');
+ const editor = page.getByRole('textbox', { name: 'Rich text editor' });
+ await expect(editor).toHaveAttribute('contenteditable', 'true');
+ await editor.click();
+ await page.keyboard.insertText('Retained decision — 한글 日本語 Tiếng Việt');
+ const originalEditor = await editor.elementHandle();
+ expect(originalEditor).not.toBeNull();
+ await page.keyboard.press(`${modifier}+k`);
+ await expect.poll(() => prompts.length).toBe(1);
+ await page.screenshot({ path: testInfo.outputPath(`${modifier}-editable.png`), fullPage: true });
+
+ await page.evaluate(() => window.inkspanInputHarness.setEditable(false));
+ await expect(editor).toHaveAttribute('contenteditable', 'false');
+ await expect(editor).toHaveAttribute('aria-readonly', 'true');
+ // Read-only content is not focusable; explicitly exercise its bubbling handler.
+ const allowedDefault = await editor.evaluate((element, keyModifier) =>
+ element.dispatchEvent(new KeyboardEvent('keydown', {
+ key: 'k', bubbles: true, cancelable: true,
+ ctrlKey: keyModifier === 'Control', metaKey: keyModifier === 'Meta',
+ })), modifier);
+ expect(allowedDefault).toBe(true);
+ expect(prompts).toEqual(['Link URL']);
+ await expect(editor).toHaveText('Retained decision — 한글 日本語 Tiếng Việt');
+ await expect(page.getByRole('button', { name: 'Bold (Ctrl/Cmd+B)', exact: true })).toHaveCount(0);
+ await page.screenshot({ path: testInfo.outputPath(`${modifier}-readonly.png`), fullPage: true });
+
+ await page.evaluate(() => window.inkspanInputHarness.setEditable(true));
+ await expect(editor).toHaveAttribute('contenteditable', 'true');
+ await editor.focus();
+ await page.keyboard.press(`${modifier}+k`);
+ await expect.poll(() => prompts.length).toBe(2);
+ expect(prompts).toEqual(['Link URL', 'Link URL']);
+ await expect(editor).toHaveText('Retained decision — 한글 日本語 Tiếng Việt');
+ expect(await originalEditor!.evaluate((element) => element.isConnected)).toBe(true);
+ await expect(page.getByRole('button', { name: 'Bold (Ctrl/Cmd+B)', exact: true })).toBeVisible();
+ await page.screenshot({ path: testInfo.outputPath(`${modifier}-reenabled.png`), fullPage: true });
+ await page.waitForLoadState('networkidle');
+ expect(rejectedOrigins).toEqual([]);
+ });
+}