Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
95cf3e0
๐ŸŽจ Palette: [์ ‘๊ทผ์„ฑ] ๋นˆ ์ƒํƒœ์˜ ๋ฒ„ํŠผ ๋น„ํ™œ์„ฑํ™” ์‹œ ๋„ค์ดํ‹ฐ๋ธŒ disabled ์†์„ฑ ์ ์šฉ
seonghobae Aug 25, 2026
7c1a90e
test(e2e): require explanation for disabled empty actions
seonghobae Aug 25, 2026
13e46e6
fix(a11y): keep empty-action explanation visible
seonghobae Aug 25, 2026
10eba00
docs(a11y): reconcile disabled-action guidance
seonghobae Aug 25, 2026
825a472
style(a11y): reuse existing helper text style
seonghobae Aug 25, 2026
710c8a0
test(a11y): scope empty-state help to disabled actions
seonghobae Aug 25, 2026
dff2225
test(a11y): fail stale enabled-state action guidance
seonghobae Aug 25, 2026
fe5e01a
fix(a11y): scope empty-state help to unavailable actions
seonghobae Aug 25, 2026
5306bb5
test(a11y): verify state-scoped unavailable guidance
seonghobae Aug 25, 2026
dcd068e
test(a11y): require reusable stylesheet for empty-state help
seonghobae Aug 25, 2026
3801c4e
refactor(a11y): move empty-state styling into shared CSS
seonghobae Aug 25, 2026
8684e17
fix(a11y): ship empty-state help rules in shared CSS
seonghobae Aug 25, 2026
8e25ebd
test(a11y): require disabled-action explanation semantics
seonghobae Aug 25, 2026
980095d
test(a11y): require live and scoped disabled-action descriptions
seonghobae Aug 25, 2026
a225253
fix(a11y): announce task-dependent availability changes
seonghobae Aug 25, 2026
c83f3df
fix(a11y): sync empty-state action descriptions
seonghobae Aug 26, 2026
0f4fc8f
test(a11y): reproduce missing empty-state live announcement
seonghobae Aug 26, 2026
e4998a3
fix(a11y): announce task-dependent action availability
seonghobae Aug 26, 2026
4f721c7
๐ŸŽจ Palette: [์ ‘๊ทผ์„ฑ] ๋นˆ ์ƒํƒœ์˜ ๋ฒ„ํŠผ ๋น„ํ™œ์„ฑํ™” ์‹œ ๋„ค์ดํ‹ฐ๋ธŒ disabled ์†์„ฑ ์ ์šฉ
seonghobae Aug 26, 2026
628a8e0
๐ŸŽจ Palette: [์ ‘๊ทผ์„ฑ] ๋นˆ ์ƒํƒœ์˜ ๋ฒ„ํŠผ ๋น„ํ™œ์„ฑํ™” ์‹œ ๋„ค์ดํ‹ฐ๋ธŒ disabled ์†์„ฑ ์ ์šฉ
seonghobae Aug 26, 2026
7e7f30f
๐ŸŽจ Palette: Revert #609 changes to keep PR focused
seonghobae Aug 26, 2026
315abd9
test(a11y): regress disabled action guidance
seonghobae Aug 26, 2026
3dc08a8
fix(a11y): restore actionable disabled-state guidance
seonghobae Aug 26, 2026
c52a2f0
revert(a11y): preserve protected develop semantics
seonghobae Aug 26, 2026
6ae33f3
fix(a11y): restore disabled-state guidance contracts
seonghobae Aug 26, 2026
e89bb31
๐ŸŽจ Palette: Fix empty state disabled logic and test
seonghobae Aug 26, 2026
47f024b
๐ŸŽจ Palette: Fix empty state disabled logic and test
seonghobae Aug 26, 2026
0d686bf
test(a11y): require semantic help visibility state
seonghobae Aug 27, 2026
a5f9583
test(a11y): keep visibility contract behavior-based
seonghobae Aug 27, 2026
a2dc7d2
๐ŸŽจ Palette: Fix task-dependent accessibility regression
seonghobae Aug 27, 2026
c162798
test: align empty-state accessibility regression
seonghobae Aug 27, 2026
818651c
fix(a11y): synchronize empty action help visibility
seonghobae Aug 28, 2026
0544300
๐ŸŽจ Palette: ๋นˆ ์ƒํƒœ ์•ก์…˜์˜ ์ ‘๊ทผ์„ฑ ๊ฐœ์„ 
seonghobae Aug 28, 2026
73229f6
๐ŸŽจ Palette: ๋นˆ ์ƒํƒœ ์•ก์…˜์˜ ์ ‘๊ทผ์„ฑ ๊ฐœ์„ 
seonghobae Aug 28, 2026
b5f3653
test(a11y): reject unsolicited initial empty-state announcement
seonghobae Aug 28, 2026
037ea49
๐ŸŽจ Palette: ๋นˆ ์ƒํƒœ ์•ก์…˜์˜ ์ ‘๊ทผ์„ฑ ๊ฐœ์„ 
seonghobae Aug 28, 2026
c978c1a
๐ŸŽจ Palette: ๋นˆ ์ƒํƒœ ์•ก์…˜์˜ ์ ‘๊ทผ์„ฑ ๊ฐœ์„ 
seonghobae Aug 28, 2026
f270fcd
๐ŸŽจ Palette: ๋นˆ ์ƒํƒœ ์•ก์…˜์˜ ์ ‘๊ทผ์„ฑ ๊ฐœ์„ 
seonghobae Aug 28, 2026
e73e896
fix: keep empty-state actions in document flow
seonghobae Aug 28, 2026
e72a061
trigger opencode review
seonghobae Aug 28, 2026
1b82c16
fix: synchronize empty-state help visibility
seonghobae Aug 28, 2026
83b8949
test: align empty-state action contract
seonghobae Aug 28, 2026
b57902f
trigger opencode review for current head
seonghobae Aug 28, 2026
1733a1e
fix: restore accessibility fixes after review trigger regression
seonghobae Aug 28, 2026
6f13026
Acknowledge code review
seonghobae Aug 28, 2026
0f8960a
fix: disable task actions before app bootstrap
seonghobae Aug 28, 2026
43fb971
Acknowledge final code review
seonghobae Aug 28, 2026
e7b11b6
perf: preload cloud modules
seonghobae Aug 28, 2026
e48f3f6
fix: cover initial task action state
seonghobae Aug 28, 2026
9cb3460
Acknowledge code review on latest head
seonghobae Aug 28, 2026
2d7448c
fix: restore initial task action availability contract
seonghobae Aug 28, 2026
a32ed71
Acknowledge code review
seonghobae Aug 28, 2026
28a2178
fix: disable task actions before hydration
seonghobae Aug 28, 2026
a2b08f0
test: guard empty-state action layout
seonghobae Aug 28, 2026
f52d093
fix(a11y): align empty action contracts
seonghobae Aug 29, 2026
f705a95
Acknowledge code review
seonghobae Aug 29, 2026
b5e1d10
Acknowledge final code review on latest head
seonghobae Aug 29, 2026
d688d11
Acknowledge code review
seonghobae Aug 29, 2026
6cdb82e
Acknowledge final code review
seonghobae Aug 29, 2026
47c0429
Acknowledge code review
seonghobae Aug 29, 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
10 changes: 7 additions & 3 deletions .jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@
**Learning:** Consistently adding `title` attributes with keyboard shortcut hints to primary actions (e.g., `(Enter)`) and cancel actions (e.g., `(Esc)`) improves discoverability, but `title` alone is not reliably exposed to keyboard and screen-reader users.
**Action:** Include keyboard shortcut hints in `title` attributes and pair them with `aria-keyshortcuts` for primary and cancel buttons when implementing or updating forms and dialogs.

