Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
5dbddeb
test(diagnostics): define accessible writing guidance UI
seonghobae Aug 12, 2026
5d6ec52
merge(diagnostics): refresh accessible UI stack on controller
seonghobae Aug 12, 2026
f4871b9
ci(diagnostics): run accessible UI TDD contract
seonghobae Aug 12, 2026
a77e600
ci(diagnostics): expose writing guidance UI TDD state
seonghobae Aug 12, 2026
0dddfae
feat(diagnostics): render accessible writing guidance panel
seonghobae Aug 12, 2026
ab008c6
test(diagnostics): require trusted EditorFrame guidance slot
seonghobae Aug 12, 2026
26f4a69
test(diagnostics): require explicit print appendix opt-in
seonghobae Aug 12, 2026
8faebd3
test(diagnostics): require accessible and print-safe guidance styles
seonghobae Aug 12, 2026
1f45ad1
ci(diagnostics): exercise complete guidance UI contract
seonghobae Aug 12, 2026
bbf2a0a
feat(diagnostics): add trusted writing guidance slot
seonghobae Aug 12, 2026
2000481
test(diagnostics): require keyboard roving navigation
seonghobae Aug 12, 2026
871364f
feat(diagnostics): gate printed guidance explicitly
seonghobae Aug 12, 2026
d2723f9
ci(diagnostics): exercise keyboard guidance contract
seonghobae Aug 12, 2026
19e582f
feat(diagnostics): add card keyboard navigation
seonghobae Aug 12, 2026
b6c1489
feat(diagnostics): style accessible writing guidance
seonghobae Aug 12, 2026
4fdf367
ci(diagnostics): enforce exact writing guidance coverage
seonghobae Aug 12, 2026
ae92ce6
ci(diagnostics): run complete UI acceptance gate
seonghobae Aug 12, 2026
e3c2a95
test(diagnostics): contain keyboard focus updates
seonghobae Aug 12, 2026
b96b5fb
ci(diagnostics): prove exact guidance acceptance
seonghobae Aug 12, 2026
51155fa
fix(ci): restore controller workflow branch isolation
seonghobae Aug 12, 2026
982f651
refactor(diagnostics): remove unreachable empty navigation branch
seonghobae Aug 12, 2026
61a9c42
ci(diagnostics): scope exact coverage to owned panel
seonghobae Aug 12, 2026
1c39ffc
test(diagnostics): cover editor frame guidance integration
seonghobae Aug 12, 2026
1ec6ea6
refactor(diagnostics): remove unreachable focus fallback
seonghobae Aug 12, 2026
04c601e
test(diagnostics): cover hostile reflection failures
seonghobae Aug 12, 2026
3aecff8
test(diagnostics): cover hostile reflection failures
seonghobae Aug 12, 2026
659c85c
test(diagnostics): remove duplicate reflection coverage
seonghobae Aug 12, 2026
9c7453d
test(diagnostics): cover command and scalar rejection paths
seonghobae Aug 12, 2026
5beb6e1
test(diagnostics): cover hostile own-key reflection
seonghobae Aug 12, 2026
129b0a7
test(diagnostics): cover invalid install-command inputs
seonghobae Aug 12, 2026
bd49b85
test(diagnostics): cover absent plugin decoration fallback
seonghobae Aug 12, 2026
59c9ed3
test(diagnostics): bind decoration prop receiver
seonghobae Aug 12, 2026
debc374
chore(ci): remove temporary writing diagnostics UI workflow
seonghobae Aug 12, 2026
3535115
fix(ci): carry executable release workflow
seonghobae Aug 12, 2026
dd1d3d7
test(a11y): preserve focus after diagnostic dismissal
seonghobae Aug 12, 2026
a198f5f
ci(a11y): prove writing diagnostic dismissal focus boundary
seonghobae Aug 12, 2026
acbbd2a
fix(a11y): preserve focus after diagnostic dismissal
seonghobae Aug 12, 2026
f197ca8
chore(ci): remove completed dismissal-focus TDD workflow
seonghobae Aug 12, 2026
61c6758
test(a11y): require visible focus on guidance handoff target
seonghobae Aug 12, 2026
d21f175
ci(a11y): prove guidance focus-indicator boundary
seonghobae Aug 12, 2026
6d447a2
fix(a11y): show focus on empty guidance handoff target
seonghobae Aug 12, 2026
a77a1e9
chore(ci): retire focused diagnostics proof workflow
seonghobae Aug 13, 2026
53e131d
chore(diagnostics): restack UI on current controller authority
seonghobae Aug 16, 2026
6f87649
fix(stack): preserve release authority while synchronizing diagnostic…
seonghobae Aug 18, 2026
70a9c29
test(a11y): cover diagnostic dismissal focus handoff
seonghobae Aug 18, 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
187 changes: 187 additions & 0 deletions src/components/EditorFrame.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import {
cleanup,
fireEvent,
render,
screen,
} from '@testing-library/react';
import { Editor } from '@tiptap/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildExtensions } from '../extensions/kit.js';
import { EditorFrame } from './EditorFrame.js';

