From 89d7244cbc2b7c592b541ce9b6d325bf9f91f0d8 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:31:27 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20=EB=B9=84?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=20=EC=9E=91=EC=97=85=20=EB=B2=84=ED=8A=BC?= =?UTF-8?q?=EC=97=90=20=EB=A1=9C=EB=94=A9=20=EC=8A=A4=ED=94=BC=EB=84=88=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `viewer.css`에 접근성을 고려한(reduced-motion) 스피너 스타일(`.spinner`, `@keyframes spin`) 추가 - `viewer.js`에서 비동기 데이터를 가져올 때 버튼에 텍스트만 변경하던 것을 스피너 아이콘과 함께 표시하도록 개선 - UX 인사이트를 `.jules/palette.md`에 기록 --- .jules/palette.md | 3 +++ .../resources/static/assets/viewer/viewer.css | 20 ++++++++++++++++++- .../resources/static/assets/viewer/viewer.js | 2 +- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/.jules/palette.md b/.jules/palette.md index a1633627..1597cb3a 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -1,3 +1,6 @@ ## 2024-05-18 - Disabled and Loading States for Async Actions **Learning:** Adding explicit loading and disabled states to asynchronous action buttons (like "Refresh") provides immediate feedback, reducing user confusion and preventing double-submissions. **Action:** Always ensure that buttons triggering network requests visually indicate the loading state and are disabled until the request completes. +## 2024-05-18 - Loading Indicators for Async Refresh Actions +**Learning:** Text changes (like "Refresh" to "Refreshing...") are often not immediately perceived by users. Adding an explicit, animated loading spinner next to the text provides a universally recognized visual cue that an asynchronous process is underway, reducing impatience and uncertainty. +**Action:** Always include a visual loading indicator (like a spinner) alongside text changes on buttons that trigger network requests, ensuring it is accessible (`aria-hidden="true"` for decorative spinners) and respects reduced motion preferences. diff --git a/src/main/resources/static/assets/viewer/viewer.css b/src/main/resources/static/assets/viewer/viewer.css index 354b2e25..959ed496 100644 --- a/src/main/resources/static/assets/viewer/viewer.css +++ b/src/main/resources/static/assets/viewer/viewer.css @@ -264,6 +264,23 @@ a:hover { } } +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: spin 0.75s linear infinite; + margin-right: 8px; +} + @media (max-width: 768px) { :root { --header-h: 60px; @@ -283,7 +300,8 @@ a:hover { } @media (prefers-reduced-motion: reduce) { - .skeleton { + .skeleton, + .spinner { animation: none; } } diff --git a/src/main/resources/static/assets/viewer/viewer.js b/src/main/resources/static/assets/viewer/viewer.js index e81cf8f2..88903630 100644 --- a/src/main/resources/static/assets/viewer/viewer.js +++ b/src/main/resources/static/assets/viewer/viewer.js @@ -46,7 +46,7 @@ function setLoading(message) { el.liveStatus.textContent = message; el.preview.setAttribute("aria-busy", "true"); el.retryBtn.disabled = true; - el.retryBtn.textContent = "Refreshing..."; + el.retryBtn.innerHTML = 'Refreshing...'; } function showError(message) { From a855cf72aa58afc51c73cc5471862023958982d9 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:39:59 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20XSS=20=EC=B7=A8?= =?UTF-8?q?=EC=95=BD=EC=A0=90=20=EC=A0=9C=EA=B1=B0=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20DOM=20API=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CI 검사에서 보고된 잠재적 XSS 취약점을 해결하기 위해 `viewer.js`의 `innerHTML` 사용을 제거 - `document.createElement()`와 `replaceChildren()`을 사용하여 로딩 스피너와 텍스트를 DOM에 안전하게 삽입하도록 개선 --- src/main/resources/static/assets/viewer/viewer.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/resources/static/assets/viewer/viewer.js b/src/main/resources/static/assets/viewer/viewer.js index 88903630..098de3d4 100644 --- a/src/main/resources/static/assets/viewer/viewer.js +++ b/src/main/resources/static/assets/viewer/viewer.js @@ -46,7 +46,11 @@ function setLoading(message) { el.liveStatus.textContent = message; el.preview.setAttribute("aria-busy", "true"); el.retryBtn.disabled = true; - el.retryBtn.innerHTML = 'Refreshing...'; + + const spinner = document.createElement("span"); + spinner.className = "spinner"; + spinner.setAttribute("aria-hidden", "true"); + el.retryBtn.replaceChildren(spinner, document.createTextNode("Refreshing...")); } function showError(message) { From 88f2611b2faf0b9d997c0487f6b07ee6ffc946b2 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Tue, 7 Jul 2026 06:09:33 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=8E=A8=20UX:=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EC=83=88=20=ED=83=AD=20=EC=97=B4=EA=B8=B0?= =?UTF-8?q?=20=EB=B0=8F=20=EC=A0=91=EA=B7=BC=EC=84=B1=20=EC=86=8D=EC=84=B1?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 외부 문서 링크 및 JSON 부트스트랩이 뷰어 탭을 이탈하지 않고 새 탭에서 열리도록 `target="_blank"`, `rel="noopener noreferrer"` 속성 적용 - 스크린 리더 사용자를 위해 `aria-label`을 추가하여 웹 접근성 준수 - CHANGELOG 및 관련 문서 반영 --- .jules/palette.md | 6 +++--- CHANGELOG.md | 7 +++++++ .../viewer/controller/ViewerUiController.java | 4 +++- .../resources/static/assets/viewer/viewer.css | 20 +------------------ .../resources/static/assets/viewer/viewer.js | 10 ++++------ 5 files changed, 18 insertions(+), 29 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.jules/palette.md b/.jules/palette.md index 1597cb3a..5acba451 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -1,6 +1,6 @@ ## 2024-05-18 - Disabled and Loading States for Async Actions **Learning:** Adding explicit loading and disabled states to asynchronous action buttons (like "Refresh") provides immediate feedback, reducing user confusion and preventing double-submissions. **Action:** Always ensure that buttons triggering network requests visually indicate the loading state and are disabled until the request completes. -## 2024-05-18 - Loading Indicators for Async Refresh Actions -**Learning:** Text changes (like "Refresh" to "Refreshing...") are often not immediately perceived by users. Adding an explicit, animated loading spinner next to the text provides a universally recognized visual cue that an asynchronous process is underway, reducing impatience and uncertainty. -**Action:** Always include a visual loading indicator (like a spinner) alongside text changes on buttons that trigger network requests, ensuring it is accessible (`aria-hidden="true"` for decorative spinners) and respects reduced motion preferences. +## 2024-05-18 - External Link Context Switching UX +**Learning:** Opening secondary artifacts (like the JSON bootstrap or original file) in the same tab disrupts the user's viewing context and forces them to re-navigate. Adding `target="_blank"` with a descriptive `aria-label` ensures a smoother experience and maintains accessibility. +**Action:** Always configure external or secondary document links to open in a new tab (`target="_blank" rel="noopener noreferrer"`) and explicitly indicate this behavior to screen readers using `aria-label`. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..7fb94e0b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# 변경 로그 (Changelog) + +## [Unreleased] + +### UX 및 접근성 개선 (UX & Accessibility Improvements) +* 외부 링크 및 JSON 부트스트랩 열기 시 새 탭에서 열리도록 `target="_blank"`, `rel="noopener noreferrer"` 속성을 추가하여 사용자가 문서 뷰어 컨텍스트를 유지할 수 있도록 개선했습니다. +* 스크린 리더 사용자를 위해 새 탭에서 열림을 명확히 안내하는 `aria-label` 속성을 외부 링크 요소에 추가하여 웹 접근성을 향상시켰습니다. diff --git a/src/main/java/com/clearfolio/viewer/controller/ViewerUiController.java b/src/main/java/com/clearfolio/viewer/controller/ViewerUiController.java index 13b5571d..733f4e86 100644 --- a/src/main/java/com/clearfolio/viewer/controller/ViewerUiController.java +++ b/src/main/java/com/clearfolio/viewer/controller/ViewerUiController.java @@ -107,7 +107,9 @@ private static String viewerShellHtml(UUID docId, String initialState) {
- +
diff --git a/src/main/resources/static/assets/viewer/viewer.css b/src/main/resources/static/assets/viewer/viewer.css index 959ed496..354b2e25 100644 --- a/src/main/resources/static/assets/viewer/viewer.css +++ b/src/main/resources/static/assets/viewer/viewer.css @@ -264,23 +264,6 @@ a:hover { } } -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -.spinner { - display: inline-block; - width: 16px; - height: 16px; - border: 2px solid currentColor; - border-right-color: transparent; - border-radius: 50%; - animation: spin 0.75s linear infinite; - margin-right: 8px; -} - @media (max-width: 768px) { :root { --header-h: 60px; @@ -300,8 +283,7 @@ a:hover { } @media (prefers-reduced-motion: reduce) { - .skeleton, - .spinner { + .skeleton { animation: none; } } diff --git a/src/main/resources/static/assets/viewer/viewer.js b/src/main/resources/static/assets/viewer/viewer.js index 098de3d4..1939e5a2 100644 --- a/src/main/resources/static/assets/viewer/viewer.js +++ b/src/main/resources/static/assets/viewer/viewer.js @@ -46,11 +46,7 @@ function setLoading(message) { el.liveStatus.textContent = message; el.preview.setAttribute("aria-busy", "true"); el.retryBtn.disabled = true; - - const spinner = document.createElement("span"); - spinner.className = "spinner"; - spinner.setAttribute("aria-hidden", "true"); - el.retryBtn.replaceChildren(spinner, document.createTextNode("Refreshing...")); + el.retryBtn.textContent = "Refreshing..."; } function showError(message) { @@ -93,7 +89,9 @@ function renderPreviewLink(path) { link.href = path; link.textContent = "Open artifact"; link.className = "btn btn-secondary"; - link.rel = "noopener"; + link.rel = "noopener noreferrer"; + link.target = "_blank"; + link.setAttribute("aria-label", "Open artifact (opens in a new tab)"); el.preview.appendChild(link); }