Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
0141a13
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Aug 21, 2026
967671a
fix(data): scope document loading feedback per action
seonghobae Aug 24, 2026
c01b5e4
fix(data): show WebDAV source loading feedback
seonghobae Aug 24, 2026
230446a
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Aug 24, 2026
56e15b2
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Aug 24, 2026
142def4
fix(data): scope document action loading feedback
seonghobae Aug 24, 2026
5dc4b5a
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Aug 24, 2026
8f85f9b
fix(data): key document action loading state per action (#1449)
seonghobae Aug 24, 2026
a5699ac
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Aug 24, 2026
6191d8c
fix(data): scope document action loading state
seonghobae Aug 24, 2026
5663547
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Aug 24, 2026
e264e0b
fix(data): scope document action loading state
seonghobae Aug 24, 2026
1d08dd7
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Aug 24, 2026
2d28c95
fix(data): scope document action loading state
seonghobae Aug 24, 2026
9748011
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Aug 24, 2026
207544e
fix(data): scope document action loading state
seonghobae Aug 24, 2026
765809d
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Aug 24, 2026
92f0ffb
fix(data): scope document action loading state
seonghobae Aug 24, 2026
a868cbf
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Aug 24, 2026
af734ff
fix(data): remove stale retrigger note and scope action state
seonghobae Aug 24, 2026
9a036fc
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Aug 24, 2026
a5d0092
fix(data): show loading state for active document action
seonghobae Aug 24, 2026
68d679b
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Aug 24, 2026
6e90f43
fix(data): lock document picker during action
seonghobae Aug 24, 2026
766001d
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Aug 24, 2026
90248b2
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Aug 24, 2026
8aab5d3
Merge branch 'develop' into palette-ux-add-aria-busy-and-loader-to-do…
seonghobae Aug 25, 2026
44190cc
fix(data): make document actions explicit
seonghobae Aug 31, 2026
9f99464
Merge remote-tracking branch 'origin/develop' into codex/pr1449-fix
seonghobae Aug 31, 2026
dee604d
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Aug 31, 2026
9fdb5b3
fix(data): contain WebDAV confirmation focus
seonghobae Aug 31, 2026
be29bc9
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Aug 31, 2026
be339b9
Revert "🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선"
seonghobae Aug 31, 2026
f8fa900
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Aug 31, 2026
31076c0
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Sep 1, 2026
8071931
test(data): follow confirmed WebDAV write flow
seonghobae Sep 5, 2026
254780c
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Sep 5, 2026
63318d9
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Sep 5, 2026
2bccde0
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Sep 5, 2026
416eff3
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Sep 5, 2026
27c8f64
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Sep 5, 2026
22ab290
chore(data): remove CI workaround and parallel gap receipt
seonghobae Sep 5, 2026
f3520f4
fix(data): restore WebDAV confirmation keyboard contract
seonghobae Sep 5, 2026
c5f10e4
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Sep 5, 2026
190fb90
test(data): restore WebDAV modal focus contract
seonghobae Sep 5, 2026
364895b
fix(data): preserve WebDAV modal keyboard and focus semantics
seonghobae Sep 5, 2026
2e68813
chore(data): remove parallel CI and gap receipts
seonghobae Sep 5, 2026
e29f7e5
test(data): align WebDAV modal fixture with typed contracts
seonghobae Sep 5, 2026
dfd8ec7
test(data): require true modal WebDAV confirmation boundary
seonghobae Sep 5, 2026
43cf974
fix(data): make WebDAV confirmation truly modal
seonghobae Sep 5, 2026
72f4532
docs(data): trace WebDAV modal accessibility decision
seonghobae Sep 5, 2026
78c690d
test(data): lock repository selection during document actions
seonghobae Sep 5, 2026
517c8e7
fix(data): freeze repository selection during document actions
seonghobae Sep 5, 2026
e2f8187
docs(data): trace pending-action selection invariant
seonghobae Sep 5, 2026
e875707
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Sep 5, 2026
8332dd0
repair(data): adopt concurrent UI delta without regressions
seonghobae Sep 5, 2026
53cbc6c
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Sep 6, 2026
cef0d99
repair(data): preserve reviewed WebDAV contract after concurrent regr…
seonghobae Sep 6, 2026
aadf787
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Sep 6, 2026
6833bd1
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Sep 6, 2026
22fd626
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Sep 6, 2026
e25a399
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Sep 7, 2026
a0862a8
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Sep 7, 2026
ab9cee0
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Sep 7, 2026
fe59ef9
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Sep 7, 2026
36bbd9b
fix(data-ui): restore reviewed WebDAV interaction boundary
seonghobae Sep 7, 2026
3760f65
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Sep 7, 2026
0cd6f17
fix(data-ui): restore reviewed WebDAV contract after palette regression
seonghobae Sep 7, 2026
f53f50c
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Sep 7, 2026
ce5ea43
fix(data-ui): keep WebDAV write confirmation focus trap
seonghobae Sep 7, 2026
3eb1465
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Sep 7, 2026
34e618e
fix(data-ui): keep WebDAV confirmation keyboard boundary
seonghobae Sep 7, 2026
0bafaa7
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Sep 7, 2026
3c246a8
fix(data-ui): restore WebDAV confirmation after palette overlay
seonghobae Sep 7, 2026
c64e753
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Sep 7, 2026
2120c8b
fix(data-ui): restore WebDAV confirmation after palette overlay
seonghobae Sep 7, 2026
f38796a
🎨 Palette: 데이터 저장소 버튼들의 로딩 상태 및 접근성 개선
seonghobae Sep 7, 2026
31ecc63
fix(data-ui): restore WebDAV confirmation after palette overlay
seonghobae Sep 7, 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
46 changes: 46 additions & 0 deletions docs/doctoring/webdav-write-confirmation-accessibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# WebDAV write-confirmation accessibility boundary

Status: **Proposed** until the exact integrated PR head has current frontend/browser evidence and an independent accessibility review.

## Problem

The Data workspace can dispatch an explicit customer WebDAV write after the user selects a server-authoritative workspace document and a write-capable WebDAV source. The confirmation surface therefore changes customer-owned source-of-truth data when confirmed. A visual warning alone is insufficient: keyboard and assistive-technology users must receive the same bounded decision surface, and `aria-modal="true"` must not claim modality while pointer or keyboard interaction with the underlying page remains possible.

A document action also captures the selected repository asset and, for WebDAV materialization, the selected WebDAV source before the request is dispatched. Allowing either selection to change while that action remains pending would let the completion/status UI appear beside a different document or source than the one actually submitted.

## Constraints and decision

Naruon keeps the confirmation as an `alertdialog` because it interrupts an explicit external-write action and requires a binary decision. The interaction contract is:

- `aria-labelledby` and `aria-describedby` identify the warning and consequence;
- `aria-modal="true"` is used only with a full-viewport modal layer that visually obscures and blocks pointer interaction with the underlying page;
- opening moves focus to the non-destructive **취소** action;
- Tab and Shift+Tab remain within the two confirmation actions;
- Escape cancels without dispatching the provider write;
- body scrolling is locked while the confirmation is open;
- cancel and confirmed completion restore focus to the invoking WebDAV write control;
- while any document action is pending, repository-asset and WebDAV-account selection are disabled so the visible selection cannot drift away from the request context; and
- the confirmed provider request remains the existing server-authoritative, conflict-aware WebDAV materialization path rather than a client-side write.

An inline `alertdialog` with `aria-modal="true"` was rejected. WAI-ARIA APG explicitly warns that `aria-modal="true"` is appropriate only when application code prevents interaction outside the dialog and the visual presentation also obscures outside content. A non-modal inline confirmation was also rejected because this action mutates a customer-owned external source and the existing product contract intentionally requires an interruptive confirmation.

The implementation follows the same repository accessibility shape already used by `frontend/src/components/SourceDrawer.tsx`: visible modal layer, focus entry, keyboard containment, Escape handling, body scroll lock, and focus restoration. It does not introduce a new design-system dependency or claim that a shadcn/ui source component is being used.

## Test and change traceability

- `190fb909627074d15b03f750c72fec556510496c` introduced a focused component regression before the focus/keyboard repair.
- `364895b7a246f35e0faacba163760315e2f78acd` restored focus entry, cyclic Tab/Shift+Tab, Escape cancellation, and invoker focus restoration while preserving the newer per-action busy-state changes.
- `2e6881395e204083041aa92122a7df233d8b3edb` removed unrelated `.jules/codeql.md` and product-gap receipt changes from this product UI writer.
- `e29f7e58ff1dbc4cb35c20d053016090de9a26ea` aligned the focused fixture with the typed WebDAV/account and repository-asset contracts.
- `dfd8ec72a3f23e257b42853fbff4eb8d03350a7c` strengthened the regression so `aria-modal` must correspond to a full-viewport interaction boundary and body scroll lock.
- `43cf974a50d69f3109893f73a53a97d40dd1d70f` implemented the full-viewport modal layer and scroll lock while retaining the focus contract.
- `78c690de92e2767834c6fac99e331c263410063e` added the source-order regression that requires WebDAV-account and repository-asset selection to remain frozen during a pending document action.
- `517c8e7ccd6081250ebbceba82dc1dac2fb533aa` disabled WebDAV-account selection and made repository-asset mouse/keyboard selection inert with `aria-disabled` while the action is pending.

These commits establish source-order RED→repair provenance. They are not, by themselves, exact-head GREEN. Promotion from Proposed requires current-head frontend/component and browser/E2E checks without warning-class failures, plus the normal repository review gates.

## References

World Wide Web Consortium, Web Accessibility Initiative. (n.d.). *Alert and message dialogs pattern*. WAI-ARIA Authoring Practices Guide. https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/

World Wide Web Consortium, Web Accessibility Initiative. (n.d.). *Dialog (modal) pattern*. WAI-ARIA Authoring Practices Guide. https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
7 changes: 4 additions & 3 deletions frontend/scripts/full-product-ui-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1253,11 +1253,12 @@ async function runCriticalInteractionSmoke(page, routeSpec, viewportSpec) {
}

if (routeSpec.name === "data") {
await page.getByRole("button", { name: "임베딩 재생성 의도", exact: true }).click();
await page.getByRole("button", { name: "임베딩 재생성 요청 등록", exact: true }).click();
await page.getByText("Embedding regeneration intent recorded", { exact: false }).waitFor({ state: "visible", timeout: 10_000 });
await page.getByRole("button", { name: "HWP 변환 의도", exact: true }).click();
await page.getByRole("button", { name: "HWP 변환 요청 등록", exact: true }).click();
await page.getByText("HWP conversion intent recorded", { exact: false }).waitFor({ state: "visible", timeout: 10_000 });
await page.getByRole("button", { name: "WebDAV 문서 실행 요청", exact: true }).click();
await page.getByRole("button", { name: "고객 WebDAV에 문서 쓰기", exact: true }).click();
await page.getByRole("alertdialog").getByRole("button", { name: "WebDAV 쓰기 확인", exact: true }).click();
await page.getByText("WebDAV materialization executed by the connector.", { exact: false }).waitFor({ state: "visible", timeout: 10_000 });
await page.getByText("외부 쓰기 실행됨", { exact: false }).waitFor({ state: "visible", timeout: 10_000 });
await page.getByRole("button", { name: "WebDAV 반영 의도 점검", exact: true }).click();
Expand Down
174 changes: 172 additions & 2 deletions frontend/src/app/data/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1903,7 +1903,7 @@ describe("DataPage", () => {
expect(container.textContent).toContain("decision-note.md");
expect(container.textContent).toContain("의도만 기록");

for (const buttonLabel of ["재파싱 실행", "임베딩 재생성 의도", "HWP 변환 의도"]) {
for (const buttonLabel of ["파싱 완료로 표시", "임베딩 재생성 요청 등록", "HWP 변환 요청 등록"]) {
const button = Array.from(container.querySelectorAll("button")).find((candidate) =>
candidate.textContent?.includes(buttonLabel),
);
Expand All @@ -1919,12 +1919,28 @@ describe("DataPage", () => {
expect(container.textContent).toContain("HWP conversion intent recorded");

const materializeButton = Array.from(container.querySelectorAll("button")).find((candidate) =>
candidate.textContent?.includes("WebDAV 문서 실행 요청"),
candidate.textContent?.includes("고객 WebDAV에 문서 쓰기"),
);
expect(materializeButton).toBeDefined();
await act(async () => {
materializeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(fetchMock.mock.calls.some(([input]) => String(input).includes("webdav-materialization-intent"))).toBe(false);
expect(container.textContent).toContain("운영 문서 원본에 현재 문서를 기록합니다");

const cancelWriteButton = Array.from(container.querySelectorAll("button")).find((candidate) => candidate.textContent === "취소");
await act(async () => {
cancelWriteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(fetchMock.mock.calls.some(([input]) => String(input).includes("webdav-materialization-intent"))).toBe(false);

await act(async () => {
materializeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const confirmWriteButton = Array.from(container.querySelectorAll("button")).find((candidate) => candidate.textContent === "WebDAV 쓰기 확인");
await act(async () => {
confirmWriteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});

const materializeCall = fetchMock.mock.calls.find(([input]) => (
String(input) === "/api/data/documents/doc_repository_ready/webdav-materialization-intent"
Expand Down Expand Up @@ -1965,6 +1981,160 @@ describe("DataPage", () => {
expect(container.textContent).not.toContain("runner_req_data_doc_1");
});

it("shows loading feedback only on the document action that is pending", async () => {
const fetchMock = mockWebdavFetch();
vi.stubGlobal("fetch", fetchMock);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

await act(async () => {
root?.render(<DataPage />);
});

const originalFetch = fetchMock.getMockImplementation();
const reparsePath = "/api/data/documents/doc_repository_ready/reparse";
let releaseReparse: (() => void) | null = null;
const pendingReparse = new Promise<ReturnType<typeof jsonResponse>>((resolve) => {
releaseReparse = () => resolve(jsonResponse({
document_id: "doc_repository_ready",
workspace_id: "workspace-org-acme",
document_name: "roadmap.md",
document_type: "text/markdown",
document_status: "parsed",
content_chars: 128,
provider_write_executed: false,
provenance: "server-authoritative",
audit_event: "data.document.reparsed",
message: "Document parse metadata refreshed in the signed workspace scope.",
}));
});
fetchMock.mockImplementation((input, init) => {
if (String(input) === reparsePath) return pendingReparse;
return originalFetch?.(input, init) ?? Promise.reject(new Error("missing fetch implementation"));
});

const findButton = (label: string) => Array.from(container?.querySelectorAll("button") ?? []).find((candidate) => (
candidate.textContent?.includes(label)
));
const reparseButton = findButton("파싱 완료로 표시");
const embeddingButton = findButton("임베딩 재생성 요청 등록");
const hwpButton = findButton("HWP 변환 요청 등록");
const materializeButton = findButton("고객 WebDAV에 문서 쓰기");
expect(reparseButton).toBeDefined();
expect(embeddingButton).toBeDefined();
expect(hwpButton).toBeDefined();
expect(materializeButton).toBeDefined();

await act(async () => {
reparseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});

expect(reparseButton?.getAttribute("aria-busy")).toBe("true");
expect(reparseButton?.textContent).toContain("상태 변경 중");
const documentFileInput = container?.querySelector('input[accept=".txt,.md,.markdown,text/plain,text/markdown"]');
expect(documentFileInput).toHaveProperty("disabled", true);
for (const button of [embeddingButton, hwpButton, materializeButton]) {
expect(button?.getAttribute("aria-busy")).toBe("false");
expect(button?.disabled).toBe(true);
expect(button?.textContent).not.toContain("요청 등록 중");
expect(button?.textContent).not.toContain("WebDAV에 쓰는 중");
}

await act(async () => {
releaseReparse?.();
await pendingReparse;
await Promise.resolve();
await Promise.resolve();
});
});

it.each([
[409, "문서 처리가 아직 끝나지 않았습니다. 처리가 완료된 후 재시도하세요."],
[422, "쓸 수 있는 문서 내용 또는 WebDAV 저장소를 확인한 후 재시도하세요."],
[401, "로그인이 만료되었습니다. 다시 로그인한 후 재시도하세요."],
[500, "문서 작업을 완료하지 못했습니다. 잠시 후 재시도하세요."],
])("shows a bounded actionable document error for HTTP %s and allows retry", async (status, message) => {
const fetchMock = mockWebdavFetch();
const originalFetch = fetchMock.getMockImplementation();
let attempts = 0;
fetchMock.mockImplementation((input, init) => {
if (String(input).endsWith("/doc_repository_ready/reparse")) {
attempts += 1;
return Promise.resolve(jsonResponse({}, false, status));
}
return originalFetch?.(input, init) ?? Promise.reject(new Error("missing fetch implementation"));
});
vi.stubGlobal("fetch", fetchMock);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

await act(async () => {
root?.render(<DataPage />);
await Promise.resolve();
await Promise.resolve();
});
const findReparseButton = () => Array.from(container?.querySelectorAll("button") ?? []).find((candidate) => candidate.textContent?.includes("파싱 완료로 표시"));
await act(async () => {
findReparseButton()?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
await Promise.resolve();
});
expect(container.textContent).toContain(message);
expect(findReparseButton()?.disabled).toBe(false);

await act(async () => {
findReparseButton()?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
await Promise.resolve();
});
expect(attempts).toBe(2);
});

it("reports a connector failure after explicit WebDAV write confirmation", async () => {
const fetchMock = mockWebdavFetch();
const originalFetch = fetchMock.getMockImplementation();
fetchMock.mockImplementation((input, init) => {
if (String(input).endsWith("/doc_repository_ready/webdav-materialization-intent")) {
return Promise.resolve(jsonResponse({
document_id: "doc_repository_ready",
workspace_id: "workspace-org-acme",
document_name: "roadmap.md",
document_type: "text/markdown",
document_status: "parsed",
content_chars: 128,
provider_write_executed: false,
provenance: "server-authoritative",
audit_event: "data.document.webdav_materialization.dispatch_failed",
error_code: "connector_unavailable",
message: "dispatch failed",
}));
}
return originalFetch?.(input, init) ?? Promise.reject(new Error("missing fetch implementation"));
});
vi.stubGlobal("fetch", fetchMock);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);

await act(async () => {
root?.render(<DataPage />);
await Promise.resolve();
await Promise.resolve();
});
const writeButton = Array.from(container.querySelectorAll("button")).find((candidate) => candidate.textContent?.includes("고객 WebDAV에 문서 쓰기"));
await act(async () => writeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
const confirmButton = Array.from(container.querySelectorAll("button")).find((candidate) => candidate.textContent === "WebDAV 쓰기 확인");
await act(async () => {
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await Promise.resolve();
await Promise.resolve();
});
expect(container.textContent).toContain("WebDAV 쓰기를 완료하지 못했습니다. 연결 상태를 확인한 후 재시도하거나 관리자에게 문의하세요.");
expect(container.textContent).not.toContain("connector_unavailable");
});

it("loads signed data quality surface without public identity headers", async () => {
const fetchMock = mockWebdavFetch();
vi.stubGlobal("fetch", fetchMock);
Expand Down
Loading
Loading