const openEditors: Editor[] = [];

/** Create one real TipTap editor so keyboard-link behavior is exercised end to end. */
function makeEditor(content = '<p>hello world</p>'): Editor {
const element = document.createElement('div');
document.body.appendChild(element);
const editor = new Editor({
element,
extensions: buildExtensions(),
content,
});
openEditors.push(editor);
return editor;
}

function editorSurface(container: HTMLElement): HTMLElement {
const surface = container.querySelector<HTMLElement>('.cwl-editor__surface');
if (surface === null) throw new Error('Missing editor surface');
return surface;
}

afterEach(() => {
cleanup();
for (const editor of openEditors.splice(0)) {
if (!editor.isDestroyed) editor.destroy();
}
vi.restoreAllMocks();
});

describe('EditorFrame writing diagnostics slot', () => {
it('renders the trusted panel slot immediately before the editor surface', () => {
const { container } = render(
<EditorFrame
editable
editor={null}
hideToolbar
mode="markdown"
writingDiagnosticsPanel={
<section aria-label="Writing guidance">Trusted guidance</section>
}
/>,
);

const panel = screen.getByRole('region', { name: 'Writing guidance' });
const surface = container.querySelector('.cwl-editor__surface');
expect(surface).not.toBeNull();
expect(surface?.previousElementSibling).toBe(panel);
});

it('adds no diagnostic markup when the internal slot is omitted', () => {
const { container } = render(
<EditorFrame
editable
editor={null}
hideToolbar
mode="markdown"
/>,
);

expect(container.querySelector('.cwl-writing-diagnostics')).toBeNull();
expect(container.querySelector('.cwl-editor__surface')).not.toBeNull();
});

it('renders host classes, status, and the formatting toolbar only when enabled', () => {
const editor = makeEditor();
const { container } = render(
<EditorFrame
className="host-editor"
editable
editor={editor}
hideToolbar={false}
mode="html"
status={<p>Editor status</p>}
/>,
);

expect(container.firstElementChild).toHaveClass('cwl-editor', 'host-editor');
expect(container.firstElementChild).toHaveAttribute('data-mode', 'html');
expect(screen.getByText('Editor status')).toBeInTheDocument();
expect(screen.getByRole('toolbar', { name: 'Formatting' })).toBeInTheDocument();
});

it('omits the toolbar for a read-only editor', () => {
const editor = makeEditor();
render(
<EditorFrame
editable={false}
editor={editor}
hideToolbar={false}
mode="markdown"
/>,
);

expect(screen.queryByRole('toolbar')).not.toBeInTheDocument();
});

it('omits the toolbar while no editor instance exists', () => {
render(
<EditorFrame
editable
editor={null}
hideToolbar={false}
mode="markdown"
/>,
);

expect(screen.queryByRole('toolbar')).not.toBeInTheDocument();
});
});