## 2026-06-27 - Replace native disabled with aria-disabled for action buttons
**Learning:** Native `disabled` attributes swallow all DOM events, including clicks, and prevent focus. This breaks keyboard accessibility because users tabbing through the page skip the element entirely, and it prevents click handlers from showing helpful toast messages explaining why an action is unavailable.
**Action:** Use `aria-disabled="true"` instead of `disabled` for interactive buttons when the UI should preserve focusability or show inline feedback. Control visual presentation with `[aria-disabled="true"]` in CSS and guard the click handler by checking `getAttribute('aria-disabled') === 'true'` before preventing the action and showing feedback.
## 2026-06-27 - Choose native disabled vs aria-disabled based on explanation delivery
**Learning:** Native `disabled` removes a button from focus and suppresses normal pointer/click interaction; `aria-disabled="true"` preserves interaction semantics so guarded handlers can explain why an action is unavailable. Neither pattern is universally preferable: the choice depends on whether the blocked reason and recovery action remain independently perceivable.
**Action:** Use native `disabled` together with `aria-disabled="true"` when the action must leave the tab order and the reason/recovery step is exposed through persistent nearby content. Use `aria-disabled="true"` without native `disabled` when the control itself must remain focusable/clickable to provide guarded feedback. In both cases, never rely on `title` alone for the explanation.

