diff --git a/CHANGELOG.md b/CHANGELOG.md index e434fa01..7e43f3bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 mode, delegating provider/model/topology policy to the shared service without weakening ScopeWeave's authenticated, fail-closed transport or response boundary controls. +- Kept invalid editor save actions keyboard-discoverable with + `aria-disabled="true"` and an explicit `aria-describedby` relationship while + preserving synchronous submit-time validation as the only persistence gate; + immediately corrected click and Enter submissions now use the latest draft. - Accepted XML whitespace before exact Microsoft Project element delimiters while preserving the linear, regex-free import scanner and rejecting attributes, longer names, non-XML whitespace, nested unmatched blocks, and diff --git a/app.js b/app.js index a04aae71..bacbb51f 100644 --- a/app.js +++ b/app.js @@ -87,7 +87,7 @@ const CSV_HEADERS = [ '스프린트', '스토리포인트' ]; -const CSV_FORMULA_PREFIX_PATTERN = /^\s*[=+\-@|]/; +const CSV_FORMULA_PREFIX_PATTERN = /^\s*[=+\-@|=+-@|]/; const UNSAFE_JSON_KEYS = new Set(['__proto__', 'constructor', 'prototype']); const CSV_FIELD_LABELS = Object.freeze(Object.assign(Object.create(null), { @@ -790,6 +790,9 @@ function renderEditorRow(anchorId) { panel.className = 'editor-panel'; const form = document.createElement('form'); form.dataset.editorForm = 'true'; + // ScopeWeave validation is the sole persistence gate; native constraint + // validation would intercept submission and move focus before saveEditor(). + form.noValidate = true; const editorGrid = document.createElement('div'); editorGrid.className = 'editor-grid'; @@ -1068,7 +1071,13 @@ function renderEditorValidation() { const saveButton = form.querySelector('button[type="submit"]'); if (saveButton) { - saveButton.disabled = errors.length > 0; + if (errors.length > 0) { + saveButton.setAttribute('aria-disabled', 'true'); + saveButton.setAttribute('aria-describedby', 'editor-errors'); + } else { + saveButton.removeAttribute('aria-disabled'); + saveButton.removeAttribute('aria-describedby'); + } saveButton.title = errors.length > 0 ? '입력값을 올바르게 수정해야 저장할 수 있습니다.' : '저장 (Enter)'; } diff --git a/docs/doctoring/editor-save-validation-accessibility.md b/docs/doctoring/editor-save-validation-accessibility.md new file mode 100644 index 00000000..e65fbb8f --- /dev/null +++ b/docs/doctoring/editor-save-validation-accessibility.md @@ -0,0 +1,75 @@ +# Focusable editor validation and synchronous save authority + +## Decision + +The editor save button remains a native button in the sequential keyboard order. +When the current draft is invalid, ScopeWeave exposes +`aria-disabled="true"`, connects the button to `#editor-errors` with +`aria-describedby`, and keeps the control physically focusable. Activation is +still accepted as an input event, but `saveEditor()` synchronously validates the +latest draft and refuses persistence while errors remain. + +The debounced validation pass is presentation only. It updates field error +states, the error summary, and save-button semantics; it is not an authorization +or persistence boundary. This avoids two inverse races: + +- a user corrects the final error and immediately clicks or presses Enter before + the debounce updates a stale disabled state; and +- a user introduces an error and immediately submits before the presentation + layer catches up. + +Both paths are decided by the same latest-draft validation inside +`saveEditor()`. + +## Accessibility rationale + +WAI-ARIA defines `aria-disabled` as a perceivable disabled state. W3C's +Authoring Practices notes that disabled commands can remain focusable when their +discoverability is useful, provided scripting prevents the unavailable action. +The save action is a primary command whose error relationship benefits from +keyboard discovery, so ScopeWeave keeps it focusable and exposes the current +error summary as its accessible description. + +The native `disabled` attribute is not used for this state because it removes the +button from normal keyboard focus and can preserve a stale block while the +debounced presentation state catches up. The implementation must not treat +`aria-disabled` alone as enforcement; synchronous validation prevents mutation. + +## Executable evidence + +`tests/e2e/editor-validation-sync.spec.js` verifies: + +- an invalid save control remains focusable and described; +- activating it does not create a task; +- a draft corrected immediately before click saves without waiting for debounce; +- a draft corrected immediately before Enter saves without waiting for debounce; +- a newly invalid draft cannot persist before debounce completes; and +- error text remains available through `#editor-errors`. + +The existing full-browser suite is updated to activate invalid save controls and +assert that task count and editor state are unchanged for reversed dates, +invalid calendar dates, and HTML input. It also re-enables the complete +`scopeweave.spec.js` cloud path for the editor acceptance boundary. + +## Compatibility and rollback + +This change does not modify persisted WBS data, API contracts, authentication, +or server storage. It changes only the editor's presentation semantics and keeps +existing synchronous validation behavior as the persistence authority. + +Rollback must revert the button-state implementation, focused browser tests, +full-suite expectations, package script, CHANGELOG entry, and this record +together. Reintroducing native `disabled` requires a new proof that immediate +correction cannot be blocked by stale debounced state. + +## References + +World Wide Web Consortium. (2023). *Accessible Rich Internet Applications +(WAI-ARIA) 1.2*. https://www.w3.org/TR/wai-aria-1.2/ + +World Wide Web Consortium. (2025). *Developing a keyboard interface*. +WAI-ARIA Authoring Practices Guide. +https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/ + +World Wide Web Consortium. (2024). *Web Content Accessibility Guidelines +(WCAG) 2.2*. https://www.w3.org/TR/WCAG22/ diff --git a/index.html b/index.html index d24b2a88..8c1a832f 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,8 @@ ScopeWeave Planner + + @@ -116,4 +118,4 @@

