Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1d9cd03
fix(a11y): synchronize editor validation and submit
seonghobae Aug 14, 2026
cfed01f
test(a11y): cover editor validation semantics in c8
seonghobae Aug 15, 2026
c5e5622
merge(a11y): reconcile editor validation with protected develop
seonghobae Aug 16, 2026
d4b894e
merge(a11y): reconcile editor validation with current develop
seonghobae Aug 16, 2026
2283a84
test(security): cover fullwidth CSV formula prefixes
seonghobae Aug 16, 2026
afc93af
fix(security): neutralize fullwidth CSV formula prefixes
seonghobae Aug 16, 2026
7daf5c1
test(security): assert fullwidth CSV quote boundaries
seonghobae Aug 16, 2026
929649c
merge(develop): reconcile editor validation with toast accessibility
seonghobae Aug 16, 2026
32d92df
merge(develop): reconcile editor validation with OpenCode config
seonghobae Aug 17, 2026
7150ad2
merge(develop): reconcile editor validation with adaptive orchestration
seonghobae Aug 19, 2026
0de787a
Merge branch 'develop' into fix/editor-validation-sync-411
opencode-agent[bot] Aug 20, 2026
0b2de67
chore(stack): reconcile editor validation with current develop
seonghobae Aug 20, 2026
d4228f5
chore(scope): keep module preload in dedicated PR
seonghobae Aug 23, 2026
588bdaa
fix(test): preserve preload contract for full browser suite
seonghobae Aug 23, 2026
f1350f9
test(security): align CSV fuzz oracle with fullwidth prefixes
seonghobae Aug 23, 2026
fca25c7
test(a11y): require invalid save focus preservation
seonghobae Aug 23, 2026
815af81
fix(a11y): keep editor validation in the submit path
seonghobae Aug 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ const CSV_HEADERS = [
'스프린트',
'스토리포인트'
];
const CSV_FORMULA_PREFIX_PATTERN = /^\s*[=+\-@|]/;
const CSV_FORMULA_PREFIX_PATTERN = /^\s*[=+\-@|=+-@|]/;
Comment thread
seonghobae marked this conversation as resolved.
const UNSAFE_JSON_KEYS = new Set(['__proto__', 'constructor', 'prototype']);

const CSV_FIELD_LABELS = Object.freeze(Object.assign(Object.create(null), {
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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');
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
saveButton.title = errors.length > 0 ? '입력값을 올바르게 수정해야 저장할 수 있습니다.' : '저장 (Enter)';
}
Comment thread
seonghobae marked this conversation as resolved.

Expand Down
75 changes: 75 additions & 0 deletions docs/doctoring/editor-save-validation-accessibility.md
Original file line number Diff line number Diff line change
@@ -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/
4 changes: 3 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'; form-action 'self';" />
<title>ScopeWeave Planner</title>
<link rel="preload" href="styles.css" as="style" />
<link rel="modulepreload" href="cloud-sync.js" />
<link rel="modulepreload" href="analytics.js" />
<link rel="modulepreload" href="app.js" />
<link rel="stylesheet" href="styles.css" />
<link rel="stylesheet" href="toast-state.css" />
Expand Down Expand Up @@ -116,4 +118,4 @@ <h2 id="gantt-title">간트 차트</h2>
<script type="module" src="analytics.js"></script>
<script type="module" src="app.js"></script>
</body>
</html>
</html>
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
27 changes: 24 additions & 3 deletions tests/e2e/csv_formula_fuzz.spec.js
Comment thread
seonghobae marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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('./');
Expand All @@ -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);
Comment thread
seonghobae marked this conversation as resolved.
}
}
});
});
89 changes: 89 additions & 0 deletions tests/e2e/editor-validation-sync.spec.js
Original file line number Diff line number Diff line change
@@ -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);
});
37 changes: 18 additions & 19 deletions tests/e2e/scopeweave.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};

Expand Down Expand Up @@ -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 }) => {
Expand All @@ -719,24 +724,19 @@ 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 }) => {
await page.locator('tbody tr[data-task-id]').first().getByRole('button', { name: '편집' }).click();

await page.locator('[data-testid="editor-task"]').fill('<script>alert(1)</script>');

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('<script>alert(1)</script>');
});

test('validateDraft pure function logic', async ({ page }) => {
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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');

Expand Down
Loading
Loading