From 5dbddeb2d176c62bf2d99110225d67e764ebf44b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:38:26 +0900 Subject: [PATCH 01/42] test(diagnostics): define accessible writing guidance UI --- .../WritingDiagnosticsPanel.test.tsx | 288 ++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 src/components/WritingDiagnosticsPanel.test.tsx 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.', + ); + }); +}); From f4871b946e33b79fc67c21e932fd2d48619fec94 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:52:16 +0900 Subject: [PATCH 02/42] ci(diagnostics): run accessible UI TDD contract --- .../writing-diagnostics-controller-tdd.yml | 107 ++---------------- 1 file changed, 7 insertions(+), 100 deletions(-) diff --git a/.github/workflows/writing-diagnostics-controller-tdd.yml b/.github/workflows/writing-diagnostics-controller-tdd.yml index b2b839d0..dc3cde4b 100644 --- a/.github/workflows/writing-diagnostics-controller-tdd.yml +++ b/.github/workflows/writing-diagnostics-controller-tdd.yml @@ -1,23 +1,23 @@ -name: Writing Diagnostics Controller TDD +name: Writing Diagnostics UI TDD on: push: branches: - - feat/writing-diagnostics-controller + - feat/writing-diagnostics-ui workflow_dispatch: permissions: contents: read concurrency: - group: writing-diagnostics-controller-tdd-${{ github.ref }} + group: writing-diagnostics-ui-tdd-${{ github.ref }} cancel-in-progress: true env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: - focused-controller: + focused-ui: runs-on: ubuntu-24.04 timeout-minutes: 25 steps: @@ -31,106 +31,13 @@ jobs: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - - name: Run revision-bound controller contract tests + - name: Run accessible writing guidance contract tests run: >- pnpm exec vitest run - src/components/useWritingDiagnosticsController.test.tsx - src/components/useWritingDiagnosticsController.actions.test.tsx - src/components/useWritingDiagnosticsController.boundary.test.tsx - src/components/useWritingDiagnosticsController.coverage.test.tsx + src/components/WritingDiagnosticsPanel.test.tsx --pool=forks --maxWorkers=1 - - name: Prove complete owned production coverage - shell: bash - run: | - set +e - pnpm exec vitest run \ - src/components/useWritingDiagnosticsController.test.tsx \ - src/components/useWritingDiagnosticsController.actions.test.tsx \ - src/components/useWritingDiagnosticsController.boundary.test.tsx \ - src/components/useWritingDiagnosticsController.coverage.test.tsx \ - --pool=forks \ - --maxWorkers=1 \ - --coverage \ - --coverage.include=src/components/useWritingDiagnosticsController.ts \ - --coverage.reporter=json - status=$? - node --input-type=module <<'NODE' - import fs from 'node:fs'; - - const sourcePath = 'src/components/useWritingDiagnosticsController.ts'; - if (!fs.existsSync('coverage/coverage-final.json')) { - console.log( - `::error file=${sourcePath},line=1::Controller coverage report was not produced.`, - ); - process.exit(0); - } - const report = JSON.parse( - fs.readFileSync('coverage/coverage-final.json', 'utf8'), - ); - const entry = Object.entries(report).find(([path]) => - path.endsWith(`/${sourcePath}`), - ); - if (!entry) { - console.log( - `::error file=${sourcePath},line=1::Controller coverage entry is missing.`, - ); - process.exit(0); - } - - const [, file] = entry; - const statementEntries = Object.entries(file.s); - const functionEntries = Object.entries(file.f); - const branchEntries = Object.entries(file.b); - const statementCovered = statementEntries.filter(([, count]) => count > 0).length; - const functionCovered = functionEntries.filter(([, count]) => count > 0).length; - const branchCounts = branchEntries.flatMap(([, counts]) => counts); - const branchCovered = branchCounts.filter((count) => count > 0).length; - console.log( - `::notice file=${sourcePath},line=1::Statements ${statementCovered}/${statementEntries.length}; ` + - `functions ${functionCovered}/${functionEntries.length}; branches ${branchCovered}/${branchCounts.length}.`, - ); - - const missingStatements = new Set(); - for (const [id, count] of statementEntries) { - if (count === 0) { - missingStatements.add(file.statementMap[id].start.line); - } - } - for (const line of [...missingStatements].sort((left, right) => left - right)) { - console.log( - `::error file=${sourcePath},line=${line}::Controller statement is not covered.`, - ); - } - - const missingFunctions = new Set(); - for (const [id, count] of functionEntries) { - if (count === 0) { - const definition = file.fnMap[id]; - missingFunctions.add( - definition.decl?.start.line ?? definition.loc.start.line, - ); - } - } - for (const line of [...missingFunctions].sort((left, right) => left - right)) { - console.log( - `::error file=${sourcePath},line=${line}::Controller function is not covered.`, - ); - } - - for (const [id, counts] of branchEntries) { - const branch = file.branchMap[id]; - counts.forEach((count, index) => { - if (count !== 0) return; - const location = branch.locations?.[index] ?? branch.loc; - console.log( - `::error file=${sourcePath},line=${location.start.line}::Controller branch ${index} is not covered.`, - ); - }); - } - NODE - exit "$status" - - name: Typecheck controller and public action contracts + - name: Typecheck public UI contracts run: pnpm typecheck - name: Build every package entrypoint run: pnpm build From a77e6006c9c7b00bf10bc444458d3f93e76f23d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:52:44 +0900 Subject: [PATCH 03/42] ci(diagnostics): expose writing guidance UI TDD state --- .../workflows/writing-diagnostics-ui-tdd.yml | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/writing-diagnostics-ui-tdd.yml diff --git a/.github/workflows/writing-diagnostics-ui-tdd.yml b/.github/workflows/writing-diagnostics-ui-tdd.yml new file mode 100644 index 00000000..1b287b27 --- /dev/null +++ b/.github/workflows/writing-diagnostics-ui-tdd.yml @@ -0,0 +1,37 @@ +name: Writing Diagnostics UI TDD + +on: + push: + branches: + - feat/writing-diagnostics-ui + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: writing-diagnostics-ui-tdd-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + focused-ui: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Run accessible writing-guidance contract tests + run: pnpm exec vitest run src/components/WritingDiagnosticsPanel.test.tsx + - name: Typecheck writing-guidance contracts + run: pnpm typecheck From 0dddfae27b7582e3a4fcf1f2a41d56745b1dc229 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:54:39 +0900 Subject: [PATCH 04/42] feat(diagnostics): render accessible writing guidance panel --- src/components/WritingDiagnosticsPanel.tsx | 200 +++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 src/components/WritingDiagnosticsPanel.tsx diff --git a/src/components/WritingDiagnosticsPanel.tsx b/src/components/WritingDiagnosticsPanel.tsx new file mode 100644 index 00000000..de5d7178 --- /dev/null +++ b/src/components/WritingDiagnosticsPanel.tsx @@ -0,0 +1,200 @@ +import { useRef, useState } 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; +} + +/** + * 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, +}: WritingDiagnosticsPanelProps) { + const diagnostics = controller.diagnostics; + const [activeDiagnosticId, setActiveDiagnosticId] = useState( + null, + ); + const [statusMessage, setStatusMessage] = useState(''); + const itemRefs = useRef>([]); + const selectedIndex = diagnostics.findIndex( + (candidate) => + candidate.diagnostic.diagnosticId === activeDiagnosticId, + ); + const activeIndex = selectedIndex < 0 ? 0 : selectedIndex; + + const navigate = (offset: number): void => { + if (diagnostics.length === 0) return; + const targetIndex = + (activeIndex + offset + diagnostics.length) % diagnostics.length; + const target = diagnostics[targetIndex]!; + const diagnosticId = target.diagnostic.diagnosticId; + setActiveDiagnosticId(diagnosticId); + controller.focusDiagnostic(diagnosticId); + itemRefs.current[targetIndex]?.focus(); + }; + + return ( +
+
+

+ {diagnostics.length} writing diagnostics +