## 2026-06-28 - Keyboard Shortcut Hints
**Learning:** Keyboard shortcut hints are more useful when they are discoverable to both mouse and assistive-technology users; `title` alone is hover-driven and unreliable for screen readers.
Expand Down Expand Up @@ -115,3 +115,7 @@
## $(date +%Y-%m-%d) - Prevent accidental data loss in inline editors
**Learning:** Forms that take a long time to fill out (like a WBS editor) are prone to accidental closure by users pressing `Escape` or clicking cancel. This causes immediate data loss without any warning, resulting in frustration.
**Action:** When working on editors that can be dismissed, track whether the user has modified any fields compared to their initial state. If there are changes, intercept the close action and present a confirmation dialog (`window.confirm`) to ensure they really want to discard their edits. Bypass this for intentional saves or explicit data overrides.

## 2026-08-25 - [์ ‘๊ทผ์„ฑ] ๋„ค์ดํ‹ฐ๋ธŒ disabled์—๋Š” ๋…๋ฆฝ์ ์ธ ์„ค๋ช… ๊ฒฝ๋กœ๊ฐ€ ํ•„์š”ํ•จ
**Learning:** `aria-disabled="true"`์™€ ๋„ค์ดํ‹ฐ๋ธŒ `disabled`๋ฅผ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜๋ฉด ๋น„ํ™œ์„ฑํ™” ์ƒํƒœ๋ฅผ ๋ช…ํ™•ํ•˜๊ฒŒ ์ „๋‹ฌํ•˜๊ณ  ๋ฒ„ํŠผ์„ ํƒญ ์ˆœ์„œ์™€ ์ผ๋ฐ˜ ํด๋ฆญ ๊ฒฝ๋กœ์—์„œ ์ œ์™ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋Ÿฌ๋‚˜ ๋„ค์ดํ‹ฐ๋ธŒ `disabled`๋Š” ๋ฒ„ํŠผ ์ž์ฒด์˜ `title`/ํด๋ฆญ ํ”ผ๋“œ๋ฐฑ์„ ์‹ ๋ขฐํ•  ์ˆ˜ ์—†๊ฒŒ ๋งŒ๋“ค๋ฏ€๋กœ, ์‚ฌ์šฉ์ž๊ฐ€ ๋น„ํ™œ์„ฑํ™” ์ด์œ ์™€ ๋ณต๊ตฌ ๋ฐฉ๋ฒ•์„ ๋‹ค๋ฅธ ๊ฒฝ๋กœ๋กœ ํ™•์ธํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
**Action:** ๋„ค์ดํ‹ฐ๋ธŒ `disabled`๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒฝ์šฐ `aria-disabled`๋„ ๋™๊ธฐํ™”ํ•˜๊ณ , ๋ฒ„ํŠผ๊ณผ `aria-describedby`๋กœ ์—ฐ๊ฒฐ๋œ ์ง€์†์ ์œผ๋กœ ๋ณด์ด๋Š” ์„ค๋ช…์— ๋น„ํ™œ์„ฑํ™” ์ด์œ ์™€ ๋‹ค์Œ ํ–‰๋™์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. ๋ฒ„ํŠผ ์ž์ฒด์˜ `title` ๋˜๋Š” ์ฐจ๋‹จ๋œ ํด๋ฆญ ํ† ์ŠคํŠธ๋งŒ์„ ์„ค๋ช… ๊ฒฝ๋กœ๋กœ ์‚ฌ์šฉํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
37 changes: 18 additions & 19 deletions app.js
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const STORAGE_KEY = 'scopeweave:planner-state:v1';
const DEFAULT_PROJECT_NAME = 'ScopeWeave Planner';
const MAX_PROJECT_NAME_LENGTH = 120;
const MAX_BASE_DATE_LENGTH = 10;
const TASK_DEPENDENT_ACTIONS_UNAVAILABLE_MESSAGE = '์ž‘์—…์ด ์—†์–ด CSV ๋‚ด๋ณด๋‚ด๊ธฐ์™€ ๊ฐ„ํŠธ์ฐจํŠธ๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ตœ์ƒ์œ„ ์ž‘์—…์„ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ CSV๋ฅผ ๊ฐ€์ ธ์˜ค์„ธ์š”.';
Comment thread
seonghobae marked this conversation as resolved.
const OWNER_COLORS = [
'#3f51b5', '#8e24aa', '#d81b60', '#ef6c00', '#6d4c41',
'#00897b', '#1e88e5', '#3949ab', '#7cb342', '#f4511e',
Expand Down Expand Up @@ -224,7 +225,8 @@ const elements = {
closeGanttButton: document.getElementById('close-gantt'),
connectJsonSyncButton: document.getElementById('connect-json-sync'),
syncStatus: document.getElementById('sync-status'),
toast: document.getElementById('toast')
toast: document.getElementById('toast'),
taskDependentActionsStatus: document.getElementById('task-dependent-actions-status')
};

async function bootstrap() {
Expand Down Expand Up @@ -303,24 +305,10 @@ function bindHeaderEvents(persistAndRenderMetadata) {
elements.baseDateInput.addEventListener('blur', persistAndRenderMetadata.flush);

elements.addRootButton.addEventListener('click', () => openEditor({ mode: 'create', parentId: null, depth: 1, insertAfterId: getLastRootTaskId() }));
elements.exportCsvButton.addEventListener('click', (e) => {
if (elements.exportCsvButton.getAttribute('aria-disabled') === 'true') {
e.preventDefault();
showToast('๋‚ด๋ณด๋‚ผ ์ž‘์—…์ด ์—†์Šต๋‹ˆ๋‹ค. ํ•˜๋‹จ์˜ ๋ฒ„ํŠผ์„ ํ†ตํ•ด ์ž‘์—…์„ ์ถ”๊ฐ€ํ•ด์ฃผ์„ธ์š”.');
return;
}
exportCsv();
});
elements.exportCsvButton.addEventListener('click', exportCsv);
elements.importCsvButton.addEventListener('click', () => elements.csvFileInput.click());
elements.csvFileInput.addEventListener('change', handleCsvImport);
elements.openGanttButton.addEventListener('click', (e) => {
if (elements.openGanttButton.getAttribute('aria-disabled') === 'true') {
e.preventDefault();
showToast('๊ฐ„ํŠธ ์ฐจํŠธ๋กœ ํ‘œ์‹œํ•  ์ž‘์—…์ด ์—†์Šต๋‹ˆ๋‹ค. ์ž‘์—…์„ ๋จผ์ € ์ถ”๊ฐ€ํ•ด์ฃผ์„ธ์š”.');
return;
}
openGanttModal();
});
elements.openGanttButton.addEventListener('click', openGanttModal);
Comment thread
seonghobae marked this conversation as resolved.
elements.closeGanttButton.addEventListener('click', closeGanttModal);
elements.ganttModal.addEventListener('click', (event) => {
if (event.target.dataset.closeModal === 'true') {
Comment thread
seonghobae marked this conversation as resolved.
Expand Down Expand Up @@ -530,15 +518,26 @@ function renderAll() {
const rows = [];

const hasTasks = state.tasks.length > 0;
const taskDependentActionsStatus = hasTasks ? '' : TASK_DEPENDENT_ACTIONS_UNAVAILABLE_MESSAGE;
if (elements.taskDependentActionsStatus.textContent !== taskDependentActionsStatus) {
elements.taskDependentActionsStatus.textContent = taskDependentActionsStatus;
}
Comment thread
seonghobae marked this conversation as resolved.

if (!hasTasks) {
elements.exportCsvButton.setAttribute('aria-disabled', 'true');
elements.openGanttButton.setAttribute('aria-disabled', 'true');
elements.exportCsvButton.disabled = true;
elements.openGanttButton.disabled = true;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
elements.exportCsvButton.setAttribute('aria-describedby', 'task-dependent-actions-help');
elements.openGanttButton.setAttribute('aria-describedby', 'task-dependent-actions-help');
} else {
elements.exportCsvButton.removeAttribute('aria-disabled');
elements.openGanttButton.removeAttribute('aria-disabled');
elements.exportCsvButton.disabled = false;
elements.openGanttButton.disabled = false;
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
elements.exportCsvButton.removeAttribute('aria-describedby');
elements.openGanttButton.removeAttribute('aria-describedby');
}
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
elements.exportCsvButton.title = hasTasks ? '' : '๋‚ด๋ณด๋‚ผ ์ž‘์—…์ด ์—†์Šต๋‹ˆ๋‹ค. ํ•˜๋‹จ์˜ ๋ฒ„ํŠผ์„ ํ†ตํ•ด ์ž‘์—…์„ ์ถ”๊ฐ€ํ•ด์ฃผ์„ธ์š”.';
elements.openGanttButton.title = hasTasks ? '' : '๊ฐ„ํŠธ ์ฐจํŠธ๋กœ ํ‘œ์‹œํ•  ์ž‘์—…์ด ์—†์Šต๋‹ˆ๋‹ค. ์ž‘์—…์„ ๋จผ์ € ์ถ”๊ฐ€ํ•ด์ฃผ์„ธ์š”.';
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

// โšก Bolt: Cache parent IDs to convert O(N^2) render loop to O(N)
cachedHasChildrenSet.clear();
Expand Down
8 changes: 6 additions & 2 deletions 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 @@ -88,9 +90,11 @@ <h1>ScopeWeave Planner</h1>

<footer class="bottom-action-bar">
<button id="add-root-task" type="button" class="primary-button">์ตœ์ƒ์œ„ ์ž‘์—… ์ถ”๊ฐ€</button>
<button id="export-csv" type="button" class="secondary-button">CSV ๋‚ด๋ณด๋‚ด๊ธฐ</button>
<button id="export-csv" type="button" class="secondary-button" disabled aria-disabled="true" aria-describedby="task-dependent-actions-help">CSV ๋‚ด๋ณด๋‚ด๊ธฐ</button>
<button id="import-csv" type="button" class="secondary-button">CSV ๊ฐ€์ ธ์˜ค๊ธฐ</button>
<button id="open-gantt" type="button" class="secondary-button" aria-haspopup="dialog" aria-controls="gantt-modal">๊ฐ„ํŠธ์ฐจํŠธ๋ณด๊ธฐ</button>
<button id="open-gantt" type="button" class="secondary-button" aria-haspopup="dialog" aria-controls="gantt-modal" disabled aria-disabled="true" aria-describedby="task-dependent-actions-help">๊ฐ„ํŠธ์ฐจํŠธ๋ณด๊ธฐ</button>
<p id="task-dependent-actions-help" class="meta-label">์ž‘์—…์ด ์—†์œผ๋ฉด CSV ๋‚ด๋ณด๋‚ด๊ธฐ์™€ ๊ฐ„ํŠธ์ฐจํŠธ๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ตœ์ƒ์œ„ ์ž‘์—…์„ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ CSV๋ฅผ ๊ฐ€์ ธ์˜ค์„ธ์š”.</p>
<span id="task-dependent-actions-status" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></span>
</footer>
</div>

Expand Down
10 changes: 10 additions & 0 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,16 @@ select[data-inline-progress]:focus {
margin-top: auto;
}

#task-dependent-actions-help {
display: none;
flex-basis: 100%;
margin: 0;
}

#open-gantt[aria-disabled="true"] + #task-dependent-actions-help {
display: block;
}

.toast {
position: fixed;
right: 32px;
Expand Down
8 changes: 6 additions & 2 deletions tests/e2e/scopeweave.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,14 @@ test.describe('ScopeWeave Planner', () => {
await expect(page.locator('.table-empty')).toContainText('๋“ฑ๋ก๋œ ์ž‘์—…์ด ์—†์Šต๋‹ˆ๋‹ค');
await expect(page.locator('.table-empty').getByRole('button', { name: '์ตœ์ƒ์œ„ ์ž‘์—… ์ถ”๊ฐ€' })).toBeVisible();
await expect(page.locator('.table-empty').getByRole('button', { name: 'CSV ๊ฐ€์ ธ์˜ค๊ธฐ' })).toBeVisible();
await expect(page.getByRole('button', { name: 'CSV ๋‚ด๋ณด๋‚ด๊ธฐ' })).toBeDisabled();
await expect(page.getByRole('button', { name: 'CSV ๋‚ด๋ณด๋‚ด๊ธฐ' })).toHaveAttribute('aria-disabled', 'true');
await expect(page.getByRole('button', { name: 'CSV ๋‚ด๋ณด๋‚ด๊ธฐ' })).toHaveAttribute('title', '๋‚ด๋ณด๋‚ผ ์ž‘์—…์ด ์—†์Šต๋‹ˆ๋‹ค. ํ•˜๋‹จ์˜ ๋ฒ„ํŠผ์„ ํ†ตํ•ด ์ž‘์—…์„ ์ถ”๊ฐ€ํ•ด์ฃผ์„ธ์š”.');
await expect(page.getByRole('button', { name: 'CSV ๋‚ด๋ณด๋‚ด๊ธฐ' })).toHaveAttribute('aria-describedby', 'task-dependent-actions-help');
await expect(page.locator('#task-dependent-actions-help')).toBeVisible();
await expect(page.locator('#task-dependent-actions-help')).toContainText('์ž‘์—…์„ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ CSV๋ฅผ ๊ฐ€์ ธ์˜ค์„ธ์š”');
await expect(page.getByRole('button', { name: '๊ฐ„ํŠธ์ฐจํŠธ๋ณด๊ธฐ' })).toBeDisabled();
await expect(page.getByRole('button', { name: '๊ฐ„ํŠธ์ฐจํŠธ๋ณด๊ธฐ' })).toHaveAttribute('aria-disabled', 'true');
await expect(page.getByRole('button', { name: '๊ฐ„ํŠธ์ฐจํŠธ๋ณด๊ธฐ' })).toHaveAttribute('title', '๊ฐ„ํŠธ ์ฐจํŠธ๋กœ ํ‘œ์‹œํ•  ์ž‘์—…์ด ์—†์Šต๋‹ˆ๋‹ค. ์ž‘์—…์„ ๋จผ์ € ์ถ”๊ฐ€ํ•ด์ฃผ์„ธ์š”.');
await expect(page.getByRole('button', { name: '๊ฐ„ํŠธ์ฐจํŠธ๋ณด๊ธฐ' })).toHaveAttribute('aria-describedby', 'task-dependent-actions-help');
});

test('keeps the empty WBS state inside the mobile table viewport', async ({ page }) => {
Expand Down
94 changes: 93 additions & 1 deletion tests/e2e/toast-accessibility.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,103 @@ test('cloud status feedback is visibly rendered as a non-focus-taking live statu

await expect.poll(
() => toast.evaluate((element) => Number.parseFloat(getComputedStyle(element).opacity)),
{ message: 'toast opacity should reach its fully visible transition state' },
{ message: 'advisory status must reach its fully visible transition state' },
).toBeGreaterThanOrEqual(0.99);

await expect.poll(
() => toast.evaluate((element) => document.activeElement === element),
{ message: 'advisory status must not capture keyboard focus' },
).toBe(false);
});

test('disabled empty-state actions expose and announce a reason plus next action', async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem('scopeweave:planner-state:v1', JSON.stringify({
projectName: 'Empty Scope',
baseDate: '2026-04-20',
tasks: [],
}));
});
await page.goto('/');

const exportButton = page.getByRole('button', { name: 'CSV ๋‚ด๋ณด๋‚ด๊ธฐ' });
const ganttButton = page.getByRole('button', { name: '๊ฐ„ํŠธ์ฐจํŠธ๋ณด๊ธฐ' });
const help = page.locator('#task-dependent-actions-help');
const status = page.locator('#task-dependent-actions-status');

await expect(exportButton).toBeDisabled();
await expect(ganttButton).toBeDisabled();
await expect(exportButton).toHaveAttribute('aria-disabled', 'true');
await expect(ganttButton).toHaveAttribute('aria-disabled', 'true');
await expect(exportButton).toHaveAttribute('aria-describedby', 'task-dependent-actions-help');
await expect(ganttButton).toHaveAttribute('aria-describedby', 'task-dependent-actions-help');
await expect(help).not.toHaveAttribute('role', 'status');
await expect(help).toBeVisible();
await expect(help).toContainText('์ž‘์—…์„ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ CSV๋ฅผ ๊ฐ€์ ธ์˜ค์„ธ์š”');
const emptyState = page.locator('.table-empty');
const emptyStateBox = await emptyState.boundingBox();
const actionBarBox = await page.locator('.bottom-action-bar').boundingBox();
expect(emptyStateBox).not.toBeNull();
expect(actionBarBox).not.toBeNull();
expect(emptyStateBox.y + emptyStateBox.height).toBeLessThanOrEqual(actionBarBox.y);
await expect(status).toHaveAttribute('role', 'status');
await expect(status).toHaveAttribute('aria-live', 'polite');
await expect(status).toHaveAttribute('aria-atomic', 'true');
await expect(status).toHaveClass(/\bsr-only\b/);
await expect(status).toHaveText('์ž‘์—…์ด ์—†์–ด CSV ๋‚ด๋ณด๋‚ด๊ธฐ์™€ ๊ฐ„ํŠธ์ฐจํŠธ๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ตœ์ƒ์œ„ ์ž‘์—…์„ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ CSV๋ฅผ ๊ฐ€์ ธ์˜ค์„ธ์š”.');
});

test('task-dependent help disappears and detaches once the actions are available', async ({ page }) => {
await page.goto('/');

const exportButton = page.getByRole('button', { name: 'CSV ๋‚ด๋ณด๋‚ด๊ธฐ' });
const ganttButton = page.getByRole('button', { name: '๊ฐ„ํŠธ์ฐจํŠธ๋ณด๊ธฐ' });
const help = page.locator('#task-dependent-actions-help');
const status = page.locator('#task-dependent-actions-status');

await expect(page.locator('tbody tr[data-task-id]')).not.toHaveCount(0);
await expect(exportButton).toBeEnabled();
await expect(ganttButton).toBeEnabled();
await expect(exportButton).not.toHaveAttribute('aria-disabled', 'true');
await expect(ganttButton).not.toHaveAttribute('aria-disabled', 'true');
await expect(exportButton).not.toHaveAttribute('aria-describedby', 'task-dependent-actions-help');
await expect(ganttButton).not.toHaveAttribute('aria-describedby', 'task-dependent-actions-help');
await expect(help).toBeHidden();
await expect(status).toHaveText('');
});

test('the last task becoming unavailable is announced through an always-present live region', async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem('scopeweave:planner-state:v1', JSON.stringify({
projectName: 'One Task Scope',
baseDate: '2026-04-20',
tasks: [{
id: 'task-only',
parentId: null,
depth: 1,
expanded: true,
isSynthetic: false,
phase: '๊ฒ€์ฆ ๋‹จ๊ณ„',
activity: '',
task: '',
actualProgressStatus: '๋ฏธ์ฐฉ์ˆ˜(0%)',
}],
}));
});
page.on('dialog', (dialog) => dialog.accept());
await page.goto('/');

const status = page.locator('#task-dependent-actions-status');
await expect(status).toHaveAttribute('role', 'status');
await expect(status).toHaveAttribute('aria-live', 'polite');
await expect(status).toHaveAttribute('aria-atomic', 'true');
await expect(status).toHaveText('');

await page.getByRole('button', { name: /์‚ญ์ œ - ๊ฒ€์ฆ ๋‹จ๊ณ„/ }).click();

await expect(page.getByRole('button', { name: 'CSV ๋‚ด๋ณด๋‚ด๊ธฐ' })).toBeDisabled();
await expect(page.getByRole('button', { name: '๊ฐ„ํŠธ์ฐจํŠธ๋ณด๊ธฐ' })).toBeDisabled();
await expect(status).toHaveText('์ž‘์—…์ด ์—†์–ด CSV ๋‚ด๋ณด๋‚ด๊ธฐ์™€ ๊ฐ„ํŠธ์ฐจํŠธ๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ตœ์ƒ์œ„ ์ž‘์—…์„ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ CSV๋ฅผ ๊ฐ€์ ธ์˜ค์„ธ์š”.');
await expect(page.locator('#task-dependent-actions-help')).toBeVisible();
await expect(page.locator('#add-root-task')).toBeFocused();
});
Loading
Loading