간트 차트

- + \ No newline at end of file diff --git a/package.json b/package.json index 8cefdc74..a560aba7 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", - "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", + "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js tests/e2e/editor-validation-sync.spec.js tests/e2e/scopeweave.spec.js", "test:fuzz": "playwright install chromium && playwright test tests/e2e/csv_formula_fuzz.spec.js", "fuzz": "node --test tests/fuzz/*.mjs" }, diff --git a/tests/e2e/csv_formula_fuzz.spec.js b/tests/e2e/csv_formula_fuzz.spec.js index 5ff20a48..d1ffd7bc 100644 --- a/tests/e2e/csv_formula_fuzz.spec.js +++ b/tests/e2e/csv_formula_fuzz.spec.js @@ -1,6 +1,8 @@ import { test, expect } from '@playwright/test'; import fc from 'fast-check'; +const DANGEROUS_CSV_PREFIX_PATTERN = /^\s*[=+\-@|=+-@|]/; + test.describe('CSV formula fuzzing', () => { test.beforeEach(async ({ page }) => { await page.goto('./'); @@ -14,16 +16,35 @@ test.describe('CSV formula fuzzing', () => { sanitized: window.sanitizeCsvFormulaValue(value) }), candidate); const normalized = String(candidate ?? ''); - const dangerous = /^\s*[=+\-@|]/.test(normalized); + const dangerous = DANGEROUS_CSV_PREFIX_PATTERN.test(normalized); const expectedSanitized = dangerous ? `'${normalized}` : normalized; expect(result.sanitized).toBe(expectedSanitized); expect(result.escaped.startsWith('"')).toBe(true); expect(result.escaped.endsWith('"')).toBe(true); expect(result.escaped.slice(1, -1).replace(/""/g, '"')).toBe(expectedSanitized); - expect(/^\s*[=+\-@|]/.test(result.sanitized)).toBe(false); + expect(DANGEROUS_CSV_PREFIX_PATTERN.test(result.sanitized)).toBe(false); }), { numRuns: 100, seed: 20260709 } ); }); -}); + + test('neutralizes fullwidth formula-prefix compatibility characters', async ({ page }) => { + const compatibilityPrefixes = ['=', '+', '-', '@', '|']; + + for (const prefix of compatibilityPrefixes) { + for (const candidate of [`${prefix}1+1`, ` \t${prefix}SUM(A1:A2)`]) { + const result = await page.evaluate((value) => ({ + escaped: window.csvEscape(value), + sanitized: window.sanitizeCsvFormulaValue(value) + }), candidate); + const expectedSanitized = `'${candidate}`; + + expect(result.sanitized).toBe(expectedSanitized); + expect(result.escaped.startsWith('"')).toBe(true); + expect(result.escaped.endsWith('"')).toBe(true); + expect(result.escaped.slice(1, -1).replace(/""/g, '"')).toBe(expectedSanitized); + } + } + }); +}); \ No newline at end of file diff --git a/tests/e2e/editor-validation-sync.spec.js b/tests/e2e/editor-validation-sync.spec.js new file mode 100644 index 00000000..8f77436a --- /dev/null +++ b/tests/e2e/editor-validation-sync.spec.js @@ -0,0 +1,89 @@ +import { test, expect } from '@playwright/test'; + +async function openRootEditor(page) { + await page.getByRole('button', { name: '최상위 작업 추가' }).click(); + return page.locator('.editor-panel'); +} + +async function makeSavePresentationValid(page, saveButton) { + const phaseInput = page.getByTestId('editor-phase'); + await phaseInput.fill('temporarily valid'); + await expect(saveButton).not.toHaveAttribute('aria-disabled', 'true'); + await expect(saveButton).not.toHaveAttribute('aria-describedby', 'editor-errors'); + return phaseInput; +} + +async function expectInvalidSaveRejected(page, editor, saveButton, initialTaskCount) { + await expect(saveButton).toBeFocused(); + await expect(editor).toBeVisible(); + await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); + await expect(saveButton).toHaveAttribute('aria-describedby', 'editor-errors'); + await expect(page.locator('#editor-errors')).toContainText('최상위 작업은 단계 값을 입력해야 합니다.'); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(initialTaskCount); +} + +test('invalid pointer save refreshes validation and preserves save focus', async ({ page }) => { + await page.goto('./'); + const initialTaskCount = await page.locator('tbody tr[data-task-id]').count(); + const editor = await openRootEditor(page); + const saveButton = editor.getByRole('button', { name: '저장', exact: true }); + const phaseInput = await makeSavePresentationValid(page, saveButton); + + await phaseInput.fill(''); + await saveButton.focus(); + await expect(saveButton).toBeFocused(); + await saveButton.click(); + + await expectInvalidSaveRejected(page, editor, saveButton, initialTaskCount); +}); + +test('invalid keyboard save refreshes validation and preserves save focus', async ({ page }) => { + await page.goto('./'); + const initialTaskCount = await page.locator('tbody tr[data-task-id]').count(); + const editor = await openRootEditor(page); + const saveButton = editor.getByRole('button', { name: '저장', exact: true }); + const phaseInput = await makeSavePresentationValid(page, saveButton); + + await phaseInput.fill(''); + await saveButton.focus(); + await expect(saveButton).toBeFocused(); + await saveButton.press('Enter'); + + await expectInvalidSaveRejected(page, editor, saveButton, initialTaskCount); +}); + +test('latest valid draft saves immediately by click and Enter', async ({ page }) => { + await page.goto('./'); + const initialTaskCount = await page.locator('tbody tr[data-task-id]').count(); + + let editor = await openRootEditor(page); + let phaseInput = page.getByTestId('editor-phase'); + let saveButton = editor.getByRole('button', { name: '저장', exact: true }); + await phaseInput.fill('P9000.즉시 클릭'); + await saveButton.click(); + + await expect(editor).toHaveCount(0); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(initialTaskCount + 1); + + editor = await openRootEditor(page); + phaseInput = page.getByTestId('editor-phase'); + await phaseInput.fill('P9001.즉시 엔터'); + await phaseInput.press('Enter'); + + await expect(editor).toHaveCount(0); + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(initialTaskCount + 2); +}); + +test('latest invalid draft cannot save before debounce completes', async ({ page }) => { + await page.goto('./'); + const initialTaskCount = await page.locator('tbody tr[data-task-id]').count(); + const editor = await openRootEditor(page); + const saveButton = editor.getByRole('button', { name: '저장', exact: true }); + const phaseInput = await makeSavePresentationValid(page, saveButton); + + await phaseInput.fill(''); + await saveButton.focus(); + await saveButton.click(); + + await expectInvalidSaveRejected(page, editor, saveButton, initialTaskCount); +}); diff --git a/tests/e2e/scopeweave.spec.js b/tests/e2e/scopeweave.spec.js index dc0cda8d..8b75397c 100644 --- a/tests/e2e/scopeweave.spec.js +++ b/tests/e2e/scopeweave.spec.js @@ -14,8 +14,16 @@ const addTopLevelTask = async (page, values) => { const expectSaveBlockedWith = async (page, message) => { const saveButton = page.getByRole('button', { name: '저장', exact: true }); - await expect(saveButton).toBeDisabled(); + const initialTaskCount = await page.locator('tbody tr[data-task-id]').count(); + + await expect(saveButton).not.toHaveAttribute('disabled'); + await expect(saveButton).toHaveAttribute('aria-disabled', 'true'); + await expect(saveButton).toHaveAttribute('aria-describedby', 'editor-errors'); await expect(page.locator('#editor-errors')).toContainText(message); + + await saveButton.evaluate((button) => button.click()); + + await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(initialTaskCount); await expect(page.locator('.editor-panel')).toBeVisible(); }; @@ -702,12 +710,9 @@ test.describe('ScopeWeave Planner', () => { await page.locator('[data-testid="editor-planned-start"]').fill('2026-05-20'); await page.locator('[data-testid="editor-planned-end"]').fill('2026-05-19'); - const saveButton = page.getByRole('button', { name: '저장', exact: true }); - await expect(saveButton).toBeDisabled(); + await expectSaveBlockedWith(page, '계획종료일은 계획시작일보다 빠를 수 없습니다'); await expect(page.locator('[data-testid="editor-planned-end"]')).toHaveAttribute('aria-invalid', 'true'); - await expect(page.locator('#editor-errors')).toContainText('계획종료일은 계획시작일보다 빠를 수 없습니다'); await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); - await expect(page.locator('.editor-panel')).toBeVisible(); }); test('rejects invalid calendar dates in the editor', async ({ page }) => { @@ -719,12 +724,9 @@ test.describe('ScopeWeave Planner', () => { await page.locator('[data-testid="editor-planned-start"]').fill('2026-02-31'); - const saveButton = page.getByRole('button', { name: '저장', exact: true }); - await expect(saveButton).toBeDisabled(); + await expectSaveBlockedWith(page, '계획시작일은 YYYY-MM-DD 형식의 실제 달력 날짜여야 합니다'); await expect(page.locator('[data-testid="editor-planned-start"]')).toHaveAttribute('aria-invalid', 'true'); - await expect(page.locator('#editor-errors')).toContainText('계획시작일은 YYYY-MM-DD 형식의 실제 달력 날짜여야 합니다'); await expect(page.locator('tbody tr[data-task-id]')).toHaveCount(4); - await expect(page.locator('.editor-panel')).toBeVisible(); }); test('rejects HTML payloads from the UI editor', async ({ page }) => { @@ -732,11 +734,9 @@ test.describe('ScopeWeave Planner', () => { await page.locator('[data-testid="editor-task"]').fill(''); - const saveButton = page.locator('.editor-panel').getByRole('button', { name: '저장' }); - await expect(saveButton).toBeDisabled(); + await expectSaveBlockedWith(page, 'HTML 태그 문자를 사용할 수 없습니다'); await expect(page.locator('[data-testid="editor-task"]')).toHaveAttribute('aria-invalid', 'true'); - await expect(page.locator('#editor-errors')).toContainText('HTML 태그 문자를 사용할 수 없습니다'); - await expect(page.locator('.editor-panel')).toBeVisible(); + await expect(page.locator('#task-table-body')).not.toContainText(''); }); test('validateDraft pure function logic', async ({ page }) => { @@ -1092,13 +1092,13 @@ test.describe('ScopeWeave Planner', () => { test('renders empty cells as independent DOM clones', async ({ page }) => { const result = await page.evaluate(() => { - const emptyCells = Array.from(document.querySelectorAll('.empty-cell')); + const emptyCells = [ + window.createTextCellContent(''), + window.createTextCellContent('') + ]; + document.body.append(...emptyCells); const [first, second] = emptyCells; - if (!first || !second) { - return { count: emptyCells.length, uniqueCount: new Set(emptyCells).size }; - } - first.querySelector('[aria-hidden="true"]').textContent = 'x'; first.querySelector('.sr-only').textContent = 'mutated'; @@ -1300,7 +1300,6 @@ test.describe('ScopeWeave Planner - Palette UX Enhancements', () => { // Verify sync status ARIA attributes const syncStatus = page.locator('#sync-status'); - await expect(syncStatus).toHaveAttribute('role', 'status'); await expect(syncStatus).toHaveAttribute('aria-live', 'polite'); await expect(syncStatus).toHaveAttribute('aria-atomic', 'true'); diff --git a/tests/unit/editor-unsaved.test.mjs b/tests/unit/editor-unsaved.test.mjs index 2fb5bd16..b839c892 100644 --- a/tests/unit/editor-unsaved.test.mjs +++ b/tests/unit/editor-unsaved.test.mjs @@ -18,6 +18,7 @@ function loadApp() { editorHasUnsavedChanges, bindGlobalEvents, closeEditor, + renderEditorValidation, state, DEFAULT_EDITOR_STATE, }; @@ -116,17 +117,27 @@ function loadApp() { if (!exportsObj?.editorHasUnsavedChanges) { throw new Error('Failed to extract editor exports from app.js'); } - return { ...exportsObj, windowListeners, setConfirm: (fn) => { confirmImpl = fn; } }; + return { + ...exportsObj, + windowListeners, + setConfirm: (fn) => { confirmImpl = fn; }, + setEditorValidationDom: ({ errorElement, form }) => { + sandbox.document.getElementById = (id) => (id === 'editor-errors' ? errorElement : null); + sandbox.document.querySelector = (selector) => (selector === 'form[data-editor-form="true"]' ? form : null); + }, + }; } const { editorHasUnsavedChanges, bindGlobalEvents, closeEditor, + renderEditorValidation, state, DEFAULT_EDITOR_STATE, windowListeners, setConfirm, + setEditorValidationDom, } = loadApp(); // --- editorHasUnsavedChanges --- @@ -228,4 +239,42 @@ setConfirm(() => { closeEditor(true); assert.equal(state.editor.mode, DEFAULT_EDITOR_STATE.mode, 'force close skips confirm'); +// --- renderEditorValidation accessibility state --- +const saveAttributes = new Map(); +const saveButton = { + title: '', + setAttribute(name, value) { + saveAttributes.set(name, value); + }, + removeAttribute(name) { + saveAttributes.delete(name); + }, +}; +const errorElement = { textContent: '' }; +const editorForm = { + querySelector(selector) { + assert.equal(selector, 'button[type="submit"]'); + return saveButton; + }, + querySelectorAll(selector) { + assert.equal(selector, 'input[data-editor-field]'); + return []; + }, +}; +setEditorValidationDom({ errorElement, form: editorForm }); + +state.editor = { mode: 'create', depth: 1, draft: { phase: '' }, errors: [] }; +renderEditorValidation(); +assert.equal(saveAttributes.get('aria-disabled'), 'true', 'invalid draft exposes disabled semantics'); +assert.equal(saveAttributes.get('aria-describedby'), 'editor-errors', 'invalid save references the error summary'); +assert.match(errorElement.textContent, /단계 값을 입력해야 합니다/, 'invalid draft publishes its error summary'); +assert.equal(saveButton.title, '입력값을 올바르게 수정해야 저장할 수 있습니다.'); + +state.editor = { mode: 'create', depth: 1, draft: { phase: 'P1000.검증' }, errors: [] }; +renderEditorValidation(); +assert.equal(saveAttributes.has('aria-disabled'), false, 'valid draft removes disabled semantics'); +assert.equal(saveAttributes.has('aria-describedby'), false, 'valid save removes stale error relationship'); +assert.equal(errorElement.textContent, '', 'valid draft clears the error summary'); +assert.equal(saveButton.title, '저장 (Enter)'); + console.log('✓ editor unsaved / beforeunload coverage tests passed');