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 (
+ - 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}
+
+
+
+
+
+
+
+
+
+ );
+ })}
+
+
+
+ {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