Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,9 @@
## $(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-07-15 - Restore keyboard focus after destroying row-level controls
**Learning:** When inline row actions (like edit or add-child) trigger a full table re-render that replaces the DOM, natively relying on `document.activeElement` to restore focus will fail because that node is detached from the document. This pushes screen reader and keyboard users back to the start of the page.
**Action:** When a destructive render occurs, save contextual identifiers (like `taskId` and `triggerAction`) from the original event rather than a DOM reference. After the render cycle completes (using `requestAnimationFrame`), query the newly created DOM for the equivalent element and call `.focus()`.
## 2026-07-15 - ReDoS in JS Regex
**Learning:** ReDoS issues can get flagged by SAST tools (like Semgrep) when using dynamic RegExp strings combined with capturing logic.
**Action:** Instead of dynamically constructing `new RegExp` objects out of inputs to parse strings, use standard String methods like `indexOf` and `substring` to safely extract values from structured text without triggering ReDoS checks.
34 changes: 26 additions & 8 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ function bindTableEvents(renderDraftValidation, updateEditorDraftFromEvent) {
}

if (!event.target.closest('input, select, button, label, .drag-handle')) {
openEditor({ mode: 'edit', targetId: taskId });
openEditor({ mode: 'edit', targetId: taskId, triggerAction: 'row-click' });
}
});

Expand Down Expand Up @@ -1108,7 +1108,7 @@ function handleRowAction(action, taskId) {
}

if (action === 'edit') {
openEditor({ mode: 'edit', targetId: taskId });
openEditor({ mode: 'edit', targetId: taskId, triggerAction: 'edit' });
return;
}

Expand All @@ -1118,7 +1118,7 @@ function handleRowAction(action, taskId) {
return;
}
task.expanded = true;
openEditor({ mode: 'create', parentId: taskId, depth: task.depth + 1, insertAfterId: getLastDescendantId(taskId), draft: createChildDraft(task) });
openEditor({ mode: 'create', parentId: taskId, depth: task.depth + 1, insertAfterId: getLastDescendantId(taskId), draft: createChildDraft(task), triggerAction: 'add-child' });
return;
}

Expand Down Expand Up @@ -1154,8 +1154,12 @@ function handleRowAction(action, taskId) {
}
}

function openEditor({ mode, targetId = null, parentId = null, depth = 1, insertAfterId = null, draft = null }) {
state.previousFocus = document.activeElement;
function openEditor({ mode, targetId = null, parentId = null, depth = 1, insertAfterId = null, draft = null, triggerAction = null }) {
state.previousFocus = {
taskId: targetId || parentId,
action: triggerAction,
element: document.activeElement
};
if (mode === 'edit') {
const task = findTask(targetId);
if (!task) {
Expand Down Expand Up @@ -1209,7 +1213,19 @@ function closeEditor(force = false) {
renderAll();

if (state.previousFocus) {
state.previousFocus.focus();
const focusContext = state.previousFocus;
requestAnimationFrame(() => {
if (focusContext.taskId && focusContext.action && focusContext.action !== 'row-click') {
const button = document.querySelector(`tr[data-task-id="${focusContext.taskId}"] button[data-action="${focusContext.action}"]`);
if (button) {
button.focus();
} else if (focusContext.element && document.body.contains(focusContext.element)) {
focusContext.element.focus();
}
} else if (focusContext.element && document.body.contains(focusContext.element)) {
focusContext.element.focus();
}
});
state.previousFocus = null;
}
}
Expand Down Expand Up @@ -2193,7 +2209,7 @@ function exportJsonArray() {
}

function openGanttModal() {
state.previousFocus = document.activeElement;
state.previousFocus = { element: document.activeElement };
elements.ganttModal.classList.remove('hidden');
renderGantt();
// Focus the modal to handle Escape key properly
Expand All @@ -2203,7 +2219,9 @@ function openGanttModal() {
function closeGanttModal() {
elements.ganttModal.classList.add('hidden');
if (state.previousFocus) {
state.previousFocus.focus();
if (state.previousFocus.element && document.body.contains(state.previousFocus.element)) {
state.previousFocus.element.focus();
}
state.previousFocus = null;
}
}
Expand Down
10 changes: 8 additions & 2 deletions cloud-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -740,8 +740,14 @@ function openReportModal() {
// hand-edited files ever matter.
export function parseMsProjectXml(xml) {
const tag = (block, name) => {
const m = block.match(new RegExp(`<${name}>([^<]*)</${name}>`));
return m ? m[1].trim() : '';
const openTag = `<${name}>`;
const closeTag = `</${name}>`;
const openIndex = block.indexOf(openTag);
if (openIndex === -1) return '';
const start = openIndex + openTag.length;
const closeIndex = block.indexOf(closeTag, start);
if (closeIndex === -1) return '';
return block.substring(start, closeIndex).trim();
};
const unescape = (s) => s
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"')
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"private": true,
"type": "module",
"description": "Production-grade pure HTML/CSS/JS WBS planner",
"packageManager": "pnpm@10.30.3",
"scripts": {
"check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings",
"coverage": "node scripts/ci/static_coverage_evidence.mjs coverage && npm run test:fuzz",
Expand Down
45 changes: 45 additions & 0 deletions tests/e2e/scopeweave.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1333,4 +1333,49 @@ test.describe('ScopeWeave Planner - Palette UX Enhancements', () => {
await expect(backBtn).toHaveAttribute('title', '์ž‘์—… ๋ชฉ๋ก์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ (Esc)');
await expect(backBtn).toHaveAttribute('aria-keyshortcuts', 'Escape');
});

test('restores keyboard focus to row action button after closing inline editor', async ({ page }) => {
// Seed test data with a single task row
await page.addInitScript(() => {
localStorage.setItem('scopeweave:planner-state:v1', JSON.stringify({
projectName: 'Test Project',
baseDate: '2026-07-15',
tasks: [{
id: 'test-task-1',
parentId: null,
depth: 1,
expanded: true,
phase: 'Target Phase',
plannedStartDate: '2026-07-15',
plannedEndDate: '2026-07-20',
actualProgressStatus: '๋ฏธ์ฐฉ์ˆ˜(0%)'
}]
}));
});
await page.goto('./');

const taskRow = page.locator('tr[data-task-id="test-task-1"]');
const editBtn = taskRow.locator('button[data-action="edit"]');

// 1. Focus the edit button programmatically (simulate keyboard nav)
await editBtn.focus();
await expect(editBtn).toBeFocused();

// 2. Press Enter to open the editor
await editBtn.press('Enter');

// 3. Verify editor opens and focus moves into it
const editorPhaseInput = page.locator('input[data-editor-field="phase"]');
await expect(editorPhaseInput).toBeVisible();
await expect(editorPhaseInput).toBeFocused();

// 4. Dismiss editor using Escape
await editorPhaseInput.press('Escape');

// 5. Verify the editor closed
await expect(editorPhaseInput).not.toBeVisible();

// 6. Verify focus returned to the exact original edit button
await expect(editBtn).toBeFocused();
});
});
Loading