diff --git a/src/components/EditorFrame.test.tsx b/src/components/EditorFrame.test.tsx new file mode 100644 index 00000000..dabe83b8 --- /dev/null +++ b/src/components/EditorFrame.test.tsx @@ -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 = '

hello world

'): 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('.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( + Trusted guidance + } + />, + ); + + 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( + , + ); + + 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( + Editor status

} + />, + ); + + 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( + , + ); + + expect(screen.queryByRole('toolbar')).not.toBeInTheDocument(); + }); + + it('omits the toolbar while no editor instance exists', () => { + render( + , + ); + + 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( + , + ); + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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://'); + }); +}); diff --git a/src/components/EditorFrame.tsx b/src/components/EditorFrame.tsx index 9cf49c23..5761fae7 100644 --- a/src/components/EditorFrame.tsx +++ b/src/components/EditorFrame.tsx @@ -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, @@ -40,6 +43,7 @@ export function EditorFrame({ formFieldInitialValue, onFormReset, status, + writingDiagnosticsPanel, }: EditorFrameProps) { const onKeyDown = useCallback( (event: KeyboardEvent) => { @@ -90,6 +94,7 @@ export function EditorFrame({ onImageError={onImageError} /> ) : null} + {writingDiagnosticsPanel}
diff --git a/src/components/WritingDiagnosticsPanel.dismissFocus.test.tsx b/src/components/WritingDiagnosticsPanel.dismissFocus.test.tsx new file mode 100644 index 00000000..3ecab985 --- /dev/null +++ b/src/components/WritingDiagnosticsPanel.dismissFocus.test.tsx @@ -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 ( + + ); +} + +afterEach(cleanup); + +describe('WritingDiagnosticsPanel dismissal focus', () => { + it('moves focus to the next diagnostic when the focused card is dismissed', () => { + render( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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(); + }); +}); diff --git a/src/components/WritingDiagnosticsPanel.dismissalFocus.test.tsx b/src/components/WritingDiagnosticsPanel.dismissalFocus.test.tsx new file mode 100644 index 00000000..62dcbd96 --- /dev/null +++ b/src/components/WritingDiagnosticsPanel.dismissalFocus.test.tsx @@ -0,0 +1,155 @@ +import { useState } from 'react'; +import { + cleanup, + fireEvent, + render, + screen, +} from '@testing-library/react'; +import { afterEach, expect, it, vi } 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 StatefulDiagnosticsPanel({ + initialDiagnostics, + focusDiagnostic, +}: Readonly<{ + initialDiagnostics: readonly CwlVerifiedWritingDiagnostic[]; + focusDiagnostic: WritingDiagnosticsController['focusDiagnostic']; +}>) { + const [diagnostics, setDiagnostics] = useState(initialDiagnostics); + const controller: WritingDiagnosticsController = { + status: diagnostics.length === 0 ? 'absent' : 'active', + generation: 7, + editor: null, + diagnostics, + digestProvider: null, + focusDiagnostic, + ignoreDiagnostic: () => null, + dismissDiagnostic: (diagnosticId) => { + const diagnostic = diagnostics.find( + (candidate) => candidate.diagnostic.diagnosticId === diagnosticId, + ); + if (diagnostic === 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, + categoryCode: diagnostic.diagnostic.categoryCode, + generation: 7, + }); + }, + requestDiagnosticExplanation: () => null, + }; + + return ( + + ); +} + +afterEach(cleanup); + +it('moves focus to the next surviving diagnostic after a stateful dismissal', () => { + const first = verifiedDiagnostic('diagnostic-one', 'First diagnostic'); + const second = verifiedDiagnostic('diagnostic-two', 'Second diagnostic'); + const focusDiagnostic = vi.fn(() => true); + + render( + , + ); + + const dismissFirst = screen.getByRole('button', { + name: 'Dismiss First diagnostic', + }); + dismissFirst.focus(); + expect(dismissFirst).toHaveFocus(); + + fireEvent.click(dismissFirst); + + const remainingItems = screen.getAllByRole('listitem'); + expect(remainingItems).toHaveLength(1); + expect(remainingItems[0]).toHaveFocus(); + expect(remainingItems[0]).toHaveAttribute('tabindex', '0'); + expect(focusDiagnostic).toHaveBeenLastCalledWith('diagnostic-two'); + expect(screen.getByRole('status')).toHaveTextContent( + 'Dismissed First diagnostic.', + ); +}); + +it('moves focus to the guidance region when the final diagnostic is dismissed', () => { + const only = verifiedDiagnostic('diagnostic-only', 'Only diagnostic'); + const focusDiagnostic = vi.fn(() => true); + + render( + , + ); + + const region = screen.getByRole('region', { name: 'Writing guidance' }); + const dismissOnly = screen.getByRole('button', { + name: 'Dismiss Only diagnostic', + }); + dismissOnly.focus(); + fireEvent.click(dismissOnly); + + expect(region).toHaveFocus(); + expect(screen.queryAllByRole('listitem')).toHaveLength(0); + expect(screen.getByText('0 writing diagnostics')).toBeVisible(); + expect(focusDiagnostic).not.toHaveBeenCalled(); +}); diff --git a/src/components/WritingDiagnosticsPanel.keyboard.test.tsx b/src/components/WritingDiagnosticsPanel.keyboard.test.tsx new file mode 100644 index 00000000..b7ecd333 --- /dev/null +++ b/src/components/WritingDiagnosticsPanel.keyboard.test.tsx @@ -0,0 +1,116 @@ +import { + act, + cleanup, + fireEvent, + render, + screen, +} from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { + CwlVerifiedWritingDiagnostic, + WritingDiagnosticsController, +} from './useWritingDiagnosticsController.js'; +import { WritingDiagnosticsPanel } from './WritingDiagnosticsPanel.js'; + +const digestHex = '6b'.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 diagnostic( + diagnosticId: string, + title: string, +): CwlVerifiedWritingDiagnostic { + return Object.freeze({ + diagnostic: Object.freeze({ + diagnosticId, + documentRevision, + textProjection, + selector: Object.freeze({ + type: 'TextPositionSelector' as const, + start: 0, + end: 1, + }), + categoryCode: 'clarity', + priority: 'advisory' as const, + title, + explanation: `${title} explanation`, + provenance: Object.freeze({ + workflowId: 'writing-review', + workflowVersion: '1', + judgePolicyVersion: '1', + }), + }), + from: 1, + to: 2, + }); +} + +function controller( + diagnostics: readonly CwlVerifiedWritingDiagnostic[], +): WritingDiagnosticsController { + return { + status: 'active', + generation: 3, + editor: null, + diagnostics, + digestProvider: null, + focusDiagnostic: vi.fn(() => true), + ignoreDiagnostic: vi.fn(() => null), + dismissDiagnostic: vi.fn(() => null), + requestDiagnosticExplanation: vi.fn(() => null), + }; +} + +afterEach(cleanup); + +describe('WritingDiagnosticsPanel keyboard navigation', () => { + it('supports ArrowUp, ArrowDown, Home, and End only from a diagnostic card', () => { + const first = diagnostic('first', 'First'); + const second = diagnostic('second', 'Second'); + const third = diagnostic('third', 'Third'); + const activeController = controller([first, second, third]); + + render( + , + ); + + const items = screen.getAllByRole('listitem'); + act(() => { + items[0]!.focus(); + }); + fireEvent.keyDown(items[0]!, { key: 'ArrowDown' }); + expect(items[1]).toHaveFocus(); + expect(activeController.focusDiagnostic).toHaveBeenLastCalledWith('second'); + + fireEvent.keyDown(items[1]!, { key: 'End' }); + expect(items[2]).toHaveFocus(); + expect(activeController.focusDiagnostic).toHaveBeenLastCalledWith('third'); + + fireEvent.keyDown(items[2]!, { key: 'Home' }); + expect(items[0]).toHaveFocus(); + expect(activeController.focusDiagnostic).toHaveBeenLastCalledWith('first'); + + fireEvent.keyDown(items[0]!, { key: 'ArrowUp' }); + expect(items[2]).toHaveFocus(); + expect(activeController.focusDiagnostic).toHaveBeenLastCalledWith('third'); + + const focusButton = screen.getByRole('button', { + name: 'Focus affected text for First', + }); + act(() => { + focusButton.focus(); + }); + fireEvent.keyDown(focusButton, { key: 'ArrowDown' }); + expect(focusButton).toHaveFocus(); + }); +}); diff --git a/src/components/WritingDiagnosticsPanel.print.test.tsx b/src/components/WritingDiagnosticsPanel.print.test.tsx new file mode 100644 index 00000000..907d4ef4 --- /dev/null +++ b/src/components/WritingDiagnosticsPanel.print.test.tsx @@ -0,0 +1,45 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { WritingDiagnosticsController } from './useWritingDiagnosticsController.js'; +import { WritingDiagnosticsPanel } from './WritingDiagnosticsPanel.js'; + +const emptyController: WritingDiagnosticsController = { + status: 'absent', + generation: 0, + editor: null, + diagnostics: [], + digestProvider: null, + focusDiagnostic: vi.fn(() => false), + ignoreDiagnostic: vi.fn(() => null), + dismissDiagnostic: vi.fn(() => null), + requestDiagnosticExplanation: vi.fn(() => null), +}; + +afterEach(cleanup); + +describe('WritingDiagnosticsPanel print contract', () => { + it('keeps the appendix disabled unless the host opts in explicitly', () => { + const { rerender } = render( + , + ); + + expect( + screen.getByRole('region', { name: 'Writing guidance' }), + ).not.toHaveAttribute('data-print-enabled'); + + rerender( + , + ); + + expect( + screen.getByRole('region', { name: 'Writing guidance' }), + ).toHaveAttribute('data-print-enabled', 'true'); + }); +}); diff --git a/src/components/WritingDiagnosticsPanel.test.tsx b/src/components/WritingDiagnosticsPanel.test.tsx new file mode 100644 index 00000000..64383b9e --- /dev/null +++ b/src/components/WritingDiagnosticsPanel.test.tsx @@ -0,0 +1,288 @@ +import { + cleanup, + fireEvent, + render, + screen, + within, +} from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { + CwlWritingDiagnosticActionEvent, + 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, + options: Readonly<{ + categoryCode?: string; + priority?: 'advisory' | 'important' | 'critical'; + explanation?: string; + suggestedReplacement?: string; + from?: number; + to?: number; + }> = {}, +): CwlVerifiedWritingDiagnostic { + const diagnostic = { + diagnosticId, + documentRevision, + textProjection, + selector: Object.freeze({ + type: 'TextPositionSelector' as const, + start: 0, + end: 4, + }), + categoryCode: options.categoryCode ?? 'clarity', + priority: options.priority ?? 'advisory', + title, + explanation: options.explanation ?? 'Clarify the intended decision.', + provenance: Object.freeze({ + workflowId: 'writing-review', + workflowVersion: '1', + judgePolicyVersion: '1', + }), + ...(options.suggestedReplacement === undefined + ? {} + : { suggestedReplacement: options.suggestedReplacement }), + }; + return Object.freeze({ + diagnostic: Object.freeze(diagnostic), + from: options.from ?? 1, + to: options.to ?? 5, + }); +} + +function actionEvent( + diagnostic: CwlVerifiedWritingDiagnostic, + action: CwlWritingDiagnosticActionEvent['action'], +): CwlWritingDiagnosticActionEvent { + return Object.freeze({ + action, + reasonCode: 'explicit', + diagnosticId: diagnostic.diagnostic.diagnosticId, + documentRevision, + categoryCode: diagnostic.diagnostic.categoryCode, + generation: 7, + }); +} + +function controllerFor( + diagnostics: readonly CwlVerifiedWritingDiagnostic[], +): WritingDiagnosticsController { + return { + status: diagnostics.length === 0 ? 'absent' : 'active', + generation: 7, + editor: null, + diagnostics, + digestProvider: null, + focusDiagnostic: vi.fn(() => true), + ignoreDiagnostic: vi.fn((diagnosticId) => { + const diagnostic = diagnostics.find( + (candidate) => candidate.diagnostic.diagnosticId === diagnosticId, + ); + return diagnostic === undefined + ? null + : actionEvent(diagnostic, 'ignored'); + }), + dismissDiagnostic: vi.fn((diagnosticId) => { + const diagnostic = diagnostics.find( + (candidate) => candidate.diagnostic.diagnosticId === diagnosticId, + ); + return diagnostic === undefined + ? null + : actionEvent(diagnostic, 'dismissed'); + }), + requestDiagnosticExplanation: vi.fn((diagnosticId) => { + const diagnostic = diagnostics.find( + (candidate) => candidate.diagnostic.diagnosticId === diagnosticId, + ); + return diagnostic === undefined + ? null + : actionEvent(diagnostic, 'requested_explanation'); + }), + }; +} + +afterEach(cleanup); + +describe('WritingDiagnosticsPanel', () => { + it('renders bounded host guidance as accessible text with explicit actions', () => { + const first = verifiedDiagnostic( + 'diagnostic-one', + 'Clarify ', + { + categoryCode: 'clarity', + priority: 'important', + suggestedReplacement: 'State the approved decision.', + }, + ); + const second = verifiedDiagnostic('diagnostic-two', 'Add supporting evidence', { + categoryCode: 'evidence', + priority: 'critical', + explanation: 'Cite the source used for this claim.', + from: 8, + to: 12, + }); + const controller = controllerFor([first, second]); + const applyDiagnostic = vi.fn(); + + render( + , + ); + + const region = screen.getByRole('region', { name: 'Writing guidance' }); + expect(within(region).getByText('2 writing diagnostics')).toBeVisible(); + expect(within(region).getByRole('list')).toBeVisible(); + expect(within(region).getAllByRole('listitem')).toHaveLength(2); + expect( + within(region).getByText('Clarify '), + ).toBeVisible(); + expect(region.querySelector('img')).toBeNull(); + expect(within(region).getByText('important')).toBeVisible(); + expect(within(region).getByText('clarity')).toBeVisible(); + expect(within(region).getByText('Cite the source used for this claim.')).toBeVisible(); + + const focusFirst = within(region).getByRole('button', { + name: 'Focus affected text for Clarify ', + }); + fireEvent.click(focusFirst); + expect(controller.focusDiagnostic).toHaveBeenCalledWith('diagnostic-one'); + + const firstApply = within(region).getByRole('button', { + name: 'Apply suggestion for Clarify ', + }); + expect(firstApply).toBeEnabled(); + fireEvent.click(firstApply); + expect(applyDiagnostic).toHaveBeenCalledWith('diagnostic-one'); + + expect( + within(region).getByRole('button', { + name: 'Apply suggestion for Add supporting evidence', + }), + ).toBeDisabled(); + + fireEvent.click( + within(region).getByRole('button', { + name: 'Ignore Add supporting evidence', + }), + ); + expect(controller.ignoreDiagnostic).toHaveBeenCalledWith('diagnostic-two'); + expect(screen.getByRole('status')).toHaveTextContent( + 'Ignored Add supporting evidence.', + ); + + fireEvent.click( + within(region).getByRole('button', { + name: 'Dismiss Add supporting evidence', + }), + ); + expect(controller.dismissDiagnostic).toHaveBeenCalledWith('diagnostic-two'); + expect(screen.getByRole('status')).toHaveTextContent( + 'Dismissed Add supporting evidence.', + ); + + fireEvent.click( + within(region).getByRole('button', { + name: 'Explain Add supporting evidence', + }), + ); + expect(controller.requestDiagnosticExplanation).toHaveBeenCalledWith( + 'diagnostic-two', + ); + expect(screen.getByRole('status')).toHaveTextContent( + 'Requested explanation for Add supporting evidence.', + ); + }); + + it('does not steal focus when diagnostics arrive and provides explicit roving navigation', () => { + const first = verifiedDiagnostic('diagnostic-one', 'First diagnostic', { + suggestedReplacement: 'First replacement', + }); + const second = verifiedDiagnostic('diagnostic-two', 'Second diagnostic'); + const emptyController = controllerFor([]); + const activeController = controllerFor([first, second]); + const { rerender } = render( + <> + + + , + ); + const hostFocus = screen.getByRole('button', { name: 'Host focus' }); + hostFocus.focus(); + + rerender( + <> + + + , + ); + + expect(hostFocus).toHaveFocus(); + const items = screen.getAllByRole('listitem'); + expect(items[0]).toHaveAttribute('tabindex', '0'); + expect(items[1]).toHaveAttribute('tabindex', '-1'); + + fireEvent.click( + screen.getByRole('button', { name: 'Next writing diagnostic' }), + ); + expect(activeController.focusDiagnostic).toHaveBeenCalledWith( + 'diagnostic-two', + ); + expect(items[1]).toHaveFocus(); + expect(items[0]).toHaveAttribute('tabindex', '-1'); + expect(items[1]).toHaveAttribute('tabindex', '0'); + + fireEvent.click( + screen.getByRole('button', { name: 'Previous writing diagnostic' }), + ); + expect(activeController.focusDiagnostic).toHaveBeenCalledWith( + 'diagnostic-one', + ); + expect(items[0]).toHaveFocus(); + }); + + it('uses an assertive alert only for an application conflict', () => { + const diagnostic = verifiedDiagnostic('diagnostic-one', 'Conflicting change', { + suggestedReplacement: 'Replacement', + }); + const controller = controllerFor([diagnostic]); + + render( + , + ); + + expect(screen.getByRole('alert')).toHaveTextContent( + 'The document changed before this suggestion could be applied.', + ); + expect(screen.queryByRole('status')).not.toHaveTextContent( + 'The document changed before this suggestion could be applied.', + ); + }); +}); diff --git a/src/components/WritingDiagnosticsPanel.tsx b/src/components/WritingDiagnosticsPanel.tsx new file mode 100644 index 00000000..33781f82 --- /dev/null +++ b/src/components/WritingDiagnosticsPanel.tsx @@ -0,0 +1,256 @@ +import { + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, +} from 'react'; +import type { WritingDiagnosticsController } from './useWritingDiagnosticsController.js'; + +/** Props for Inkspan's provider-neutral writing-guidance presentation surface. */ +export interface WritingDiagnosticsPanelProps { + /** Revision-bound diagnostics and local advisory actions owned by the controller. */ + readonly controller: WritingDiagnosticsController; + /** Accessible name for the guidance region. */ + readonly label: string; + /** Host-owned replacement request. Task 6 performs revision-rechecked mutation. */ + readonly onApplyDiagnostic?: (diagnosticId: string) => void; + /** Host-supplied, already-redacted conflict text announced assertively. */ + readonly conflictMessage?: string; + /** Include a compact diagnostic appendix in print output when explicitly enabled. */ + readonly printEnabled?: boolean; +} + +/** + * Render already-validated writing diagnostics as accessible plain text. + * + * Inkspan does not infer language quality, reinterpret host categories, or call + * models, providers, networks, persistence services, or host transports here. + */ +export function WritingDiagnosticsPanel({ + controller, + label, + onApplyDiagnostic, + conflictMessage, + printEnabled = false, +}: WritingDiagnosticsPanelProps) { + const diagnostics = controller.diagnostics; + const [activeDiagnosticId, setActiveDiagnosticId] = useState( + null, + ); + const [statusMessage, setStatusMessage] = useState(''); + const regionRef = useRef(null); + const itemRefs = useRef>([]); + const selectedIndex = diagnostics.findIndex( + (candidate) => + candidate.diagnostic.diagnosticId === activeDiagnosticId, + ); + const activeIndex = selectedIndex < 0 ? 0 : selectedIndex; + + const focusIndex = (requestedIndex: number): void => { + const targetIndex = + (requestedIndex + diagnostics.length) % diagnostics.length; + const target = diagnostics[targetIndex]!; + const diagnosticId = target.diagnostic.diagnosticId; + setActiveDiagnosticId(diagnosticId); + controller.focusDiagnostic(diagnosticId); + // Only mounted diagnostic cards and enabled navigation invoke this helper. + itemRefs.current[targetIndex]!.focus(); + }; + + const focusAfterDismissal = (dismissedIndex: number): void => { + if (diagnostics.length === 1) { + setActiveDiagnosticId(null); + regionRef.current?.focus(); + return; + } + + const targetIndex = + dismissedIndex < diagnostics.length - 1 + ? dismissedIndex + 1 + : dismissedIndex - 1; + const target = diagnostics[targetIndex]!; + const diagnosticId = target.diagnostic.diagnosticId; + setActiveDiagnosticId(diagnosticId); + controller.focusDiagnostic(diagnosticId); + itemRefs.current[targetIndex]?.focus(); + }; + + const navigate = (offset: number): void => { + focusIndex(activeIndex + offset); + }; + + const onItemKeyDown = ( + event: ReactKeyboardEvent, + index: number, + ): void => { + if (event.target !== event.currentTarget) return; + if (event.key === 'ArrowDown') { + event.preventDefault(); + focusIndex(index + 1); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + focusIndex(index - 1); + } else if (event.key === 'Home') { + event.preventDefault(); + focusIndex(0); + } else if (event.key === 'End') { + event.preventDefault(); + focusIndex(diagnostics.length - 1); + } + }; + + return ( +
+
+

+ {diagnostics.length} writing diagnostics +

+
+ + +
+
+ +
    + {diagnostics.map((verified, index) => { + const diagnostic = verified.diagnostic; + const hasReplacement = + diagnostic.suggestedReplacement !== undefined; + return ( +
  1. setActiveDiagnosticId(diagnostic.diagnosticId)} + onKeyDown={(event) => onItemKeyDown(event, index)} + ref={(element) => { + itemRefs.current[index] = element; + }} + tabIndex={index === activeIndex ? 0 : -1} + > +
    +
    +

    {diagnostic.title}

    + {diagnostic.priority} + {diagnostic.categoryCode} +
    +

    {diagnostic.explanation}

    + {hasReplacement ? ( +

    + {diagnostic.suggestedReplacement} +

    + ) : null} +
    + + + + + +
    +
    +
  2. + ); + })} +
+ +

+ {statusMessage} +

+ {conflictMessage === undefined ? null : ( +

+ {conflictMessage} +

+ )} +
+ ); +} + +export default WritingDiagnosticsPanel; diff --git a/src/extensions/WritingDiagnosticsReflection.test.ts b/src/extensions/WritingDiagnosticsReflection.test.ts new file mode 100644 index 00000000..4127b1d5 --- /dev/null +++ b/src/extensions/WritingDiagnosticsReflection.test.ts @@ -0,0 +1,173 @@ +import { Schema } from '@tiptap/pm/model'; +import { EditorState } from '@tiptap/pm/state'; +import { Editor } from '@tiptap/react'; +import { describe, expect, it } from 'vitest'; +import { buildExtensions } from './kit.js'; +import { + createWritingDiagnosticsPlugin, + writingDiagnosticsPluginKey, + type CwlResolvedWritingDiagnosticDecoration, + type WritingDiagnosticsPluginState, +} from './WritingDiagnostics.js'; + +const schema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + paragraph: { content: 'text*', toDOM: () => ['p', 0] }, + text: {}, + }, +}); + +/** Create one real plugin state for metadata-boundary assertions. */ +function stateWithText(): EditorState { + return EditorState.create({ + schema, + doc: schema.node('doc', undefined, [ + schema.node('paragraph', undefined, [schema.text('Alpha beta gamma')]), + ]), + plugins: [createWritingDiagnosticsPlugin()], + }); +} + +/** Create one equivalent document state without installing the diagnostics plugin. */ +function stateWithoutDiagnostics(): EditorState { + return EditorState.create({ + schema, + doc: schema.node('doc', undefined, [ + schema.node('paragraph', undefined, [schema.text('Alpha beta gamma')]), + ]), + }); +} + +/** Read the writing-diagnostics state or fail the test fixture explicitly. */ +function pluginState(state: EditorState): WritingDiagnosticsPluginState { + const result = writingDiagnosticsPluginKey.getState(state); + if (result === undefined) throw new Error('Missing writing diagnostics state'); + return result; +} + +/** Build one ordinary resolved diagnostic before wrapping it in hostile proxies. */ +function diagnostic(): CwlResolvedWritingDiagnosticDecoration { + return { + diagnosticId: 'diag-reflection', + from: 1, + to: 6, + priority: 'important', + }; +} + +/** Apply untrusted transaction metadata without using typed helper functions. */ +function applyInstallCandidate( + state: EditorState, + candidate: unknown, +): EditorState { + return state.apply( + state.tr.setMeta(writingDiagnosticsPluginKey, { + type: 'install', + generation: 1, + diagnostics: [candidate], + }), + ); +} + +/** Create an editor that includes the shared writing-diagnostics command surface. */ +function commandEditor(): Editor { + return new Editor({ + extensions: buildExtensions(), + content: '

Alpha beta gamma

', + }); +} + +describe('WritingDiagnostics hostile reflection failures', () => { + it('returns no decorations when the plugin prop is queried against an unrelated state', () => { + const plugin = createWritingDiagnosticsPlugin(); + const decorations = plugin.props.decorations; + if (decorations === undefined) { + throw new Error('Missing writing diagnostics decoration prop'); + } + + expect(decorations.call(plugin, stateWithoutDiagnostics())).toBeNull(); + }); + + it('rejects prototype, own-key, and property-descriptor traps without leaking or throwing', () => { + let state = stateWithText(); + const initial = pluginState(state); + const prototypeTrap = new Proxy(diagnostic(), { + getPrototypeOf() { + throw new Error('private prototype detail'); + }, + }); + const ownKeyTrap = new Proxy(diagnostic(), { + ownKeys() { + throw new Error('private key detail'); + }, + }); + const descriptorTrap = new Proxy(diagnostic(), { + getOwnPropertyDescriptor(target, key) { + if (key === 'from') throw new Error('private descriptor detail'); + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }); + + for (const candidate of [prototypeTrap, ownKeyTrap, descriptorTrap]) { + expect(() => { + state = applyInstallCandidate(state, candidate); + }).not.toThrow(); + expect(pluginState(state)).toBe(initial); + } + }); + + it('rejects primitive, null, and array diagnostic members as inert metadata', () => { + let state = stateWithText(); + const initial = pluginState(state); + + for (const candidate of ['diagnostic', null, []]) { + expect(() => { + state = applyInstallCandidate(state, candidate); + }).not.toThrow(); + expect(pluginState(state)).toBe(initial); + } + }); + + it('rejects every invalid install-command input before dispatch', () => { + const editor = commandEditor(); + + try { + expect( + editor.commands.installWritingDiagnostics(Number.NaN, [diagnostic()]), + ).toBe(false); + expect( + editor.commands.installWritingDiagnostics(-1, [diagnostic()]), + ).toBe(false); + expect( + editor.commands.installWritingDiagnostics( + 0, + null as unknown as readonly CwlResolvedWritingDiagnosticDecoration[], + ), + ).toBe(false); + } finally { + editor.destroy(); + } + }); + + it('rejects every invalid focus-command scalar before dispatch', () => { + const editor = commandEditor(); + + try { + expect(editor.commands.focusWritingDiagnostic(Number.NaN, 'diag')).toBe(false); + expect(editor.commands.focusWritingDiagnostic(-1, 'diag')).toBe(false); + expect( + editor.commands.focusWritingDiagnostic( + 0, + 42 as unknown as string, + ), + ).toBe(false); + expect(editor.commands.focusWritingDiagnostic(0, '')).toBe(false); + expect( + editor.commands.focusWritingDiagnostic(0, 'x'.repeat(257)), + ).toBe(false); + } finally { + editor.destroy(); + } + }); +}); diff --git a/src/printStyles.test.ts b/src/printStyles.test.ts index 632a033b..78a85077 100644 --- a/src/printStyles.test.ts +++ b/src/printStyles.test.ts @@ -64,4 +64,49 @@ describe('print stylesheet contract', () => { expect(browserSpecification).not.toContain('/src/styles.css'); expect(browserConfiguration).toContain('pnpm --dir ../.. build'); }); -}); \ No newline at end of file + + it('styles diagnostic ranges by priority without generated-text dependence', () => { + expect(styles).toContain('.cwl-writing-diagnostic--advisory'); + expect(styles).toContain('.cwl-writing-diagnostic--important'); + expect(styles).toContain('.cwl-writing-diagnostic--critical'); + expect(styles).toContain('text-decoration-line: underline'); + expect(styles).toContain('.cwl-writing-diagnostics__item:focus-visible'); + expect(styles).toContain( + '.cwl-writing-diagnostics__actions button:focus-visible', + ); + expect(styles).not.toMatch( + /\.cwl-writing-diagnostics[^\{]*::(?:before|after)\s*\{[^}]*content\s*:/u, + ); + }); + + it('keeps the empty-guidance focus handoff visibly perceivable', () => { + expect(styles).toMatch( + /\.cwl-writing-diagnostics:focus-visible[\s\S]*\{[^}]*outline:\s*2px solid var\(--cwl-accent\)\s*;[^}]*outline-offset:\s*2px\s*;/u, + ); + }); + + it('preserves forced-colors, reduced-motion, and touch-target guidance', () => { + expect(styles).toMatch( + /@media\s*\(forced-colors:\s*active\)[\s\S]*\.cwl-writing-diagnostic[\s\S]*CanvasText/u, + ); + expect(styles).toContain('@media (prefers-reduced-motion: reduce)'); + expect(styles).toContain('min-height: 44px'); + expect(styles).toContain('min-width: 44px'); + }); + + it('prints no guidance by default and only a compact opted-in appendix', () => { + const printIndex = styles.indexOf('@media print'); + expect(printIndex).toBeGreaterThan(-1); + const printStyles = styles.slice(printIndex); + + expect(printStyles).toMatch( + /\.cwl-writing-diagnostics\s*\{[^}]*display:\s*none\s*!important\s*;/u, + ); + expect(printStyles).toMatch( + /\.cwl-writing-diagnostics\[data-print-enabled='true'\]\s*\{[^}]*display:\s*block\s*!important\s*;/u, + ); + expect(printStyles).toMatch( + /\.cwl-writing-diagnostics__actions[\s\S]*\.cwl-writing-diagnostics__navigation[\s\S]*\{[^}]*display:\s*none\s*!important\s*;/u, + ); + }); +}); diff --git a/src/styles.css b/src/styles.css index 0970a931..470f30c3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -16,6 +16,7 @@ --cwl-surface: #f6f8fa; --cwl-accent: #0969da; --cwl-accent-soft: #ddf4ff; + --cwl-critical: #cf222e; --cwl-radius: 8px; /* Bundled Noto Sans stack: covers Latin/Vietnamese + Korean + Japanese + Simplified & Traditional Chinese. Override --cwl-font to re-theme. */ @@ -43,6 +44,7 @@ --cwl-surface: #161b22; --cwl-accent: #4493f8; --cwl-accent-soft: #163356; + --cwl-critical: #ff7b72; } } @@ -107,9 +109,24 @@ } @media (forced-colors: active) { - .cwl-tb-btn:focus-visible { + .cwl-tb-btn:focus-visible, + .cwl-writing-diagnostics:focus-visible, + .cwl-writing-diagnostics__item:focus-visible, + .cwl-writing-diagnostics__actions button:focus-visible, + .cwl-writing-diagnostics__navigation-button:focus-visible { outline-color: CanvasText; } + + .cwl-writing-diagnostic { + text-decoration-color: CanvasText; + } + + .cwl-writing-diagnostics, + .cwl-writing-diagnostics__item, + .cwl-writing-diagnostics__actions button, + .cwl-writing-diagnostics__navigation-button { + border-color: CanvasText; + } } .cwl-editor__surface { @@ -260,6 +277,183 @@ padding-top: calc(16px + 1.6em); } +.cwl-writing-diagnostic { + text-decoration-line: underline; + text-decoration-style: wavy; + text-decoration-thickness: 0.12em; + text-underline-offset: 0.16em; +} + +.cwl-writing-diagnostic--advisory { + text-decoration-color: var(--cwl-muted); +} + +.cwl-writing-diagnostic--important { + text-decoration-color: var(--cwl-accent); +} + +.cwl-writing-diagnostic--critical { + text-decoration-color: var(--cwl-critical); +} + +.cwl-writing-diagnostics { + border-top: 1px solid var(--cwl-border); + border-bottom: 1px solid var(--cwl-border); + background: var(--cwl-surface); + padding: 12px; +} + +.cwl-writing-diagnostics__header, +.cwl-writing-diagnostics__item-header, +.cwl-writing-diagnostics__actions, +.cwl-writing-diagnostics__navigation { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.cwl-writing-diagnostics__header { + justify-content: space-between; + margin-bottom: 10px; +} + +.cwl-writing-diagnostics__summary, +.cwl-writing-diagnostics__item-header h3, +.cwl-writing-diagnostics__item p, +.cwl-writing-diagnostics__status, +.cwl-writing-diagnostics__conflict { + margin: 0; +} + +.cwl-writing-diagnostics__summary { + font-weight: 700; +} + +.cwl-writing-diagnostics__list { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style-position: inside; +} + +.cwl-writing-diagnostics__item { + border: 1px solid var(--cwl-border); + border-left-width: 4px; + border-radius: var(--cwl-radius); + background: var(--cwl-bg); + padding: 12px; +} + +.cwl-writing-diagnostics__item--advisory { + border-left-color: var(--cwl-muted); +} + +.cwl-writing-diagnostics__item--important { + border-left-color: var(--cwl-accent); +} + +.cwl-writing-diagnostics__item--critical { + border-left-color: var(--cwl-critical); +} + +.cwl-writing-diagnostics:focus-visible, +.cwl-writing-diagnostics__item:focus-visible, +.cwl-writing-diagnostics__actions button:focus-visible, +.cwl-writing-diagnostics__navigation-button:focus-visible { + outline: 2px solid var(--cwl-accent); + outline-offset: 2px; +} + +.cwl-writing-diagnostics__item-header { + margin-bottom: 8px; +} + +.cwl-writing-diagnostics__item-header h3 { + flex: 1 1 16rem; + font-size: 0.95rem; +} + +.cwl-writing-diagnostics__item-header span { + border: 1px solid var(--cwl-border); + border-radius: 999px; + padding: 2px 8px; + color: var(--cwl-muted); + font-size: 0.75rem; + font-weight: 700; +} + +.cwl-writing-diagnostics__replacement { + margin-top: 8px !important; + border-left: 3px solid var(--cwl-accent); + padding-left: 10px; + white-space: pre-wrap; +} + +.cwl-writing-diagnostics__actions { + margin-top: 10px; +} + +.cwl-writing-diagnostics__actions button, +.cwl-writing-diagnostics__navigation-button { + min-width: 44px; + min-height: 44px; + border: 1px solid var(--cwl-border); + border-radius: 6px; + background: var(--cwl-bg); + color: var(--cwl-fg); + cursor: pointer; + font: inherit; + font-size: 0.8rem; + font-weight: 600; + padding: 8px 10px; + transition: + background 0.12s ease, + border-color 0.12s ease; +} + +.cwl-writing-diagnostics__actions button:hover:not(:disabled), +.cwl-writing-diagnostics__navigation-button:hover:not(:disabled) { + border-color: var(--cwl-accent); + background: var(--cwl-accent-soft); +} + +.cwl-writing-diagnostics__actions button:disabled, +.cwl-writing-diagnostics__navigation-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.cwl-writing-diagnostics__status { + min-height: 1.5em; + margin-top: 8px; + color: var(--cwl-muted); +} + +.cwl-writing-diagnostics__conflict { + margin-top: 8px; + border: 1px solid var(--cwl-critical); + border-radius: 6px; + padding: 8px 10px; + color: var(--cwl-critical); + font-weight: 700; +} + +@media (prefers-reduced-motion: reduce) { + .cwl-writing-diagnostics__actions button, + .cwl-writing-diagnostics__navigation-button { + transition: none; + } +} + +@media (max-width: 560px) { + .cwl-writing-diagnostics__actions button, + .cwl-writing-diagnostics__navigation-button { + flex: 1 1 auto; + } +} + @media print { .cwl-editor { --cwl-fg: #000000; @@ -269,6 +463,7 @@ --cwl-surface: #ffffff; --cwl-accent: #000000; --cwl-accent-soft: #ffffff; + --cwl-critical: #000000; overflow: visible; border: 0; @@ -326,4 +521,30 @@ .cwl-editor__surface:has(.collaboration-cursor__caret) .cwl-editor__content { padding-top: 0; } -} + + .cwl-writing-diagnostics { + display: none !important; + } + + .cwl-writing-diagnostics[data-print-enabled='true'] { + display: block !important; + border: 1px solid #000000; + margin-top: 1rem; + padding: 0.5rem; + } + + .cwl-writing-diagnostics__actions, + .cwl-writing-diagnostics__navigation { + display: none !important; + } + + .cwl-writing-diagnostics__status, + .cwl-writing-diagnostics__conflict { + display: none !important; + } + + .cwl-writing-diagnostics__item { + break-inside: avoid; + border-color: #000000; + } +} \ No newline at end of file