describe('EditorFrame link keyboard workflow', () => {
it('ignores ordinary keys and safely contains a missing editor instance', () => {
const prompt = vi.spyOn(window, 'prompt');
const { container } = render(
<EditorFrame
editable
editor={null}
hideToolbar
mode="markdown"
/>,
);
const surface = editorSurface(container);

fireEvent.keyDown(surface, { key: 'x' });
fireEvent.keyDown(surface, { key: 'k', ctrlKey: true });
expect(prompt).not.toHaveBeenCalled();
});

it('leaves the existing link unchanged when the prompt is cancelled', () => {
const editor = makeEditor();
editor.commands.selectAll();
editor.commands.setLink({ href: 'https://existing.example' });
editor.commands.setTextSelection(2);
vi.spyOn(window, 'prompt').mockReturnValue(null);
const { container } = render(
<EditorFrame editable editor={editor} hideToolbar mode="markdown" />,
);

fireEvent.keyDown(editorSurface(container), { key: 'k', metaKey: true });

expect(editor.getAttributes('link').href).toBe('https://existing.example');
expect(window.prompt).toHaveBeenCalledWith(
'Link URL',
'https://existing.example',
);
});

it('removes the current link when the prompt is submitted empty', () => {
const editor = makeEditor();
editor.commands.selectAll();
editor.commands.setLink({ href: 'https://existing.example' });
editor.commands.setTextSelection(2);
vi.spyOn(window, 'prompt').mockReturnValue('');
const { container } = render(
<EditorFrame editable editor={editor} hideToolbar mode="markdown" />,
);

fireEvent.keyDown(editorSurface(container), { key: 'K', ctrlKey: true });

expect(editor.isActive('link')).toBe(false);
});

it('sets the submitted link URL through the real editor command chain', () => {
const editor = makeEditor();
editor.commands.selectAll();
vi.spyOn(window, 'prompt').mockReturnValue('https://new.example/path');
const { container } = render(
<EditorFrame editable editor={editor} hideToolbar mode="markdown" />,
);

fireEvent.keyDown(editorSurface(container), { key: 'k', ctrlKey: true });

expect(editor.getAttributes('link').href).toBe('https://new.example/path');
expect(window.prompt).toHaveBeenCalledWith('Link URL', 'https://');
});
});
7 changes: 6 additions & 1 deletion src/components/EditorFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,14 @@ export interface EditorFrameProps {
formFieldInitialValue?: string;
onFormReset?: (event: Event) => void;
status?: ReactNode;
/** Trusted, already-validated writing guidance rendered before the editor. */
writingDiagnosticsPanel?: ReactNode;
}