+
+ + +
+
+ +
    + {diagnostics.map((verified, index) => { + const diagnostic = verified.diagnostic; + const hasReplacement = + diagnostic.suggestedReplacement !== undefined; + return ( +
  1. { + 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; From ab008c61c01c6514eb52ce76211febf6da3a77cc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:57:26 +0900 Subject: [PATCH 05/42] test(diagnostics): require trusted EditorFrame guidance slot --- src/components/EditorFrame.test.tsx | 40 +++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/components/EditorFrame.test.tsx diff --git a/src/components/EditorFrame.test.tsx b/src/components/EditorFrame.test.tsx new file mode 100644 index 00000000..8c6571cb --- /dev/null +++ b/src/components/EditorFrame.test.tsx @@ -0,0 +1,40 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { EditorFrame } from './EditorFrame.js'; + +afterEach(cleanup); + +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(); + }); +}); From 26f4a69195250d52e3b67f2b8a57465fb24c3f33 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:57:38 +0900 Subject: [PATCH 06/42] test(diagnostics): require explicit print appendix opt-in --- .../WritingDiagnosticsPanel.print.test.tsx | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/components/WritingDiagnosticsPanel.print.test.tsx 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'); + }); +}); From 8faebd304435b84d04014234506720ecac264b6b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:58:30 +0900 Subject: [PATCH 07/42] test(diagnostics): require accessible and print-safe guidance styles --- src/printStyles.test.ts | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/src/printStyles.test.ts b/src/printStyles.test.ts index 632a033b..8d08d4db 100644 --- a/src/printStyles.test.ts +++ b/src/printStyles.test.ts @@ -64,4 +64,43 @@ 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('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, + ); + }); +}); From 1f45ad11c5ac7ecff77da92f4230145575d04463 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 19:58:57 +0900 Subject: [PATCH 08/42] ci(diagnostics): exercise complete guidance UI contract --- .github/workflows/writing-diagnostics-ui-tdd.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/writing-diagnostics-ui-tdd.yml b/.github/workflows/writing-diagnostics-ui-tdd.yml index 1b287b27..536c2f36 100644 --- a/.github/workflows/writing-diagnostics-ui-tdd.yml +++ b/.github/workflows/writing-diagnostics-ui-tdd.yml @@ -32,6 +32,11 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - name: Run accessible writing-guidance contract tests - run: pnpm exec vitest run src/components/WritingDiagnosticsPanel.test.tsx + run: >- + pnpm exec vitest run + src/components/WritingDiagnosticsPanel.test.tsx + src/components/WritingDiagnosticsPanel.print.test.tsx + src/components/EditorFrame.test.tsx + src/printStyles.test.ts - name: Typecheck writing-guidance contracts run: pnpm typecheck From bbf2a0aa014bc1c8298222bbb43434f8622fa4a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:01:10 +0900 Subject: [PATCH 09/42] feat(diagnostics): add trusted writing guidance slot --- src/components/EditorFrame.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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}