/**
* Render the common Inkspan root, toolbar, keyboard surface, native form field,
* and editor content without owning document state or transport lifecycle.
* optional writing guidance, and editor content without owning document state or
* transport lifecycle.
*/
export function EditorFrame({
editor,
Expand All @@ -40,6 +43,7 @@ export function EditorFrame({
formFieldInitialValue,
onFormReset,
status,
writingDiagnosticsPanel,
}: EditorFrameProps) {
const onKeyDown = useCallback(
(event: KeyboardEvent) => {
Expand Down Expand Up @@ -90,6 +94,7 @@ export function EditorFrame({
onImageError={onImageError}
/>
) : null}
{writingDiagnosticsPanel}
<div className="cwl-editor__surface" onKeyDown={onKeyDown}>
<EditorContent editor={editor} />
</div>
Expand Down
154 changes: 154 additions & 0 deletions src/components/WritingDiagnosticsPanel.dismissFocus.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { useState } from 'react';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import type {
CwlVerifiedWritingDiagnostic,
WritingDiagnosticsController,
} from './useWritingDiagnosticsController.js';
import { WritingDiagnosticsPanel } from './WritingDiagnosticsPanel.js';

const digestHex = '4a'.repeat(32);
const documentRevision = Object.freeze({
algorithm: 'SHA-256' as const,
digestHex,
strongEntityTag: `"sha256-${digestHex}"`,
});
const textProjection = Object.freeze({
id: 'inkspan-prosemirror-text' as const,
version: 1 as const,
});

function verifiedDiagnostic(
diagnosticId: string,
title: string,
): CwlVerifiedWritingDiagnostic {
return Object.freeze({
diagnostic: Object.freeze({
diagnosticId,
documentRevision,
textProjection,
selector: Object.freeze({
type: 'TextPositionSelector' as const,
start: 0,
end: 4,
}),
categoryCode: 'clarity',
priority: 'advisory' as const,
title,
explanation: 'Clarify the intended decision.',
provenance: Object.freeze({
workflowId: 'writing-review',
workflowVersion: '1',
judgePolicyVersion: '1',
}),
}),
from: 1,
to: 5,
});
}

function Harness({
initialDiagnostics,
}: Readonly<{ initialDiagnostics: readonly CwlVerifiedWritingDiagnostic[] }>) {
const [diagnostics, setDiagnostics] = useState(initialDiagnostics);
const controller: WritingDiagnosticsController = {
status: 'active',
generation: 7,
editor: null,
diagnostics,
digestProvider: null,
focusDiagnostic: () => true,
ignoreDiagnostic: () => null,
dismissDiagnostic: (diagnosticId) => {
const target = diagnostics.find(
(candidate) => candidate.diagnostic.diagnosticId === diagnosticId,
);
if (target === undefined) return null;
setDiagnostics((current) =>
current.filter(
(candidate) => candidate.diagnostic.diagnosticId !== diagnosticId,
),
);
return Object.freeze({
action: 'dismissed' as const,
reasonCode: 'explicit' as const,
diagnosticId,
documentRevision: target.diagnostic.documentRevision,
categoryCode: target.diagnostic.categoryCode,
generation: 8,
});
},
requestDiagnosticExplanation: () => null,
};

return (
<WritingDiagnosticsPanel controller={controller} label="Writing guidance" />
);
}

afterEach(cleanup);

describe('WritingDiagnosticsPanel dismissal focus', () => {
it('moves focus to the next diagnostic when the focused card is dismissed', () => {
render(
<Harness
initialDiagnostics={[
verifiedDiagnostic('first', 'First diagnostic'),
verifiedDiagnostic('second', 'Second diagnostic'),
]}
/>,
);

const dismiss = screen.getByRole('button', { name: 'Dismiss First diagnostic' });
dismiss.focus();
expect(dismiss).toHaveFocus();

fireEvent.click(dismiss);

const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(1);
expect(items[0]).toHaveTextContent('Second diagnostic');
expect(items[0]).toHaveFocus();
});

it('moves focus to the previous diagnostic when the last card is dismissed', () => {
render(
<Harness
initialDiagnostics={[
verifiedDiagnostic('first', 'First diagnostic'),
verifiedDiagnostic('second', 'Second diagnostic'),
]}
/>,
);

const dismiss = screen.getByRole('button', { name: 'Dismiss Second diagnostic' });
dismiss.focus();
expect(dismiss).toHaveFocus();

fireEvent.click(dismiss);

const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(1);
expect(items[0]).toHaveTextContent('First diagnostic');
expect(items[0]).toHaveFocus();
});

it('moves focus to the guidance region when the only card is dismissed', () => {
render(
<Harness
initialDiagnostics={[verifiedDiagnostic('only', 'Only diagnostic')]}
/>,
);

const dismiss = screen.getByRole('button', { name: 'Dismiss Only diagnostic' });
dismiss.focus();
expect(dismiss).toHaveFocus();

fireEvent.click(dismiss);

expect(screen.queryAllByRole('listitem')).toHaveLength(0);
expect(
screen.getByRole('region', { name: 'Writing guidance' }),
).toHaveFocus();
});
});
Loading