From 20004818a5a26fab3ae2209d4f93457a759fdcf1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:01:58 +0900 Subject: [PATCH 10/42] test(diagnostics): require keyboard roving navigation --- .../WritingDiagnosticsPanel.keyboard.test.tsx | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 src/components/WritingDiagnosticsPanel.keyboard.test.tsx diff --git a/src/components/WritingDiagnosticsPanel.keyboard.test.tsx b/src/components/WritingDiagnosticsPanel.keyboard.test.tsx new file mode 100644 index 00000000..a7ec1868 --- /dev/null +++ b/src/components/WritingDiagnosticsPanel.keyboard.test.tsx @@ -0,0 +1,106 @@ +import { 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'); + 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', + }); + focusButton.focus(); + fireEvent.keyDown(focusButton, { key: 'ArrowDown' }); + expect(focusButton).toHaveFocus(); + }); +}); From 871364fc53c3c6d58cc2afecb908f810c6111cc3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:02:26 +0900 Subject: [PATCH 11/42] feat(diagnostics): gate printed guidance explicitly --- src/components/WritingDiagnosticsPanel.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/WritingDiagnosticsPanel.tsx b/src/components/WritingDiagnosticsPanel.tsx index de5d7178..5ebcd586 100644 --- a/src/components/WritingDiagnosticsPanel.tsx +++ b/src/components/WritingDiagnosticsPanel.tsx @@ -11,6 +11,8 @@ export interface WritingDiagnosticsPanelProps { 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; } /** @@ -24,6 +26,7 @@ export function WritingDiagnosticsPanel({ label, onApplyDiagnostic, conflictMessage, + printEnabled = false, }: WritingDiagnosticsPanelProps) { const diagnostics = controller.diagnostics; const [activeDiagnosticId, setActiveDiagnosticId] = useState( @@ -52,6 +55,7 @@ export function WritingDiagnosticsPanel({
From d2723f997bf6ea4d5e632ae43ff25aebc2eaf573 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:02:30 +0900 Subject: [PATCH 12/42] ci(diagnostics): exercise keyboard guidance contract --- .github/workflows/writing-diagnostics-ui-tdd.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/writing-diagnostics-ui-tdd.yml b/.github/workflows/writing-diagnostics-ui-tdd.yml index 536c2f36..31bcd4b9 100644 --- a/.github/workflows/writing-diagnostics-ui-tdd.yml +++ b/.github/workflows/writing-diagnostics-ui-tdd.yml @@ -35,8 +35,11 @@ jobs: run: >- pnpm exec vitest run src/components/WritingDiagnosticsPanel.test.tsx + src/components/WritingDiagnosticsPanel.keyboard.test.tsx src/components/WritingDiagnosticsPanel.print.test.tsx src/components/EditorFrame.test.tsx src/printStyles.test.ts + --pool=forks + --maxWorkers=1 - name: Typecheck writing-guidance contracts run: pnpm typecheck From 19e582f996f677d5b6affbccc1c255ab8f5b026a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:04:08 +0900 Subject: [PATCH 13/42] feat(diagnostics): add card keyboard navigation --- src/components/WritingDiagnosticsPanel.tsx | 36 ++++++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/src/components/WritingDiagnosticsPanel.tsx b/src/components/WritingDiagnosticsPanel.tsx index 5ebcd586..2bd1ebe1 100644 --- a/src/components/WritingDiagnosticsPanel.tsx +++ b/src/components/WritingDiagnosticsPanel.tsx @@ -1,4 +1,8 @@ -import { useRef, useState } from 'react'; +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. */ @@ -40,10 +44,10 @@ export function WritingDiagnosticsPanel({ ); const activeIndex = selectedIndex < 0 ? 0 : selectedIndex; - const navigate = (offset: number): void => { + const focusIndex = (requestedIndex: number): void => { if (diagnostics.length === 0) return; const targetIndex = - (activeIndex + offset + diagnostics.length) % diagnostics.length; + (requestedIndex + diagnostics.length) % diagnostics.length; const target = diagnostics[targetIndex]!; const diagnosticId = target.diagnostic.diagnosticId; setActiveDiagnosticId(diagnosticId); @@ -51,6 +55,30 @@ export function WritingDiagnosticsPanel({ 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 (
setActiveDiagnosticId(diagnostic.diagnosticId)} + onKeyDown={(event) => onItemKeyDown(event, index)} ref={(element) => { itemRefs.current[index] = element; }} From b6c1489341f35e409aacf947d79c31a245b39b02 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:04:28 +0900 Subject: [PATCH 14/42] feat(diagnostics): style accessible writing guidance --- src/styles.css | 221 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 220 insertions(+), 1 deletion(-) diff --git a/src/styles.css b/src/styles.css index 0970a931..3b1efbd9 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,23 @@ } @media (forced-colors: active) { - .cwl-tb-btn:focus-visible { + .cwl-tb-btn: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 +276,182 @@ 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__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 +461,7 @@ --cwl-surface: #ffffff; --cwl-accent: #000000; --cwl-accent-soft: #ffffff; + --cwl-critical: #000000; overflow: visible; border: 0; @@ -326,4 +519,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; + } } From 4fdf367098082856fbebff5ef027bc7058f458ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:08:01 +0900 Subject: [PATCH 15/42] ci(diagnostics): enforce exact writing guidance coverage --- .../workflows/writing-diagnostics-ui-tdd.yml | 80 ++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/.github/workflows/writing-diagnostics-ui-tdd.yml b/.github/workflows/writing-diagnostics-ui-tdd.yml index 31bcd4b9..55a61aad 100644 --- a/.github/workflows/writing-diagnostics-ui-tdd.yml +++ b/.github/workflows/writing-diagnostics-ui-tdd.yml @@ -19,7 +19,7 @@ env: jobs: focused-ui: runs-on: ubuntu-24.04 - timeout-minutes: 15 + timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -31,7 +31,9 @@ jobs: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - - name: Run accessible writing-guidance contract tests + - name: Collect accessible writing-guidance coverage + id: focused_coverage + continue-on-error: true run: >- pnpm exec vitest run src/components/WritingDiagnosticsPanel.test.tsx @@ -41,5 +43,79 @@ jobs: src/printStyles.test.ts --pool=forks --maxWorkers=1 + --coverage + --coverage.include=src/components/WritingDiagnosticsPanel.tsx + --coverage.include=src/components/EditorFrame.tsx + --coverage.reporter=text + --coverage.reporter=json + - name: Report and enforce exact UI coverage + run: | + node <<'NODE' + const { existsSync, readFileSync } = require('node:fs'); + if (!existsSync('coverage/coverage-final.json')) { + console.error('::error file=coverage/coverage-final.json,line=1::Focused coverage report is missing.'); + process.exit(1); + } + const coverage = JSON.parse(readFileSync('coverage/coverage-final.json', 'utf8')); + const requiredSuffixes = [ + '/src/components/WritingDiagnosticsPanel.tsx', + '/src/components/EditorFrame.tsx', + ]; + let failed = process.env.FOCUSED_COVERAGE_OUTCOME !== 'success'; + + for (const suffix of requiredSuffixes) { + const entry = Object.entries(coverage).find(([path]) => path.endsWith(suffix)); + const displayPath = suffix.slice(1); + if (!entry) { + console.error(`::error file=${displayPath},line=1::Focused coverage record is missing.`); + failed = true; + continue; + } + const [, fileCoverage] = entry; + const missingStatementLines = [...new Set( + Object.entries(fileCoverage.s) + .filter(([, count]) => count === 0) + .map(([id]) => fileCoverage.statementMap[id].start.line), + )].sort((a, b) => a - b); + const missingFunctionLines = [...new Set( + Object.entries(fileCoverage.f) + .filter(([, count]) => count === 0) + .map(([id]) => fileCoverage.fnMap[id].decl.start.line), + )].sort((a, b) => a - b); + const missingBranches = []; + for (const [id, counts] of Object.entries(fileCoverage.b)) { + counts.forEach((count, index) => { + if (count === 0) { + const branch = fileCoverage.branchMap[id]; + const location = branch.locations?.[index] ?? branch.loc; + missingBranches.push(`${location.start.line}:${index}`); + } + }); + } + const statementTotal = Object.keys(fileCoverage.s).length; + const statementCovered = Object.values(fileCoverage.s).filter((count) => count > 0).length; + const functionTotal = Object.keys(fileCoverage.f).length; + const functionCovered = Object.values(fileCoverage.f).filter((count) => count > 0).length; + const branchCounts = Object.values(fileCoverage.b).flat(); + const branchTotal = branchCounts.length; + const branchCovered = branchCounts.filter((count) => count > 0).length; + console.log( + `::notice file=${displayPath},line=1::Statements ${statementCovered}/${statementTotal}; ` + + `functions ${functionCovered}/${functionTotal}; branches ${branchCovered}/${branchTotal}.`, + ); + if (missingStatementLines.length || missingFunctionLines.length || missingBranches.length) { + console.error( + `::error file=${displayPath},line=1::` + + `Missing statement lines: ${missingStatementLines.join(', ') || 'none'}; ` + + `missing function lines: ${missingFunctionLines.join(', ') || 'none'}; ` + + `missing branches line:index: ${missingBranches.join(', ') || 'none'}.`, + ); + failed = true; + } + } + if (failed) process.exit(1); + NODE + env: + FOCUSED_COVERAGE_OUTCOME: ${{ steps.focused_coverage.outcome }} - name: Typecheck writing-guidance contracts run: pnpm typecheck From ae92ce64867786a023989e5faa7618189aa11d11 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:10:09 +0900 Subject: [PATCH 16/42] ci(diagnostics): run complete UI acceptance gate --- .../workflows/writing-diagnostics-ui-tdd.yml | 86 ++----------------- 1 file changed, 8 insertions(+), 78 deletions(-) diff --git a/.github/workflows/writing-diagnostics-ui-tdd.yml b/.github/workflows/writing-diagnostics-ui-tdd.yml index 55a61aad..4a71b167 100644 --- a/.github/workflows/writing-diagnostics-ui-tdd.yml +++ b/.github/workflows/writing-diagnostics-ui-tdd.yml @@ -19,7 +19,7 @@ env: jobs: focused-ui: runs-on: ubuntu-24.04 - timeout-minutes: 20 + timeout-minutes: 25 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -31,9 +31,7 @@ jobs: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - - name: Collect accessible writing-guidance coverage - id: focused_coverage - continue-on-error: true + - name: Run accessible writing-guidance contract tests run: >- pnpm exec vitest run src/components/WritingDiagnosticsPanel.test.tsx @@ -43,79 +41,11 @@ jobs: src/printStyles.test.ts --pool=forks --maxWorkers=1 - --coverage - --coverage.include=src/components/WritingDiagnosticsPanel.tsx - --coverage.include=src/components/EditorFrame.tsx - --coverage.reporter=text - --coverage.reporter=json - - name: Report and enforce exact UI coverage - run: | - node <<'NODE' - const { existsSync, readFileSync } = require('node:fs'); - if (!existsSync('coverage/coverage-final.json')) { - console.error('::error file=coverage/coverage-final.json,line=1::Focused coverage report is missing.'); - process.exit(1); - } - const coverage = JSON.parse(readFileSync('coverage/coverage-final.json', 'utf8')); - const requiredSuffixes = [ - '/src/components/WritingDiagnosticsPanel.tsx', - '/src/components/EditorFrame.tsx', - ]; - let failed = process.env.FOCUSED_COVERAGE_OUTCOME !== 'success'; - - for (const suffix of requiredSuffixes) { - const entry = Object.entries(coverage).find(([path]) => path.endsWith(suffix)); - const displayPath = suffix.slice(1); - if (!entry) { - console.error(`::error file=${displayPath},line=1::Focused coverage record is missing.`); - failed = true; - continue; - } - const [, fileCoverage] = entry; - const missingStatementLines = [...new Set( - Object.entries(fileCoverage.s) - .filter(([, count]) => count === 0) - .map(([id]) => fileCoverage.statementMap[id].start.line), - )].sort((a, b) => a - b); - const missingFunctionLines = [...new Set( - Object.entries(fileCoverage.f) - .filter(([, count]) => count === 0) - .map(([id]) => fileCoverage.fnMap[id].decl.start.line), - )].sort((a, b) => a - b); - const missingBranches = []; - for (const [id, counts] of Object.entries(fileCoverage.b)) { - counts.forEach((count, index) => { - if (count === 0) { - const branch = fileCoverage.branchMap[id]; - const location = branch.locations?.[index] ?? branch.loc; - missingBranches.push(`${location.start.line}:${index}`); - } - }); - } - const statementTotal = Object.keys(fileCoverage.s).length; - const statementCovered = Object.values(fileCoverage.s).filter((count) => count > 0).length; - const functionTotal = Object.keys(fileCoverage.f).length; - const functionCovered = Object.values(fileCoverage.f).filter((count) => count > 0).length; - const branchCounts = Object.values(fileCoverage.b).flat(); - const branchTotal = branchCounts.length; - const branchCovered = branchCounts.filter((count) => count > 0).length; - console.log( - `::notice file=${displayPath},line=1::Statements ${statementCovered}/${statementTotal}; ` + - `functions ${functionCovered}/${functionTotal}; branches ${branchCovered}/${branchTotal}.`, - ); - if (missingStatementLines.length || missingFunctionLines.length || missingBranches.length) { - console.error( - `::error file=${displayPath},line=1::` + - `Missing statement lines: ${missingStatementLines.join(', ') || 'none'}; ` + - `missing function lines: ${missingFunctionLines.join(', ') || 'none'}; ` + - `missing branches line:index: ${missingBranches.join(', ') || 'none'}.`, - ); - failed = true; - } - } - if (failed) process.exit(1); - NODE - env: - FOCUSED_COVERAGE_OUTCOME: ${{ steps.focused_coverage.outcome }} - name: Typecheck writing-guidance contracts run: pnpm typecheck + - name: Run complete production coverage gate + run: pnpm coverage + - name: Build all package entrypoints + run: pnpm build + - name: Build demonstration application + run: pnpm build:demo From e3c2a952bef77057117474f5baa58e0af5fc97fe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:10:53 +0900 Subject: [PATCH 17/42] test(diagnostics): contain keyboard focus updates --- .../WritingDiagnosticsPanel.keyboard.test.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/components/WritingDiagnosticsPanel.keyboard.test.tsx b/src/components/WritingDiagnosticsPanel.keyboard.test.tsx index a7ec1868..b7ecd333 100644 --- a/src/components/WritingDiagnosticsPanel.keyboard.test.tsx +++ b/src/components/WritingDiagnosticsPanel.keyboard.test.tsx @@ -1,4 +1,10 @@ -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { CwlVerifiedWritingDiagnostic, @@ -79,7 +85,9 @@ describe('WritingDiagnosticsPanel keyboard navigation', () => { ); const items = screen.getAllByRole('listitem'); - items[0]!.focus(); + act(() => { + items[0]!.focus(); + }); fireEvent.keyDown(items[0]!, { key: 'ArrowDown' }); expect(items[1]).toHaveFocus(); expect(activeController.focusDiagnostic).toHaveBeenLastCalledWith('second'); @@ -99,7 +107,9 @@ describe('WritingDiagnosticsPanel keyboard navigation', () => { const focusButton = screen.getByRole('button', { name: 'Focus affected text for First', }); - focusButton.focus(); + act(() => { + focusButton.focus(); + }); fireEvent.keyDown(focusButton, { key: 'ArrowDown' }); expect(focusButton).toHaveFocus(); }); From b96b5fbd9577cc96f3218415c7fc59b10158dd62 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:13:05 +0900 Subject: [PATCH 18/42] ci(diagnostics): prove exact guidance acceptance --- .../workflows/writing-diagnostics-ui-tdd.yml | 85 ++++++++++++++++++- 1 file changed, 81 insertions(+), 4 deletions(-) diff --git a/.github/workflows/writing-diagnostics-ui-tdd.yml b/.github/workflows/writing-diagnostics-ui-tdd.yml index 4a71b167..56c83d0b 100644 --- a/.github/workflows/writing-diagnostics-ui-tdd.yml +++ b/.github/workflows/writing-diagnostics-ui-tdd.yml @@ -19,7 +19,7 @@ env: jobs: focused-ui: runs-on: ubuntu-24.04 - timeout-minutes: 25 + timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -31,7 +31,9 @@ jobs: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - - name: Run accessible writing-guidance contract tests + - name: Collect accessible writing-guidance coverage + id: focused_coverage + continue-on-error: true run: >- pnpm exec vitest run src/components/WritingDiagnosticsPanel.test.tsx @@ -41,11 +43,86 @@ jobs: src/printStyles.test.ts --pool=forks --maxWorkers=1 + --coverage + --coverage.include=src/components/WritingDiagnosticsPanel.tsx + --coverage.include=src/components/EditorFrame.tsx + --coverage.reporter=text + --coverage.reporter=json + - name: Report and enforce exact UI coverage + run: | + node <<'NODE' + const { existsSync, readFileSync } = require('node:fs'); + if (!existsSync('coverage/coverage-final.json')) { + console.error('::error file=coverage/coverage-final.json,line=1::Focused coverage report is missing.'); + process.exit(1); + } + const coverage = JSON.parse(readFileSync('coverage/coverage-final.json', 'utf8')); + const requiredSuffixes = [ + '/src/components/WritingDiagnosticsPanel.tsx', + '/src/components/EditorFrame.tsx', + ]; + let failed = process.env.FOCUSED_COVERAGE_OUTCOME !== 'success'; + + for (const suffix of requiredSuffixes) { + const entry = Object.entries(coverage).find(([path]) => path.endsWith(suffix)); + const displayPath = suffix.slice(1); + if (!entry) { + console.error(`::error file=${displayPath},line=1::Focused coverage record is missing.`); + failed = true; + continue; + } + const [, fileCoverage] = entry; + const missingStatementLines = [...new Set( + Object.entries(fileCoverage.s) + .filter(([, count]) => count === 0) + .map(([id]) => fileCoverage.statementMap[id].start.line), + )].sort((left, right) => left - right); + const missingFunctionLines = [...new Set( + Object.entries(fileCoverage.f) + .filter(([, count]) => count === 0) + .map(([id]) => fileCoverage.fnMap[id].decl.start.line), + )].sort((left, right) => left - right); + const missingBranches = []; + for (const [id, counts] of Object.entries(fileCoverage.b)) { + counts.forEach((count, index) => { + if (count !== 0) return; + const branch = fileCoverage.branchMap[id]; + const location = branch.locations?.[index] ?? branch.loc; + missingBranches.push(`${location.start.line}:${index}`); + }); + } + const statementTotal = Object.keys(fileCoverage.s).length; + const statementCovered = Object.values(fileCoverage.s).filter((count) => count > 0).length; + const functionTotal = Object.keys(fileCoverage.f).length; + const functionCovered = Object.values(fileCoverage.f).filter((count) => count > 0).length; + const branchCounts = Object.values(fileCoverage.b).flat(); + const branchTotal = branchCounts.length; + const branchCovered = branchCounts.filter((count) => count > 0).length; + console.log( + `::notice file=${displayPath},line=1::Statements ${statementCovered}/${statementTotal}; ` + + `functions ${functionCovered}/${functionTotal}; branches ${branchCovered}/${branchTotal}.`, + ); + if (missingStatementLines.length || missingFunctionLines.length || missingBranches.length) { + console.error( + `::error file=${displayPath},line=1::` + + `Missing statement lines: ${missingStatementLines.join(', ') || 'none'}; ` + + `missing function lines: ${missingFunctionLines.join(', ') || 'none'}; ` + + `missing branches line:index: ${missingBranches.join(', ') || 'none'}.`, + ); + failed = true; + } + } + if (failed) process.exit(1); + NODE + env: + FOCUSED_COVERAGE_OUTCOME: ${{ steps.focused_coverage.outcome }} - name: Typecheck writing-guidance contracts run: pnpm typecheck - name: Run complete production coverage gate run: pnpm coverage - - name: Build all package entrypoints + - name: Build every package entrypoint run: pnpm build - - name: Build demonstration application + - name: Verify packed-package consumers + run: pnpm verify:package + - name: Build the demonstration application run: pnpm build:demo From 51155faae7e7738e9802c9c1996bc8ac04e1bb55 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:13:44 +0900 Subject: [PATCH 19/42] fix(ci): restore controller workflow branch isolation --- .../writing-diagnostics-controller-tdd.yml | 107 ++++++++++++++++-- 1 file changed, 100 insertions(+), 7 deletions(-) diff --git a/.github/workflows/writing-diagnostics-controller-tdd.yml b/.github/workflows/writing-diagnostics-controller-tdd.yml index dc3cde4b..b2b839d0 100644 --- a/.github/workflows/writing-diagnostics-controller-tdd.yml +++ b/.github/workflows/writing-diagnostics-controller-tdd.yml @@ -1,23 +1,23 @@ -name: Writing Diagnostics UI TDD +name: Writing Diagnostics Controller TDD on: push: branches: - - feat/writing-diagnostics-ui + - feat/writing-diagnostics-controller workflow_dispatch: permissions: contents: read concurrency: - group: writing-diagnostics-ui-tdd-${{ github.ref }} + group: writing-diagnostics-controller-tdd-${{ github.ref }} cancel-in-progress: true env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: - focused-ui: + focused-controller: runs-on: ubuntu-24.04 timeout-minutes: 25 steps: @@ -31,13 +31,106 @@ jobs: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - - name: Run accessible writing guidance contract tests + - name: Run revision-bound controller contract tests run: >- pnpm exec vitest run - src/components/WritingDiagnosticsPanel.test.tsx + src/components/useWritingDiagnosticsController.test.tsx + src/components/useWritingDiagnosticsController.actions.test.tsx + src/components/useWritingDiagnosticsController.boundary.test.tsx + src/components/useWritingDiagnosticsController.coverage.test.tsx --pool=forks --maxWorkers=1 - - name: Typecheck public UI contracts + - name: Prove complete owned production coverage + shell: bash + run: | + set +e + pnpm exec vitest run \ + src/components/useWritingDiagnosticsController.test.tsx \ + src/components/useWritingDiagnosticsController.actions.test.tsx \ + src/components/useWritingDiagnosticsController.boundary.test.tsx \ + src/components/useWritingDiagnosticsController.coverage.test.tsx \ + --pool=forks \ + --maxWorkers=1 \ + --coverage \ + --coverage.include=src/components/useWritingDiagnosticsController.ts \ + --coverage.reporter=json + status=$? + node --input-type=module <<'NODE' + import fs from 'node:fs'; + + const sourcePath = 'src/components/useWritingDiagnosticsController.ts'; + if (!fs.existsSync('coverage/coverage-final.json')) { + console.log( + `::error file=${sourcePath},line=1::Controller coverage report was not produced.`, + ); + process.exit(0); + } + const report = JSON.parse( + fs.readFileSync('coverage/coverage-final.json', 'utf8'), + ); + const entry = Object.entries(report).find(([path]) => + path.endsWith(`/${sourcePath}`), + ); + if (!entry) { + console.log( + `::error file=${sourcePath},line=1::Controller coverage entry is missing.`, + ); + process.exit(0); + } + + const [, file] = entry; + const statementEntries = Object.entries(file.s); + const functionEntries = Object.entries(file.f); + const branchEntries = Object.entries(file.b); + const statementCovered = statementEntries.filter(([, count]) => count > 0).length; + const functionCovered = functionEntries.filter(([, count]) => count > 0).length; + const branchCounts = branchEntries.flatMap(([, counts]) => counts); + const branchCovered = branchCounts.filter((count) => count > 0).length; + console.log( + `::notice file=${sourcePath},line=1::Statements ${statementCovered}/${statementEntries.length}; ` + + `functions ${functionCovered}/${functionEntries.length}; branches ${branchCovered}/${branchCounts.length}.`, + ); + + const missingStatements = new Set(); + for (const [id, count] of statementEntries) { + if (count === 0) { + missingStatements.add(file.statementMap[id].start.line); + } + } + for (const line of [...missingStatements].sort((left, right) => left - right)) { + console.log( + `::error file=${sourcePath},line=${line}::Controller statement is not covered.`, + ); + } + + const missingFunctions = new Set(); + for (const [id, count] of functionEntries) { + if (count === 0) { + const definition = file.fnMap[id]; + missingFunctions.add( + definition.decl?.start.line ?? definition.loc.start.line, + ); + } + } + for (const line of [...missingFunctions].sort((left, right) => left - right)) { + console.log( + `::error file=${sourcePath},line=${line}::Controller function is not covered.`, + ); + } + + for (const [id, counts] of branchEntries) { + const branch = file.branchMap[id]; + counts.forEach((count, index) => { + if (count !== 0) return; + const location = branch.locations?.[index] ?? branch.loc; + console.log( + `::error file=${sourcePath},line=${location.start.line}::Controller branch ${index} is not covered.`, + ); + }); + } + NODE + exit "$status" + - name: Typecheck controller and public action contracts run: pnpm typecheck - name: Build every package entrypoint run: pnpm build From 982f651f12da337b400cc3c014fdff111783d996 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:16:36 +0900 Subject: [PATCH 20/42] refactor(diagnostics): remove unreachable empty navigation branch --- src/components/WritingDiagnosticsPanel.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/WritingDiagnosticsPanel.tsx b/src/components/WritingDiagnosticsPanel.tsx index 2bd1ebe1..6d5f9d19 100644 --- a/src/components/WritingDiagnosticsPanel.tsx +++ b/src/components/WritingDiagnosticsPanel.tsx @@ -45,7 +45,6 @@ export function WritingDiagnosticsPanel({ const activeIndex = selectedIndex < 0 ? 0 : selectedIndex; const focusIndex = (requestedIndex: number): void => { - if (diagnostics.length === 0) return; const targetIndex = (requestedIndex + diagnostics.length) % diagnostics.length; const target = diagnostics[targetIndex]!; From 61a9c42f583c37c298acbd5df0964b24b277c76c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:17:25 +0900 Subject: [PATCH 21/42] ci(diagnostics): scope exact coverage to owned panel --- .../workflows/writing-diagnostics-ui-tdd.yml | 98 +++++++++---------- 1 file changed, 45 insertions(+), 53 deletions(-) diff --git a/.github/workflows/writing-diagnostics-ui-tdd.yml b/.github/workflows/writing-diagnostics-ui-tdd.yml index 56c83d0b..7f24ac4a 100644 --- a/.github/workflows/writing-diagnostics-ui-tdd.yml +++ b/.github/workflows/writing-diagnostics-ui-tdd.yml @@ -45,7 +45,6 @@ jobs: --maxWorkers=1 --coverage --coverage.include=src/components/WritingDiagnosticsPanel.tsx - --coverage.include=src/components/EditorFrame.tsx --coverage.reporter=text --coverage.reporter=json - name: Report and enforce exact UI coverage @@ -57,60 +56,53 @@ jobs: process.exit(1); } const coverage = JSON.parse(readFileSync('coverage/coverage-final.json', 'utf8')); - const requiredSuffixes = [ - '/src/components/WritingDiagnosticsPanel.tsx', - '/src/components/EditorFrame.tsx', - ]; + const suffix = '/src/components/WritingDiagnosticsPanel.tsx'; + const displayPath = suffix.slice(1); + const entry = Object.entries(coverage).find(([path]) => path.endsWith(suffix)); let failed = process.env.FOCUSED_COVERAGE_OUTCOME !== 'success'; - - for (const suffix of requiredSuffixes) { - const entry = Object.entries(coverage).find(([path]) => path.endsWith(suffix)); - const displayPath = suffix.slice(1); - if (!entry) { - console.error(`::error file=${displayPath},line=1::Focused coverage record is missing.`); - failed = true; - continue; - } - const [, fileCoverage] = entry; - const missingStatementLines = [...new Set( - Object.entries(fileCoverage.s) - .filter(([, count]) => count === 0) - .map(([id]) => fileCoverage.statementMap[id].start.line), - )].sort((left, right) => left - right); - const missingFunctionLines = [...new Set( - Object.entries(fileCoverage.f) - .filter(([, count]) => count === 0) - .map(([id]) => fileCoverage.fnMap[id].decl.start.line), - )].sort((left, right) => left - right); - const missingBranches = []; - for (const [id, counts] of Object.entries(fileCoverage.b)) { - counts.forEach((count, index) => { - if (count !== 0) return; - const branch = fileCoverage.branchMap[id]; - const location = branch.locations?.[index] ?? branch.loc; - missingBranches.push(`${location.start.line}:${index}`); - }); - } - const statementTotal = Object.keys(fileCoverage.s).length; - const statementCovered = Object.values(fileCoverage.s).filter((count) => count > 0).length; - const functionTotal = Object.keys(fileCoverage.f).length; - const functionCovered = Object.values(fileCoverage.f).filter((count) => count > 0).length; - const branchCounts = Object.values(fileCoverage.b).flat(); - const branchTotal = branchCounts.length; - const branchCovered = branchCounts.filter((count) => count > 0).length; - console.log( - `::notice file=${displayPath},line=1::Statements ${statementCovered}/${statementTotal}; ` + - `functions ${functionCovered}/${functionTotal}; branches ${branchCovered}/${branchTotal}.`, + if (!entry) { + console.error(`::error file=${displayPath},line=1::Focused coverage record is missing.`); + process.exit(1); + } + const [, fileCoverage] = entry; + const missingStatementLines = [...new Set( + Object.entries(fileCoverage.s) + .filter(([, count]) => count === 0) + .map(([id]) => fileCoverage.statementMap[id].start.line), + )].sort((left, right) => left - right); + const missingFunctionLines = [...new Set( + Object.entries(fileCoverage.f) + .filter(([, count]) => count === 0) + .map(([id]) => fileCoverage.fnMap[id].decl.start.line), + )].sort((left, right) => left - right); + const missingBranches = []; + for (const [id, counts] of Object.entries(fileCoverage.b)) { + counts.forEach((count, index) => { + if (count !== 0) return; + const branch = fileCoverage.branchMap[id]; + const location = branch.locations?.[index] ?? branch.loc; + missingBranches.push(`${location.start.line}:${index}`); + }); + } + const statementTotal = Object.keys(fileCoverage.s).length; + const statementCovered = Object.values(fileCoverage.s).filter((count) => count > 0).length; + const functionTotal = Object.keys(fileCoverage.f).length; + const functionCovered = Object.values(fileCoverage.f).filter((count) => count > 0).length; + const branchCounts = Object.values(fileCoverage.b).flat(); + const branchTotal = branchCounts.length; + const branchCovered = branchCounts.filter((count) => count > 0).length; + console.log( + `::notice file=${displayPath},line=1::Statements ${statementCovered}/${statementTotal}; ` + + `functions ${functionCovered}/${functionTotal}; branches ${branchCovered}/${branchTotal}.`, + ); + if (missingStatementLines.length || missingFunctionLines.length || missingBranches.length) { + console.error( + `::error file=${displayPath},line=1::` + + `Missing statement lines: ${missingStatementLines.join(', ') || 'none'}; ` + + `missing function lines: ${missingFunctionLines.join(', ') || 'none'}; ` + + `missing branches line:index: ${missingBranches.join(', ') || 'none'}.`, ); - if (missingStatementLines.length || missingFunctionLines.length || missingBranches.length) { - console.error( - `::error file=${displayPath},line=1::` + - `Missing statement lines: ${missingStatementLines.join(', ') || 'none'}; ` + - `missing function lines: ${missingFunctionLines.join(', ') || 'none'}; ` + - `missing branches line:index: ${missingBranches.join(', ') || 'none'}.`, - ); - failed = true; - } + failed = true; } if (failed) process.exit(1); NODE From 1c39ffc823361ee6ce8567c175c81919c18c0129 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:18:00 +0900 Subject: [PATCH 22/42] test(diagnostics): cover editor frame guidance integration --- src/components/EditorFrame.test.tsx | 153 +++++++++++++++++++++++++++- 1 file changed, 150 insertions(+), 3 deletions(-) diff --git a/src/components/EditorFrame.test.tsx b/src/components/EditorFrame.test.tsx index 8c6571cb..dabe83b8 100644 --- a/src/components/EditorFrame.test.tsx +++ b/src/components/EditorFrame.test.tsx @@ -1,8 +1,42 @@ -import { cleanup, render, screen } from '@testing-library/react'; -import { afterEach, describe, expect, it } from 'vitest'; +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'; -afterEach(cleanup); +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', () => { @@ -37,4 +71,117 @@ describe('EditorFrame writing diagnostics slot', () => { 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://'); + }); }); From 1ec6ea6002b55f0c8fe6bc8b588d58dc0b2fcf23 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:19:33 +0900 Subject: [PATCH 23/42] refactor(diagnostics): remove unreachable focus fallback --- src/components/WritingDiagnosticsPanel.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/WritingDiagnosticsPanel.tsx b/src/components/WritingDiagnosticsPanel.tsx index 6d5f9d19..6b6fa23c 100644 --- a/src/components/WritingDiagnosticsPanel.tsx +++ b/src/components/WritingDiagnosticsPanel.tsx @@ -51,7 +51,8 @@ export function WritingDiagnosticsPanel({ const diagnosticId = target.diagnostic.diagnosticId; setActiveDiagnosticId(diagnosticId); controller.focusDiagnostic(diagnosticId); - itemRefs.current[targetIndex]?.focus(); + // Only mounted diagnostic cards and enabled navigation invoke this helper. + itemRefs.current[targetIndex]!.focus(); }; const navigate = (offset: number): void => { From 04c601e277bbd8b2672f8b975ae773e1e3a3d7b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:23:31 +0900 Subject: [PATCH 24/42] test(diagnostics): cover hostile reflection failures --- ...WritingDiagnosticsBoundaryCoverage.test.ts | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/extensions/WritingDiagnosticsBoundaryCoverage.test.ts diff --git a/src/extensions/WritingDiagnosticsBoundaryCoverage.test.ts b/src/extensions/WritingDiagnosticsBoundaryCoverage.test.ts new file mode 100644 index 00000000..2541024c --- /dev/null +++ b/src/extensions/WritingDiagnosticsBoundaryCoverage.test.ts @@ -0,0 +1,87 @@ +import { Schema } from '@tiptap/pm/model'; +import { EditorState } from '@tiptap/pm/state'; +import { describe, expect, it } from 'vitest'; +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: {}, + }, +}); + +function stateWithText(): EditorState { + return EditorState.create({ + schema, + doc: schema.node('doc', undefined, [ + schema.node('paragraph', undefined, [schema.text('Alpha beta gamma')]), + ]), + plugins: [createWritingDiagnosticsPlugin()], + }); +} + +function pluginState(state: EditorState): WritingDiagnosticsPluginState { + const result = writingDiagnosticsPluginKey.getState(state); + if (result === undefined) throw new Error('Missing writing diagnostics state'); + return result; +} + +function diagnostic(): CwlResolvedWritingDiagnosticDecoration { + return { + diagnosticId: 'diag-boundary', + from: 1, + to: 6, + priority: 'important', + }; +} + +function applyCandidate(state: EditorState, candidate: unknown): EditorState { + return state.apply( + state.tr.setMeta(writingDiagnosticsPluginKey, { + type: 'install', + generation: 1, + diagnostics: [candidate], + }), + ); +} + +describe('WritingDiagnostics reflection failure coverage', () => { + it('contains candidate prototype and key reflection failures', () => { + for (const candidate of [ + new Proxy(diagnostic(), { + getPrototypeOf() { + throw new Error('private prototype detail'); + }, + }), + new Proxy(diagnostic(), { + ownKeys() { + throw new Error('private key detail'); + }, + }), + ]) { + const state = stateWithText(); + const initial = pluginState(state); + expect(() => applyCandidate(state, candidate)).not.toThrow(); + expect(pluginState(applyCandidate(state, candidate))).toBe(initial); + } + }); + + it('contains candidate property-descriptor reflection failures', () => { + const state = stateWithText(); + const initial = pluginState(state); + const candidate = new Proxy(diagnostic(), { + getOwnPropertyDescriptor() { + throw new Error('private descriptor detail'); + }, + }); + + expect(() => applyCandidate(state, candidate)).not.toThrow(); + expect(pluginState(applyCandidate(state, candidate))).toBe(initial); + }); +}); From 3aecff8cecb3fd46663a239d8ac3e76cb1b3d483 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:24:36 +0900 Subject: [PATCH 25/42] test(diagnostics): cover hostile reflection failures --- .../WritingDiagnosticsReflection.test.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 src/extensions/WritingDiagnosticsReflection.test.ts diff --git a/src/extensions/WritingDiagnosticsReflection.test.ts b/src/extensions/WritingDiagnosticsReflection.test.ts new file mode 100644 index 00000000..ef152f45 --- /dev/null +++ b/src/extensions/WritingDiagnosticsReflection.test.ts @@ -0,0 +1,76 @@ +import { Schema } from '@tiptap/pm/model'; +import { EditorState } from '@tiptap/pm/state'; +import { describe, expect, it } from 'vitest'; +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()], + }); +} + +/** 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', + }; +} + +describe('WritingDiagnostics hostile reflection failures', () => { + it('rejects prototype 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 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, descriptorTrap]) { + expect(() => { + state = state.apply( + state.tr.setMeta(writingDiagnosticsPluginKey, { + type: 'install', + generation: 1, + diagnostics: [candidate], + }), + ); + }).not.toThrow(); + expect(pluginState(state)).toBe(initial); + } + }); +}); From 659c85cf56d74626e0b5430dbceda264eea02efc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:25:48 +0900 Subject: [PATCH 26/42] test(diagnostics): remove duplicate reflection coverage --- ...WritingDiagnosticsBoundaryCoverage.test.ts | 87 ------------------- 1 file changed, 87 deletions(-) delete mode 100644 src/extensions/WritingDiagnosticsBoundaryCoverage.test.ts diff --git a/src/extensions/WritingDiagnosticsBoundaryCoverage.test.ts b/src/extensions/WritingDiagnosticsBoundaryCoverage.test.ts deleted file mode 100644 index 2541024c..00000000 --- a/src/extensions/WritingDiagnosticsBoundaryCoverage.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Schema } from '@tiptap/pm/model'; -import { EditorState } from '@tiptap/pm/state'; -import { describe, expect, it } from 'vitest'; -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: {}, - }, -}); - -function stateWithText(): EditorState { - return EditorState.create({ - schema, - doc: schema.node('doc', undefined, [ - schema.node('paragraph', undefined, [schema.text('Alpha beta gamma')]), - ]), - plugins: [createWritingDiagnosticsPlugin()], - }); -} - -function pluginState(state: EditorState): WritingDiagnosticsPluginState { - const result = writingDiagnosticsPluginKey.getState(state); - if (result === undefined) throw new Error('Missing writing diagnostics state'); - return result; -} - -function diagnostic(): CwlResolvedWritingDiagnosticDecoration { - return { - diagnosticId: 'diag-boundary', - from: 1, - to: 6, - priority: 'important', - }; -} - -function applyCandidate(state: EditorState, candidate: unknown): EditorState { - return state.apply( - state.tr.setMeta(writingDiagnosticsPluginKey, { - type: 'install', - generation: 1, - diagnostics: [candidate], - }), - ); -} - -describe('WritingDiagnostics reflection failure coverage', () => { - it('contains candidate prototype and key reflection failures', () => { - for (const candidate of [ - new Proxy(diagnostic(), { - getPrototypeOf() { - throw new Error('private prototype detail'); - }, - }), - new Proxy(diagnostic(), { - ownKeys() { - throw new Error('private key detail'); - }, - }), - ]) { - const state = stateWithText(); - const initial = pluginState(state); - expect(() => applyCandidate(state, candidate)).not.toThrow(); - expect(pluginState(applyCandidate(state, candidate))).toBe(initial); - } - }); - - it('contains candidate property-descriptor reflection failures', () => { - const state = stateWithText(); - const initial = pluginState(state); - const candidate = new Proxy(diagnostic(), { - getOwnPropertyDescriptor() { - throw new Error('private descriptor detail'); - }, - }); - - expect(() => applyCandidate(state, candidate)).not.toThrow(); - expect(pluginState(applyCandidate(state, candidate))).toBe(initial); - }); -}); From 9c7453d7c95b580c30491688cfe1e457b4ae1478 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:30:47 +0900 Subject: [PATCH 27/42] test(diagnostics): cover command and scalar rejection paths --- .../WritingDiagnosticsReflection.test.ts | 60 ++++++++++++++++--- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/src/extensions/WritingDiagnosticsReflection.test.ts b/src/extensions/WritingDiagnosticsReflection.test.ts index ef152f45..3a0f65f1 100644 --- a/src/extensions/WritingDiagnosticsReflection.test.ts +++ b/src/extensions/WritingDiagnosticsReflection.test.ts @@ -1,6 +1,8 @@ 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, @@ -44,6 +46,20 @@ function diagnostic(): CwlResolvedWritingDiagnosticDecoration { }; } +/** 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], + }), + ); +} + describe('WritingDiagnostics hostile reflection failures', () => { it('rejects prototype and property-descriptor traps without leaking or throwing', () => { let state = stateWithText(); @@ -62,15 +78,45 @@ describe('WritingDiagnostics hostile reflection failures', () => { for (const candidate of [prototypeTrap, descriptorTrap]) { expect(() => { - state = state.apply( - state.tr.setMeta(writingDiagnosticsPluginKey, { - type: 'install', - generation: 1, - diagnostics: [candidate], - }), - ); + 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 focus-command scalar before dispatch', () => { + const editor = new Editor({ + extensions: buildExtensions(), + content: '

Alpha beta gamma

', + }); + + 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(); + } + }); }); From 5beb6e145894b97af72f7eeedfc9ab2a852ed44c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:32:11 +0900 Subject: [PATCH 28/42] test(diagnostics): cover hostile own-key reflection --- src/extensions/WritingDiagnosticsReflection.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/extensions/WritingDiagnosticsReflection.test.ts b/src/extensions/WritingDiagnosticsReflection.test.ts index 3a0f65f1..5e7db9ee 100644 --- a/src/extensions/WritingDiagnosticsReflection.test.ts +++ b/src/extensions/WritingDiagnosticsReflection.test.ts @@ -61,7 +61,7 @@ function applyInstallCandidate( } describe('WritingDiagnostics hostile reflection failures', () => { - it('rejects prototype and property-descriptor traps without leaking or throwing', () => { + 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(), { @@ -69,6 +69,11 @@ describe('WritingDiagnostics hostile reflection failures', () => { 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'); @@ -76,7 +81,7 @@ describe('WritingDiagnostics hostile reflection failures', () => { }, }); - for (const candidate of [prototypeTrap, descriptorTrap]) { + for (const candidate of [prototypeTrap, ownKeyTrap, descriptorTrap]) { expect(() => { state = applyInstallCandidate(state, candidate); }).not.toThrow(); From 129b0a7915a47733f338ab2364509b55e653d455 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:36:14 +0900 Subject: [PATCH 29/42] test(diagnostics): cover invalid install-command inputs --- .../WritingDiagnosticsReflection.test.ts | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/extensions/WritingDiagnosticsReflection.test.ts b/src/extensions/WritingDiagnosticsReflection.test.ts index 5e7db9ee..665bd2ae 100644 --- a/src/extensions/WritingDiagnosticsReflection.test.ts +++ b/src/extensions/WritingDiagnosticsReflection.test.ts @@ -60,6 +60,14 @@ function applyInstallCandidate( ); } +/** 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('rejects prototype, own-key, and property-descriptor traps without leaking or throwing', () => { let state = stateWithText(); @@ -101,11 +109,29 @@ describe('WritingDiagnostics hostile reflection failures', () => { } }); + 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 = new Editor({ - extensions: buildExtensions(), - content: '

Alpha beta gamma

', - }); + const editor = commandEditor(); try { expect(editor.commands.focusWritingDiagnostic(Number.NaN, 'diag')).toBe(false); From bd49b85d6e19c4521c304fe017cf7358500b11ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:39:45 +0900 Subject: [PATCH 30/42] test(diagnostics): cover absent plugin decoration fallback --- .../WritingDiagnosticsReflection.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/extensions/WritingDiagnosticsReflection.test.ts b/src/extensions/WritingDiagnosticsReflection.test.ts index 665bd2ae..e1ed1f98 100644 --- a/src/extensions/WritingDiagnosticsReflection.test.ts +++ b/src/extensions/WritingDiagnosticsReflection.test.ts @@ -29,6 +29,16 @@ function stateWithText(): EditorState { }); } +/** 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); @@ -69,6 +79,16 @@ function commandEditor(): Editor { } 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(stateWithoutDiagnostics())).toBeNull(); + }); + it('rejects prototype, own-key, and property-descriptor traps without leaking or throwing', () => { let state = stateWithText(); const initial = pluginState(state); From 59c9ed37d46d86331f961dc4eefcfb8a7ee57594 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 20:42:17 +0900 Subject: [PATCH 31/42] test(diagnostics): bind decoration prop receiver --- src/extensions/WritingDiagnosticsReflection.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/WritingDiagnosticsReflection.test.ts b/src/extensions/WritingDiagnosticsReflection.test.ts index e1ed1f98..4127b1d5 100644 --- a/src/extensions/WritingDiagnosticsReflection.test.ts +++ b/src/extensions/WritingDiagnosticsReflection.test.ts @@ -86,7 +86,7 @@ describe('WritingDiagnostics hostile reflection failures', () => { throw new Error('Missing writing diagnostics decoration prop'); } - expect(decorations(stateWithoutDiagnostics())).toBeNull(); + expect(decorations.call(plugin, stateWithoutDiagnostics())).toBeNull(); }); it('rejects prototype, own-key, and property-descriptor traps without leaking or throwing', () => { From debc374a7d579566d19d6f30ecfe718e111989c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 02:35:21 +0900 Subject: [PATCH 32/42] chore(ci): remove temporary writing diagnostics UI workflow --- .../workflows/writing-diagnostics-ui-tdd.yml | 120 ------------------ 1 file changed, 120 deletions(-) delete mode 100644 .github/workflows/writing-diagnostics-ui-tdd.yml diff --git a/.github/workflows/writing-diagnostics-ui-tdd.yml b/.github/workflows/writing-diagnostics-ui-tdd.yml deleted file mode 100644 index 7f24ac4a..00000000 --- a/.github/workflows/writing-diagnostics-ui-tdd.yml +++ /dev/null @@ -1,120 +0,0 @@ -name: Writing Diagnostics UI TDD - -on: - push: - branches: - - feat/writing-diagnostics-ui - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: writing-diagnostics-ui-tdd-${{ github.ref }} - cancel-in-progress: true - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - focused-ui: - runs-on: ubuntu-24.04 - timeout-minutes: 30 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - persist-credentials: false - - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 22 - cache: pnpm - - run: pnpm install --frozen-lockfile - - name: Collect accessible writing-guidance coverage - id: focused_coverage - continue-on-error: true - run: >- - pnpm exec vitest run - src/components/WritingDiagnosticsPanel.test.tsx - src/components/WritingDiagnosticsPanel.keyboard.test.tsx - src/components/WritingDiagnosticsPanel.print.test.tsx - src/components/EditorFrame.test.tsx - src/printStyles.test.ts - --pool=forks - --maxWorkers=1 - --coverage - --coverage.include=src/components/WritingDiagnosticsPanel.tsx - --coverage.reporter=text - --coverage.reporter=json - - name: Report and enforce exact UI coverage - run: | - node <<'NODE' - const { existsSync, readFileSync } = require('node:fs'); - if (!existsSync('coverage/coverage-final.json')) { - console.error('::error file=coverage/coverage-final.json,line=1::Focused coverage report is missing.'); - process.exit(1); - } - const coverage = JSON.parse(readFileSync('coverage/coverage-final.json', 'utf8')); - const suffix = '/src/components/WritingDiagnosticsPanel.tsx'; - const displayPath = suffix.slice(1); - const entry = Object.entries(coverage).find(([path]) => path.endsWith(suffix)); - let failed = process.env.FOCUSED_COVERAGE_OUTCOME !== 'success'; - if (!entry) { - console.error(`::error file=${displayPath},line=1::Focused coverage record is missing.`); - process.exit(1); - } - const [, fileCoverage] = entry; - const missingStatementLines = [...new Set( - Object.entries(fileCoverage.s) - .filter(([, count]) => count === 0) - .map(([id]) => fileCoverage.statementMap[id].start.line), - )].sort((left, right) => left - right); - const missingFunctionLines = [...new Set( - Object.entries(fileCoverage.f) - .filter(([, count]) => count === 0) - .map(([id]) => fileCoverage.fnMap[id].decl.start.line), - )].sort((left, right) => left - right); - const missingBranches = []; - for (const [id, counts] of Object.entries(fileCoverage.b)) { - counts.forEach((count, index) => { - if (count !== 0) return; - const branch = fileCoverage.branchMap[id]; - const location = branch.locations?.[index] ?? branch.loc; - missingBranches.push(`${location.start.line}:${index}`); - }); - } - const statementTotal = Object.keys(fileCoverage.s).length; - const statementCovered = Object.values(fileCoverage.s).filter((count) => count > 0).length; - const functionTotal = Object.keys(fileCoverage.f).length; - const functionCovered = Object.values(fileCoverage.f).filter((count) => count > 0).length; - const branchCounts = Object.values(fileCoverage.b).flat(); - const branchTotal = branchCounts.length; - const branchCovered = branchCounts.filter((count) => count > 0).length; - console.log( - `::notice file=${displayPath},line=1::Statements ${statementCovered}/${statementTotal}; ` + - `functions ${functionCovered}/${functionTotal}; branches ${branchCovered}/${branchTotal}.`, - ); - if (missingStatementLines.length || missingFunctionLines.length || missingBranches.length) { - console.error( - `::error file=${displayPath},line=1::` + - `Missing statement lines: ${missingStatementLines.join(', ') || 'none'}; ` + - `missing function lines: ${missingFunctionLines.join(', ') || 'none'}; ` + - `missing branches line:index: ${missingBranches.join(', ') || 'none'}.`, - ); - failed = true; - } - if (failed) process.exit(1); - NODE - env: - FOCUSED_COVERAGE_OUTCOME: ${{ steps.focused_coverage.outcome }} - - name: Typecheck writing-guidance contracts - run: pnpm typecheck - - name: Run complete production coverage gate - run: pnpm coverage - - name: Build every package entrypoint - run: pnpm build - - name: Verify packed-package consumers - run: pnpm verify:package - - name: Build the demonstration application - run: pnpm build:demo From 3535115bb720d23e2473fa39c26f4157a04dfac1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 02:44:12 +0900 Subject: [PATCH 33/42] fix(ci): carry executable release workflow --- .github/workflows/release.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cfb80a5a..e735c099 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -91,7 +91,8 @@ jobs: mv "$package_file" release/ - name: Install hash-locked Office dependencies working-directory: office - run: python -m pip install --require-hashes --only-binary=:all: -r requirements-ci.txt + run: | + python -m pip install --require-hashes --only-binary=:all: -r requirements-ci.txt - name: Verify Office dependency consistency working-directory: office run: python -m pip check @@ -600,7 +601,7 @@ jobs: process.exit(2); } process.stdout.write(url.origin); - NODE + NODE )" || { echo "::error::npm dist.tarball must stay on the canonical registry.npmjs.org HTTPS origin." exit 1 From dd1d3d70e1318812f14401dc69d51d797d391f54 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 08:04:32 +0900 Subject: [PATCH 34/42] test(a11y): preserve focus after diagnostic dismissal --- ...tingDiagnosticsPanel.dismissFocus.test.tsx | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 src/components/WritingDiagnosticsPanel.dismissFocus.test.tsx 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(); + }); +}); From a198f5fae774cbab6f12c6994eb1f0411483a9c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 08:05:20 +0900 Subject: [PATCH 35/42] ci(a11y): prove writing diagnostic dismissal focus boundary --- .../writing-diagnostics-dismiss-focus-tdd.yml | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/writing-diagnostics-dismiss-focus-tdd.yml diff --git a/.github/workflows/writing-diagnostics-dismiss-focus-tdd.yml b/.github/workflows/writing-diagnostics-dismiss-focus-tdd.yml new file mode 100644 index 00000000..a8a3fba1 --- /dev/null +++ b/.github/workflows/writing-diagnostics-dismiss-focus-tdd.yml @@ -0,0 +1,42 @@ +name: Writing Diagnostics Dismiss Focus TDD + +on: + push: + branches: [feat/writing-diagnostics-ui] + +permissions: + contents: read + +concurrency: + group: writing-diagnostics-dismiss-focus-tdd-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + verify: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Typecheck + run: pnpm typecheck + - name: Focused dismissal-focus regression + run: pnpm exec vitest run src/components/WritingDiagnosticsPanel.dismissFocus.test.tsx + - name: Repository 100% owned-production coverage + run: pnpm coverage + - name: Build library + run: pnpm build + - name: Verify packed package consumers + run: pnpm verify:package + - name: Build demo + run: pnpm build:demo From acbbd2a1ad9836df0cc2e1e60d4d0439e987a021 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 08:08:27 +0900 Subject: [PATCH 36/42] fix(a11y): preserve focus after diagnostic dismissal --- src/components/WritingDiagnosticsPanel.tsx | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/components/WritingDiagnosticsPanel.tsx b/src/components/WritingDiagnosticsPanel.tsx index 6b6fa23c..33781f82 100644 --- a/src/components/WritingDiagnosticsPanel.tsx +++ b/src/components/WritingDiagnosticsPanel.tsx @@ -37,6 +37,7 @@ export function WritingDiagnosticsPanel({ null, ); const [statusMessage, setStatusMessage] = useState(''); + const regionRef = useRef(null); const itemRefs = useRef>([]); const selectedIndex = diagnostics.findIndex( (candidate) => @@ -55,6 +56,24 @@ export function WritingDiagnosticsPanel({ 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); }; @@ -84,7 +103,9 @@ export function WritingDiagnosticsPanel({ aria-label={label} className="cwl-writing-diagnostics" data-print-enabled={printEnabled ? 'true' : undefined} + ref={regionRef} role="region" + tabIndex={-1} >

@@ -189,6 +210,7 @@ export function WritingDiagnosticsPanel({ ) !== null ) { setStatusMessage(`Dismissed ${diagnostic.title}.`); + focusAfterDismissal(index); } }} type="button" From f197ca8404953eff16ef63eb47e6d96db18ca7a8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 08:12:38 +0900 Subject: [PATCH 37/42] chore(ci): remove completed dismissal-focus TDD workflow --- .../writing-diagnostics-dismiss-focus-tdd.yml | 42 ------------------- 1 file changed, 42 deletions(-) delete mode 100644 .github/workflows/writing-diagnostics-dismiss-focus-tdd.yml diff --git a/.github/workflows/writing-diagnostics-dismiss-focus-tdd.yml b/.github/workflows/writing-diagnostics-dismiss-focus-tdd.yml deleted file mode 100644 index a8a3fba1..00000000 --- a/.github/workflows/writing-diagnostics-dismiss-focus-tdd.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Writing Diagnostics Dismiss Focus TDD - -on: - push: - branches: [feat/writing-diagnostics-ui] - -permissions: - contents: read - -concurrency: - group: writing-diagnostics-dismiss-focus-tdd-${{ github.ref }} - cancel-in-progress: true - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - verify: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - persist-credentials: false - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 22 - cache: pnpm - - run: pnpm install --frozen-lockfile - - name: Typecheck - run: pnpm typecheck - - name: Focused dismissal-focus regression - run: pnpm exec vitest run src/components/WritingDiagnosticsPanel.dismissFocus.test.tsx - - name: Repository 100% owned-production coverage - run: pnpm coverage - - name: Build library - run: pnpm build - - name: Verify packed package consumers - run: pnpm verify:package - - name: Build demo - run: pnpm build:demo From 61c6758a7e01950c183d473b9d7d7d63e6a9fea0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 08:14:39 +0900 Subject: [PATCH 38/42] test(a11y): require visible focus on guidance handoff target --- src/printStyles.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/printStyles.test.ts b/src/printStyles.test.ts index 8d08d4db..78a85077 100644 --- a/src/printStyles.test.ts +++ b/src/printStyles.test.ts @@ -79,6 +79,12 @@ describe('print stylesheet contract', () => { ); }); + 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, From d21f17536465e66c02f385c136e041c7cfc01f16 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 08:15:18 +0900 Subject: [PATCH 39/42] ci(a11y): prove guidance focus-indicator boundary --- ...riting-diagnostics-focus-indicator-tdd.yml | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/writing-diagnostics-focus-indicator-tdd.yml diff --git a/.github/workflows/writing-diagnostics-focus-indicator-tdd.yml b/.github/workflows/writing-diagnostics-focus-indicator-tdd.yml new file mode 100644 index 00000000..d4c4a26b --- /dev/null +++ b/.github/workflows/writing-diagnostics-focus-indicator-tdd.yml @@ -0,0 +1,42 @@ +name: Writing Diagnostics Focus Indicator TDD + +on: + push: + branches: [feat/writing-diagnostics-ui] + +permissions: + contents: read + +concurrency: + group: writing-diagnostics-focus-indicator-tdd-${{ github.ref }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + verify: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Typecheck + run: pnpm typecheck + - name: Focused focus-indicator regression + run: pnpm exec vitest run src/printStyles.test.ts + - name: Repository 100% owned-production coverage + run: pnpm coverage + - name: Build library + run: pnpm build + - name: Verify packed package consumers + run: pnpm verify:package + - name: Build demo + run: pnpm build:demo From 6d447a26415a4377eb9798c700046db7f7c87ffa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 08:18:34 +0900 Subject: [PATCH 40/42] fix(a11y): show focus on empty guidance handoff target --- src/styles.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/styles.css b/src/styles.css index 3b1efbd9..470f30c3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -110,6 +110,7 @@ @media (forced-colors: active) { .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 { @@ -357,6 +358,7 @@ 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 { @@ -545,4 +547,4 @@ break-inside: avoid; border-color: #000000; } -} +} \ No newline at end of file From a77a1e9f1bb431034995c1f445ba085b1bce0110 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 10:03:13 +0900 Subject: [PATCH 41/42] chore(ci): retire focused diagnostics proof workflow --- ...riting-diagnostics-focus-indicator-tdd.yml | 42 ------------------- 1 file changed, 42 deletions(-) delete mode 100644 .github/workflows/writing-diagnostics-focus-indicator-tdd.yml diff --git a/.github/workflows/writing-diagnostics-focus-indicator-tdd.yml b/.github/workflows/writing-diagnostics-focus-indicator-tdd.yml deleted file mode 100644 index d4c4a26b..00000000 --- a/.github/workflows/writing-diagnostics-focus-indicator-tdd.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Writing Diagnostics Focus Indicator TDD - -on: - push: - branches: [feat/writing-diagnostics-ui] - -permissions: - contents: read - -concurrency: - group: writing-diagnostics-focus-indicator-tdd-${{ github.ref }} - cancel-in-progress: true - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - verify: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - persist-credentials: false - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 22 - cache: pnpm - - run: pnpm install --frozen-lockfile - - name: Typecheck - run: pnpm typecheck - - name: Focused focus-indicator regression - run: pnpm exec vitest run src/printStyles.test.ts - - name: Repository 100% owned-production coverage - run: pnpm coverage - - name: Build library - run: pnpm build - - name: Verify packed package consumers - run: pnpm verify:package - - name: Build demo - run: pnpm build:demo From 70a9c29d35ed152720686e3e3731725a739347d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 20:08:52 -0700 Subject: [PATCH 42/42] test(a11y): cover diagnostic dismissal focus handoff --- ...ngDiagnosticsPanel.dismissalFocus.test.tsx | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 src/components/WritingDiagnosticsPanel.dismissalFocus.test.tsx 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(); +});