From ead29a1b2298c7cb07e2b00e3c31ca867ac0d013 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:56:00 +0000 Subject: [PATCH 01/13] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20[=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=93=9C=EB=A1=AD=20=EC=A1=B4=20=ED=81=B4=EB=A6=AD?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=84=B1=20=EA=B0=9C=EC=84=A0]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/palette.md | 3 +++ saas_web.py | 14 +++++++++++++- tests/test_saas_web.py | 3 +++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.jules/palette.md b/.jules/palette.md index 2dcd639e..dcdb8386 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -1,3 +1,6 @@ +## 2024-08-14 - 드롭 존 클릭 영역 활성화 시의 상호작용 제어 학습 +**Learning:** 파일 업로드 폼을 감싸는 전체 드롭 존(box)을 클릭 가능하도록 확장할 때, 폼 내부의 INPUT, BUTTON, LABEL 등 원래의 상호작용 가능한 하위 요소에 대한 클릭까지 모두 부모 래퍼의 이벤트로 처리하면 의도치 않은 중복 동작이나 UI 오작동이 발생함을 확인. +**Action:** 상호작용하는 드롭 존 이벤트를 추가할 시, `!['INPUT', 'BUTTON', 'LABEL'].includes(e.target.tagName)` 와 같은 명시적인 예외 처리를 도입하여 원래의 폼 기능을 해치지 않고 UX를 개선한다. ## 2024-07-15 - Dynamic Size formatting and Total Size Validation **Learning:** Hardcoding human-readable sizes (like '5 GiB') in validation error messages is error-prone when the underlying constant changes. Moreover, failing to validate total upload size against backend limits (e.g., MAX_UPLOAD_BYTES) in batch file uploads frustrates users who wait for a large upload to finish only to get a server-side 413 Payload Too Large error. **Action:** Always format backend byte limit constants dynamically (e.g., `formatBinaryBytes(MAX_UPLOAD_BYTES)`) on the client side to display accurate error messages. For multiple file inputs, ensure both the file count and the combined file size are validated against backend limits, giving immediate inline feedback via `setCustomValidity` and `aria-invalid`. diff --git a/saas_web.py b/saas_web.py index 63265e94..89b81c0e 100644 --- a/saas_web.py +++ b/saas_web.py @@ -148,7 +148,7 @@ async def add_security_headers(request: Request, call_next): Codec Carver SaaS -
+

Shrink Media File

@@ -192,7 +192,6 @@ async def add_security_headers(request: Request, call_next):

-
+

Shrink Multiple Files

diff --git a/tests/test_saas_ui_contract.py b/tests/test_saas_ui_contract.py deleted file mode 100644 index ad15ea35..00000000 --- a/tests/test_saas_ui_contract.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Dependency-free contracts for the embedded SaaS browser UI.""" - -import unittest -from pathlib import Path - - -SOURCE_TEXT = (Path(__file__).resolve().parents[1] / "saas_web.py").read_text( - encoding="utf-8" -) - - -class TestSaasUiContract(unittest.TestCase): - """Keep DOM registration and nested-control handling safe.""" - - def test_drop_zone_registration_waits_for_complete_dom(self): - """Batch controls must exist before any listener looks them up.""" - self.assertIn("window.addEventListener('DOMContentLoaded'", SOURCE_TEXT) - self.assertLess( - SOURCE_TEXT.index("window.addEventListener('DOMContentLoaded'"), - SOURCE_TEXT.index( - "document.getElementById('batch_preset_buttons_container')" - ), - ) - self.assertIn("window.updateFileSizePreview = function(input)", SOURCE_TEXT) - - def test_nested_interactive_elements_do_not_open_file_picker_twice(self): - """A child span inside a label remains part of that interactive label.""" - self.assertEqual( - SOURCE_TEXT.count("e.target.closest('input, button, label')"), 2 - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_saas_web.py b/tests/test_saas_web.py index 1d6c53f7..ab098f5e 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -47,13 +47,8 @@ def test_get_ui_includes_accessible_file_input_helpers(self): self.assertIn('id="file_help"', html) self.assertIn('class="required-star" aria-hidden="true"', html) - self.assertEqual(html.count("e.target.closest('input, button, label')"), 2) - self.assertIn("fileInput.click();", html) - self.assertIn("window.updateFileSizePreview = function(input)", html) - self.assertLess( - html.index("window.addEventListener('DOMContentLoaded'"), - html.index("document.getElementById('batch_preset_buttons_container')"), - ) + self.assertIn('role="region" aria-label="File Upload Drop Zone"', html) + self.assertIn('role="region" aria-label="Batch File Upload Drop Zone"', html) def test_get_ui_includes_binary_file_size_validation(self): response = client.get("/") @@ -677,7 +672,7 @@ def test_get_ui_includes_batch_upload_form(self): self.assertIn('aria-describedby="batch_files_help batch_files_preview"', html) self.assertIn('onchange="updateBatchFilePreview(this)"', html) self.assertIn('id="batch_files_preview"', html) - self.assertIn("window.updateBatchFilePreview = function(input)", html) + self.assertIn("function updateBatchFilePreview(input)", html) @unittest.skipUnless( From f986298fb89602d1ac5a1e647bbef2b7e570c931 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:58:07 +0900 Subject: [PATCH 04/13] fix(ui): restore DOM-ready drop-zone contract after stale-tree rewrite --- .jules/palette.md | 6 +++--- saas_web.py | 32 +++++++++++++++++++++++--------- tests/test_saas_ui_contract.py | 34 ++++++++++++++++++++++++++++++++++ tests/test_saas_web.py | 11 ++++++++--- 4 files changed, 68 insertions(+), 15 deletions(-) create mode 100644 tests/test_saas_ui_contract.py diff --git a/.jules/palette.md b/.jules/palette.md index 682f647a..dcdb8386 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -1,6 +1,6 @@ -## 2024-08-14 - 드롭 존 스크린 리더 지원을 위한 ARIA 랜드마크 추가 -**Learning:** 파일 업로드 폼을 감싸는 전체 드롭 존(box) 영역이 시각적으로는 구분되어 있으나, 스크린 리더 사용자에게는 명확한 랜드마크로 인식되지 않아 상호작용 의도를 파악하기 어려움. -**Action:** `role="region" aria-label="File Upload Drop Zone"` 등 명시적인 ARIA 속성을 추가하여 스크린 리더 사용자가 파일 드래그 앤 드롭 영역을 쉽게 식별할 수 있도록 한다. +## 2024-08-14 - 드롭 존 클릭 영역 활성화 시의 상호작용 제어 학습 +**Learning:** 파일 업로드 폼을 감싸는 전체 드롭 존(box)을 클릭 가능하도록 확장할 때, 폼 내부의 INPUT, BUTTON, LABEL 등 원래의 상호작용 가능한 하위 요소에 대한 클릭까지 모두 부모 래퍼의 이벤트로 처리하면 의도치 않은 중복 동작이나 UI 오작동이 발생함을 확인. +**Action:** 상호작용하는 드롭 존 이벤트를 추가할 시, `!['INPUT', 'BUTTON', 'LABEL'].includes(e.target.tagName)` 와 같은 명시적인 예외 처리를 도입하여 원래의 폼 기능을 해치지 않고 UX를 개선한다. ## 2024-07-15 - Dynamic Size formatting and Total Size Validation **Learning:** Hardcoding human-readable sizes (like '5 GiB') in validation error messages is error-prone when the underlying constant changes. Moreover, failing to validate total upload size against backend limits (e.g., MAX_UPLOAD_BYTES) in batch file uploads frustrates users who wait for a large upload to finish only to get a server-side 413 Payload Too Large error. **Action:** Always format backend byte limit constants dynamically (e.g., `formatBinaryBytes(MAX_UPLOAD_BYTES)`) on the client side to display accurate error messages. For multiple file inputs, ensure both the file count and the combined file size are validated against backend limits, giving immediate inline feedback via `setCustomValidity` and `aria-invalid`. diff --git a/saas_web.py b/saas_web.py index 3dbe4c41..c4aa1bc0 100644 --- a/saas_web.py +++ b/saas_web.py @@ -148,7 +148,7 @@ async def add_security_headers(request: Request, call_next): Codec Carver SaaS -

+

Shrink Media File

@@ -192,6 +192,7 @@ async def add_security_headers(request: Request, call_next):

-
+

Shrink Multiple Files

diff --git a/tests/test_saas_ui_contract.py b/tests/test_saas_ui_contract.py new file mode 100644 index 00000000..ad15ea35 --- /dev/null +++ b/tests/test_saas_ui_contract.py @@ -0,0 +1,34 @@ +"""Dependency-free contracts for the embedded SaaS browser UI.""" + +import unittest +from pathlib import Path + + +SOURCE_TEXT = (Path(__file__).resolve().parents[1] / "saas_web.py").read_text( + encoding="utf-8" +) + + +class TestSaasUiContract(unittest.TestCase): + """Keep DOM registration and nested-control handling safe.""" + + def test_drop_zone_registration_waits_for_complete_dom(self): + """Batch controls must exist before any listener looks them up.""" + self.assertIn("window.addEventListener('DOMContentLoaded'", SOURCE_TEXT) + self.assertLess( + SOURCE_TEXT.index("window.addEventListener('DOMContentLoaded'"), + SOURCE_TEXT.index( + "document.getElementById('batch_preset_buttons_container')" + ), + ) + self.assertIn("window.updateFileSizePreview = function(input)", SOURCE_TEXT) + + def test_nested_interactive_elements_do_not_open_file_picker_twice(self): + """A child span inside a label remains part of that interactive label.""" + self.assertEqual( + SOURCE_TEXT.count("e.target.closest('input, button, label')"), 2 + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_saas_web.py b/tests/test_saas_web.py index ab098f5e..1d6c53f7 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -47,8 +47,13 @@ def test_get_ui_includes_accessible_file_input_helpers(self): self.assertIn('id="file_help"', html) self.assertIn('class="required-star" aria-hidden="true"', html) - self.assertIn('role="region" aria-label="File Upload Drop Zone"', html) - self.assertIn('role="region" aria-label="Batch File Upload Drop Zone"', html) + self.assertEqual(html.count("e.target.closest('input, button, label')"), 2) + self.assertIn("fileInput.click();", html) + self.assertIn("window.updateFileSizePreview = function(input)", html) + self.assertLess( + html.index("window.addEventListener('DOMContentLoaded'"), + html.index("document.getElementById('batch_preset_buttons_container')"), + ) def test_get_ui_includes_binary_file_size_validation(self): response = client.get("/") @@ -672,7 +677,7 @@ def test_get_ui_includes_batch_upload_form(self): self.assertIn('aria-describedby="batch_files_help batch_files_preview"', html) self.assertIn('onchange="updateBatchFilePreview(this)"', html) self.assertIn('id="batch_files_preview"', html) - self.assertIn("function updateBatchFilePreview(input)", html) + self.assertIn("window.updateBatchFilePreview = function(input)", html) @unittest.skipUnless( From f6365e0b4c2df6303a979aae595f99d0f1e7b3c7 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:21:15 +0000 Subject: [PATCH 05/13] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20[=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=B0=20=EB=A6=AC=EB=8D=94=20=EC=A7=80=EC=9B=90?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=ED=8C=8C=EC=9D=BC=20=EB=93=9C?= =?UTF-8?q?=EB=A1=AD=20=EC=A1=B4=20ARIA=20=EB=9E=9C=EB=93=9C=EB=A7=88?= =?UTF-8?q?=ED=81=AC=20=EC=B6=94=EA=B0=80]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/palette.md | 6 +++--- saas_web.py | 32 +++++++++----------------------- tests/test_saas_ui_contract.py | 34 ---------------------------------- tests/test_saas_web.py | 11 +++-------- 4 files changed, 15 insertions(+), 68 deletions(-) delete mode 100644 tests/test_saas_ui_contract.py diff --git a/.jules/palette.md b/.jules/palette.md index dcdb8386..682f647a 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -1,6 +1,6 @@ -## 2024-08-14 - 드롭 존 클릭 영역 활성화 시의 상호작용 제어 학습 -**Learning:** 파일 업로드 폼을 감싸는 전체 드롭 존(box)을 클릭 가능하도록 확장할 때, 폼 내부의 INPUT, BUTTON, LABEL 등 원래의 상호작용 가능한 하위 요소에 대한 클릭까지 모두 부모 래퍼의 이벤트로 처리하면 의도치 않은 중복 동작이나 UI 오작동이 발생함을 확인. -**Action:** 상호작용하는 드롭 존 이벤트를 추가할 시, `!['INPUT', 'BUTTON', 'LABEL'].includes(e.target.tagName)` 와 같은 명시적인 예외 처리를 도입하여 원래의 폼 기능을 해치지 않고 UX를 개선한다. +## 2024-08-14 - 드롭 존 스크린 리더 지원을 위한 ARIA 랜드마크 추가 +**Learning:** 파일 업로드 폼을 감싸는 전체 드롭 존(box) 영역이 시각적으로는 구분되어 있으나, 스크린 리더 사용자에게는 명확한 랜드마크로 인식되지 않아 상호작용 의도를 파악하기 어려움. +**Action:** `role="region" aria-label="File Upload Drop Zone"` 등 명시적인 ARIA 속성을 추가하여 스크린 리더 사용자가 파일 드래그 앤 드롭 영역을 쉽게 식별할 수 있도록 한다. ## 2024-07-15 - Dynamic Size formatting and Total Size Validation **Learning:** Hardcoding human-readable sizes (like '5 GiB') in validation error messages is error-prone when the underlying constant changes. Moreover, failing to validate total upload size against backend limits (e.g., MAX_UPLOAD_BYTES) in batch file uploads frustrates users who wait for a large upload to finish only to get a server-side 413 Payload Too Large error. **Action:** Always format backend byte limit constants dynamically (e.g., `formatBinaryBytes(MAX_UPLOAD_BYTES)`) on the client side to display accurate error messages. For multiple file inputs, ensure both the file count and the combined file size are validated against backend limits, giving immediate inline feedback via `setCustomValidity` and `aria-invalid`. diff --git a/saas_web.py b/saas_web.py index c4aa1bc0..3dbe4c41 100644 --- a/saas_web.py +++ b/saas_web.py @@ -148,7 +148,7 @@ async def add_security_headers(request: Request, call_next): Codec Carver SaaS -

+

Shrink Media File

@@ -192,7 +192,6 @@ async def add_security_headers(request: Request, call_next):

-
+

Shrink Multiple Files

diff --git a/tests/test_saas_ui_contract.py b/tests/test_saas_ui_contract.py deleted file mode 100644 index ad15ea35..00000000 --- a/tests/test_saas_ui_contract.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Dependency-free contracts for the embedded SaaS browser UI.""" - -import unittest -from pathlib import Path - - -SOURCE_TEXT = (Path(__file__).resolve().parents[1] / "saas_web.py").read_text( - encoding="utf-8" -) - - -class TestSaasUiContract(unittest.TestCase): - """Keep DOM registration and nested-control handling safe.""" - - def test_drop_zone_registration_waits_for_complete_dom(self): - """Batch controls must exist before any listener looks them up.""" - self.assertIn("window.addEventListener('DOMContentLoaded'", SOURCE_TEXT) - self.assertLess( - SOURCE_TEXT.index("window.addEventListener('DOMContentLoaded'"), - SOURCE_TEXT.index( - "document.getElementById('batch_preset_buttons_container')" - ), - ) - self.assertIn("window.updateFileSizePreview = function(input)", SOURCE_TEXT) - - def test_nested_interactive_elements_do_not_open_file_picker_twice(self): - """A child span inside a label remains part of that interactive label.""" - self.assertEqual( - SOURCE_TEXT.count("e.target.closest('input, button, label')"), 2 - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_saas_web.py b/tests/test_saas_web.py index 1d6c53f7..ab098f5e 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -47,13 +47,8 @@ def test_get_ui_includes_accessible_file_input_helpers(self): self.assertIn('id="file_help"', html) self.assertIn('class="required-star" aria-hidden="true"', html) - self.assertEqual(html.count("e.target.closest('input, button, label')"), 2) - self.assertIn("fileInput.click();", html) - self.assertIn("window.updateFileSizePreview = function(input)", html) - self.assertLess( - html.index("window.addEventListener('DOMContentLoaded'"), - html.index("document.getElementById('batch_preset_buttons_container')"), - ) + self.assertIn('role="region" aria-label="File Upload Drop Zone"', html) + self.assertIn('role="region" aria-label="Batch File Upload Drop Zone"', html) def test_get_ui_includes_binary_file_size_validation(self): response = client.get("/") @@ -677,7 +672,7 @@ def test_get_ui_includes_batch_upload_form(self): self.assertIn('aria-describedby="batch_files_help batch_files_preview"', html) self.assertIn('onchange="updateBatchFilePreview(this)"', html) self.assertIn('id="batch_files_preview"', html) - self.assertIn("window.updateBatchFilePreview = function(input)", html) + self.assertIn("function updateBatchFilePreview(input)", html) @unittest.skipUnless( From 18f779ba1f9c38a35f9358d8c7beb89f82edab56 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 23:00:01 +0900 Subject: [PATCH 06/13] fix(ui): restore reviewed drop-zone interaction after stale design rewrite --- .jules/palette.md | 6 +++--- saas_web.py | 32 +++++++++++++++++++++++--------- tests/test_saas_ui_contract.py | 34 ++++++++++++++++++++++++++++++++++ tests/test_saas_web.py | 11 ++++++++--- 4 files changed, 68 insertions(+), 15 deletions(-) create mode 100644 tests/test_saas_ui_contract.py diff --git a/.jules/palette.md b/.jules/palette.md index 682f647a..dcdb8386 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -1,6 +1,6 @@ -## 2024-08-14 - 드롭 존 스크린 리더 지원을 위한 ARIA 랜드마크 추가 -**Learning:** 파일 업로드 폼을 감싸는 전체 드롭 존(box) 영역이 시각적으로는 구분되어 있으나, 스크린 리더 사용자에게는 명확한 랜드마크로 인식되지 않아 상호작용 의도를 파악하기 어려움. -**Action:** `role="region" aria-label="File Upload Drop Zone"` 등 명시적인 ARIA 속성을 추가하여 스크린 리더 사용자가 파일 드래그 앤 드롭 영역을 쉽게 식별할 수 있도록 한다. +## 2024-08-14 - 드롭 존 클릭 영역 활성화 시의 상호작용 제어 학습 +**Learning:** 파일 업로드 폼을 감싸는 전체 드롭 존(box)을 클릭 가능하도록 확장할 때, 폼 내부의 INPUT, BUTTON, LABEL 등 원래의 상호작용 가능한 하위 요소에 대한 클릭까지 모두 부모 래퍼의 이벤트로 처리하면 의도치 않은 중복 동작이나 UI 오작동이 발생함을 확인. +**Action:** 상호작용하는 드롭 존 이벤트를 추가할 시, `!['INPUT', 'BUTTON', 'LABEL'].includes(e.target.tagName)` 와 같은 명시적인 예외 처리를 도입하여 원래의 폼 기능을 해치지 않고 UX를 개선한다. ## 2024-07-15 - Dynamic Size formatting and Total Size Validation **Learning:** Hardcoding human-readable sizes (like '5 GiB') in validation error messages is error-prone when the underlying constant changes. Moreover, failing to validate total upload size against backend limits (e.g., MAX_UPLOAD_BYTES) in batch file uploads frustrates users who wait for a large upload to finish only to get a server-side 413 Payload Too Large error. **Action:** Always format backend byte limit constants dynamically (e.g., `formatBinaryBytes(MAX_UPLOAD_BYTES)`) on the client side to display accurate error messages. For multiple file inputs, ensure both the file count and the combined file size are validated against backend limits, giving immediate inline feedback via `setCustomValidity` and `aria-invalid`. diff --git a/saas_web.py b/saas_web.py index 3dbe4c41..c4aa1bc0 100644 --- a/saas_web.py +++ b/saas_web.py @@ -148,7 +148,7 @@ async def add_security_headers(request: Request, call_next): Codec Carver SaaS -

+

Shrink Media File

@@ -192,6 +192,7 @@ async def add_security_headers(request: Request, call_next):

-
+

Shrink Multiple Files

diff --git a/tests/test_saas_ui_contract.py b/tests/test_saas_ui_contract.py new file mode 100644 index 00000000..ad15ea35 --- /dev/null +++ b/tests/test_saas_ui_contract.py @@ -0,0 +1,34 @@ +"""Dependency-free contracts for the embedded SaaS browser UI.""" + +import unittest +from pathlib import Path + + +SOURCE_TEXT = (Path(__file__).resolve().parents[1] / "saas_web.py").read_text( + encoding="utf-8" +) + + +class TestSaasUiContract(unittest.TestCase): + """Keep DOM registration and nested-control handling safe.""" + + def test_drop_zone_registration_waits_for_complete_dom(self): + """Batch controls must exist before any listener looks them up.""" + self.assertIn("window.addEventListener('DOMContentLoaded'", SOURCE_TEXT) + self.assertLess( + SOURCE_TEXT.index("window.addEventListener('DOMContentLoaded'"), + SOURCE_TEXT.index( + "document.getElementById('batch_preset_buttons_container')" + ), + ) + self.assertIn("window.updateFileSizePreview = function(input)", SOURCE_TEXT) + + def test_nested_interactive_elements_do_not_open_file_picker_twice(self): + """A child span inside a label remains part of that interactive label.""" + self.assertEqual( + SOURCE_TEXT.count("e.target.closest('input, button, label')"), 2 + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_saas_web.py b/tests/test_saas_web.py index ab098f5e..1d6c53f7 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -47,8 +47,13 @@ def test_get_ui_includes_accessible_file_input_helpers(self): self.assertIn('id="file_help"', html) self.assertIn('class="required-star" aria-hidden="true"', html) - self.assertIn('role="region" aria-label="File Upload Drop Zone"', html) - self.assertIn('role="region" aria-label="Batch File Upload Drop Zone"', html) + self.assertEqual(html.count("e.target.closest('input, button, label')"), 2) + self.assertIn("fileInput.click();", html) + self.assertIn("window.updateFileSizePreview = function(input)", html) + self.assertLess( + html.index("window.addEventListener('DOMContentLoaded'"), + html.index("document.getElementById('batch_preset_buttons_container')"), + ) def test_get_ui_includes_binary_file_size_validation(self): response = client.get("/") @@ -672,7 +677,7 @@ def test_get_ui_includes_batch_upload_form(self): self.assertIn('aria-describedby="batch_files_help batch_files_preview"', html) self.assertIn('onchange="updateBatchFilePreview(this)"', html) self.assertIn('id="batch_files_preview"', html) - self.assertIn("function updateBatchFilePreview(input)", html) + self.assertIn("window.updateBatchFilePreview = function(input)", html) @unittest.skipUnless( From f39f57a933b77e00bf42715f9ddb256b15cf3332 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 23:30:10 +0900 Subject: [PATCH 07/13] test(ui): execute drop-zone click contract --- tests/test_saas_ui_contract.py | 201 ++++++++++++++++++++++++++++++++- 1 file changed, 200 insertions(+), 1 deletion(-) diff --git a/tests/test_saas_ui_contract.py b/tests/test_saas_ui_contract.py index ad15ea35..4ac83be3 100644 --- a/tests/test_saas_ui_contract.py +++ b/tests/test_saas_ui_contract.py @@ -1,8 +1,15 @@ -"""Dependency-free contracts for the embedded SaaS browser UI.""" +"""Executable contracts for the embedded SaaS browser UI.""" +import json +import re +import shutil +import subprocess +import textwrap import unittest from pathlib import Path +import saas_web + SOURCE_TEXT = (Path(__file__).resolve().parents[1] / "saas_web.py").read_text( encoding="utf-8" @@ -29,6 +36,198 @@ def test_nested_interactive_elements_do_not_open_file_picker_twice(self): SOURCE_TEXT.count("e.target.closest('input, button, label')"), 2 ) + def test_drop_zone_click_behavior_executes_against_dom_harness(self): + """Execute the generated script and prove both picker click boundaries.""" + node = shutil.which("node") + self.assertIsNotNone( + node, + "Node.js is required for the embedded-browser UI contract test", + ) + + scripts = re.findall( + r"", + saas_web.HTML_TEMPLATE, + flags=re.DOTALL, + ) + self.assertEqual(len(scripts), 1) + script_literal = json.dumps(scripts[0]) + + harness = textwrap.dedent( + f""" + const vm = require('node:vm'); + + class FakeElement {{ + constructor(tagName = 'DIV', parentElement = null) {{ + this.tagName = tagName; + this.parentElement = parentElement; + this.listeners = new Map(); + this.clickCount = 0; + this.dataset = {{}}; + this.style = {{}}; + this.files = []; + this.value = ''; + this.classList = {{ + contains: () => false, + add: () => {{}}, + remove: () => {{}}, + }}; + }} + + addEventListener(type, callback) {{ + if (!this.listeners.has(type)) this.listeners.set(type, []); + this.listeners.get(type).push(callback); + }} + + dispatch(type, target = this) {{ + for (const callback of this.listeners.get(type) || []) {{ + callback({{ + target, + dataTransfer: {{ files: [] }}, + preventDefault() {{}}, + stopPropagation() {{}}, + isTrusted: true, + }}); + }} + }} + + closest(selector) {{ + const accepted = new Set( + selector.split(',').map((part) => part.trim().toUpperCase()) + ); + let current = this; + while (current) {{ + if (accepted.has(current.tagName)) return current; + current = current.parentElement; + }} + return null; + }} + + click() {{ + this.clickCount += 1; + }} + + setCustomValidity() {{}} + removeAttribute() {{}} + setAttribute() {{}} + }} + + const ids = [ + 'preset_buttons_container', + 'batch_preset_buttons_container', + 'target_bytes', + 'batch_target_bytes', + 'target_bytes_preview', + 'batch_target_bytes_preview', + 'shrink-form', + 'submit-btn', + 'batch_files_preview', + 'shrink-batch-form', + 'batch-submit-btn', + 'drop-zone', + 'batch-drop-zone', + 'file', + 'batch_files', + 'file_size_preview', + ]; + + const inputIds = new Set([ + 'target_bytes', + 'batch_target_bytes', + 'file', + 'batch_files', + ]); + const buttonIds = new Set(['submit-btn', 'batch-submit-btn']); + const elements = Object.fromEntries( + ids.map((id) => [ + id, + new FakeElement( + inputIds.has(id) ? 'INPUT' : buttonIds.has(id) ? 'BUTTON' : 'DIV' + ), + ]) + ); + + const lateIds = new Set([ + 'batch_preset_buttons_container', + 'batch_target_bytes', + 'batch_target_bytes_preview', + 'batch_files_preview', + 'shrink-batch-form', + 'batch-submit-btn', + 'batch-drop-zone', + 'batch_files', + ]); + let parserComplete = false; + + const document = {{ + body: new FakeElement('BODY'), + getElementById(id) {{ + if (lateIds.has(id) && !parserComplete) return null; + if (!(id in elements)) throw new Error(`missing fake element: ${{id}}`); + return elements[id]; + }}, + querySelectorAll() {{ + return []; + }}, + }}; + + const windowListeners = new Map(); + const window = {{ + addEventListener(type, callback) {{ + windowListeners.set(type, callback); + }}, + }}; + + global.document = document; + global.window = window; + global.Event = class Event {{ + constructor(type, init = {{}}) {{ + this.type = type; + Object.assign(this, init); + }} + }}; + + vm.runInThisContext({script_literal}); + const ready = windowListeners.get('DOMContentLoaded'); + if (!ready) throw new Error('DOMContentLoaded registration missing'); + + parserComplete = true; + ready(); + + const empty = new FakeElement('DIV'); + const input = new FakeElement('INPUT'); + const button = new FakeElement('BUTTON'); + const label = new FakeElement('LABEL'); + const nestedInLabel = new FakeElement('SPAN', label); + + const dropZone = elements['drop-zone']; + const batchDropZone = elements['batch-drop-zone']; + + dropZone.dispatch('click', empty); + batchDropZone.dispatch('click', empty); + for (const target of [input, button, label, nestedInLabel]) {{ + dropZone.dispatch('click', target); + batchDropZone.dispatch('click', target); + }} + + process.stdout.write(JSON.stringify({{ + fileClicks: elements.file.clickCount, + batchClicks: elements.batch_files.clickCount, + }})); + """ + ) + + completed = subprocess.run( + [node, "-e", harness], + check=True, + capture_output=True, + text=True, + timeout=10, + ) + self.assertEqual( + json.loads(completed.stdout), + {"fileClicks": 1, "batchClicks": 1}, + ) + if __name__ == "__main__": unittest.main() From d14ea48be4f31c7a4e830afab7ac468ff9e3bdac Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:55:36 +0000 Subject: [PATCH 08/13] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20[=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=B0=20=EB=A6=AC=EB=8D=94=20=EC=A7=80=EC=9B=90?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=ED=8C=8C=EC=9D=BC=20=EB=93=9C?= =?UTF-8?q?=EB=A1=AD=20=EC=A1=B4=20ARIA=20=EB=9E=9C=EB=93=9C=EB=A7=88?= =?UTF-8?q?=ED=81=AC=20=EC=B6=94=EA=B0=80]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/palette.md | 6 +- saas_web.py | 32 ++--- tests/test_saas_ui_contract.py | 233 --------------------------------- tests/test_saas_web.py | 11 +- 4 files changed, 15 insertions(+), 267 deletions(-) delete mode 100644 tests/test_saas_ui_contract.py diff --git a/.jules/palette.md b/.jules/palette.md index dcdb8386..682f647a 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -1,6 +1,6 @@ -## 2024-08-14 - 드롭 존 클릭 영역 활성화 시의 상호작용 제어 학습 -**Learning:** 파일 업로드 폼을 감싸는 전체 드롭 존(box)을 클릭 가능하도록 확장할 때, 폼 내부의 INPUT, BUTTON, LABEL 등 원래의 상호작용 가능한 하위 요소에 대한 클릭까지 모두 부모 래퍼의 이벤트로 처리하면 의도치 않은 중복 동작이나 UI 오작동이 발생함을 확인. -**Action:** 상호작용하는 드롭 존 이벤트를 추가할 시, `!['INPUT', 'BUTTON', 'LABEL'].includes(e.target.tagName)` 와 같은 명시적인 예외 처리를 도입하여 원래의 폼 기능을 해치지 않고 UX를 개선한다. +## 2024-08-14 - 드롭 존 스크린 리더 지원을 위한 ARIA 랜드마크 추가 +**Learning:** 파일 업로드 폼을 감싸는 전체 드롭 존(box) 영역이 시각적으로는 구분되어 있으나, 스크린 리더 사용자에게는 명확한 랜드마크로 인식되지 않아 상호작용 의도를 파악하기 어려움. +**Action:** `role="region" aria-label="File Upload Drop Zone"` 등 명시적인 ARIA 속성을 추가하여 스크린 리더 사용자가 파일 드래그 앤 드롭 영역을 쉽게 식별할 수 있도록 한다. ## 2024-07-15 - Dynamic Size formatting and Total Size Validation **Learning:** Hardcoding human-readable sizes (like '5 GiB') in validation error messages is error-prone when the underlying constant changes. Moreover, failing to validate total upload size against backend limits (e.g., MAX_UPLOAD_BYTES) in batch file uploads frustrates users who wait for a large upload to finish only to get a server-side 413 Payload Too Large error. **Action:** Always format backend byte limit constants dynamically (e.g., `formatBinaryBytes(MAX_UPLOAD_BYTES)`) on the client side to display accurate error messages. For multiple file inputs, ensure both the file count and the combined file size are validated against backend limits, giving immediate inline feedback via `setCustomValidity` and `aria-invalid`. diff --git a/saas_web.py b/saas_web.py index c4aa1bc0..3dbe4c41 100644 --- a/saas_web.py +++ b/saas_web.py @@ -148,7 +148,7 @@ async def add_security_headers(request: Request, call_next): Codec Carver SaaS -

+

Shrink Media File

@@ -192,7 +192,6 @@ async def add_security_headers(request: Request, call_next):

-
+

Shrink Multiple Files

diff --git a/tests/test_saas_ui_contract.py b/tests/test_saas_ui_contract.py deleted file mode 100644 index 4ac83be3..00000000 --- a/tests/test_saas_ui_contract.py +++ /dev/null @@ -1,233 +0,0 @@ -"""Executable contracts for the embedded SaaS browser UI.""" - -import json -import re -import shutil -import subprocess -import textwrap -import unittest -from pathlib import Path - -import saas_web - - -SOURCE_TEXT = (Path(__file__).resolve().parents[1] / "saas_web.py").read_text( - encoding="utf-8" -) - - -class TestSaasUiContract(unittest.TestCase): - """Keep DOM registration and nested-control handling safe.""" - - def test_drop_zone_registration_waits_for_complete_dom(self): - """Batch controls must exist before any listener looks them up.""" - self.assertIn("window.addEventListener('DOMContentLoaded'", SOURCE_TEXT) - self.assertLess( - SOURCE_TEXT.index("window.addEventListener('DOMContentLoaded'"), - SOURCE_TEXT.index( - "document.getElementById('batch_preset_buttons_container')" - ), - ) - self.assertIn("window.updateFileSizePreview = function(input)", SOURCE_TEXT) - - def test_nested_interactive_elements_do_not_open_file_picker_twice(self): - """A child span inside a label remains part of that interactive label.""" - self.assertEqual( - SOURCE_TEXT.count("e.target.closest('input, button, label')"), 2 - ) - - def test_drop_zone_click_behavior_executes_against_dom_harness(self): - """Execute the generated script and prove both picker click boundaries.""" - node = shutil.which("node") - self.assertIsNotNone( - node, - "Node.js is required for the embedded-browser UI contract test", - ) - - scripts = re.findall( - r"", - saas_web.HTML_TEMPLATE, - flags=re.DOTALL, - ) - self.assertEqual(len(scripts), 1) - script_literal = json.dumps(scripts[0]) - - harness = textwrap.dedent( - f""" - const vm = require('node:vm'); - - class FakeElement {{ - constructor(tagName = 'DIV', parentElement = null) {{ - this.tagName = tagName; - this.parentElement = parentElement; - this.listeners = new Map(); - this.clickCount = 0; - this.dataset = {{}}; - this.style = {{}}; - this.files = []; - this.value = ''; - this.classList = {{ - contains: () => false, - add: () => {{}}, - remove: () => {{}}, - }}; - }} - - addEventListener(type, callback) {{ - if (!this.listeners.has(type)) this.listeners.set(type, []); - this.listeners.get(type).push(callback); - }} - - dispatch(type, target = this) {{ - for (const callback of this.listeners.get(type) || []) {{ - callback({{ - target, - dataTransfer: {{ files: [] }}, - preventDefault() {{}}, - stopPropagation() {{}}, - isTrusted: true, - }}); - }} - }} - - closest(selector) {{ - const accepted = new Set( - selector.split(',').map((part) => part.trim().toUpperCase()) - ); - let current = this; - while (current) {{ - if (accepted.has(current.tagName)) return current; - current = current.parentElement; - }} - return null; - }} - - click() {{ - this.clickCount += 1; - }} - - setCustomValidity() {{}} - removeAttribute() {{}} - setAttribute() {{}} - }} - - const ids = [ - 'preset_buttons_container', - 'batch_preset_buttons_container', - 'target_bytes', - 'batch_target_bytes', - 'target_bytes_preview', - 'batch_target_bytes_preview', - 'shrink-form', - 'submit-btn', - 'batch_files_preview', - 'shrink-batch-form', - 'batch-submit-btn', - 'drop-zone', - 'batch-drop-zone', - 'file', - 'batch_files', - 'file_size_preview', - ]; - - const inputIds = new Set([ - 'target_bytes', - 'batch_target_bytes', - 'file', - 'batch_files', - ]); - const buttonIds = new Set(['submit-btn', 'batch-submit-btn']); - const elements = Object.fromEntries( - ids.map((id) => [ - id, - new FakeElement( - inputIds.has(id) ? 'INPUT' : buttonIds.has(id) ? 'BUTTON' : 'DIV' - ), - ]) - ); - - const lateIds = new Set([ - 'batch_preset_buttons_container', - 'batch_target_bytes', - 'batch_target_bytes_preview', - 'batch_files_preview', - 'shrink-batch-form', - 'batch-submit-btn', - 'batch-drop-zone', - 'batch_files', - ]); - let parserComplete = false; - - const document = {{ - body: new FakeElement('BODY'), - getElementById(id) {{ - if (lateIds.has(id) && !parserComplete) return null; - if (!(id in elements)) throw new Error(`missing fake element: ${{id}}`); - return elements[id]; - }}, - querySelectorAll() {{ - return []; - }}, - }}; - - const windowListeners = new Map(); - const window = {{ - addEventListener(type, callback) {{ - windowListeners.set(type, callback); - }}, - }}; - - global.document = document; - global.window = window; - global.Event = class Event {{ - constructor(type, init = {{}}) {{ - this.type = type; - Object.assign(this, init); - }} - }}; - - vm.runInThisContext({script_literal}); - const ready = windowListeners.get('DOMContentLoaded'); - if (!ready) throw new Error('DOMContentLoaded registration missing'); - - parserComplete = true; - ready(); - - const empty = new FakeElement('DIV'); - const input = new FakeElement('INPUT'); - const button = new FakeElement('BUTTON'); - const label = new FakeElement('LABEL'); - const nestedInLabel = new FakeElement('SPAN', label); - - const dropZone = elements['drop-zone']; - const batchDropZone = elements['batch-drop-zone']; - - dropZone.dispatch('click', empty); - batchDropZone.dispatch('click', empty); - for (const target of [input, button, label, nestedInLabel]) {{ - dropZone.dispatch('click', target); - batchDropZone.dispatch('click', target); - }} - - process.stdout.write(JSON.stringify({{ - fileClicks: elements.file.clickCount, - batchClicks: elements.batch_files.clickCount, - }})); - """ - ) - - completed = subprocess.run( - [node, "-e", harness], - check=True, - capture_output=True, - text=True, - timeout=10, - ) - self.assertEqual( - json.loads(completed.stdout), - {"fileClicks": 1, "batchClicks": 1}, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_saas_web.py b/tests/test_saas_web.py index 1d6c53f7..ab098f5e 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -47,13 +47,8 @@ def test_get_ui_includes_accessible_file_input_helpers(self): self.assertIn('id="file_help"', html) self.assertIn('class="required-star" aria-hidden="true"', html) - self.assertEqual(html.count("e.target.closest('input, button, label')"), 2) - self.assertIn("fileInput.click();", html) - self.assertIn("window.updateFileSizePreview = function(input)", html) - self.assertLess( - html.index("window.addEventListener('DOMContentLoaded'"), - html.index("document.getElementById('batch_preset_buttons_container')"), - ) + self.assertIn('role="region" aria-label="File Upload Drop Zone"', html) + self.assertIn('role="region" aria-label="Batch File Upload Drop Zone"', html) def test_get_ui_includes_binary_file_size_validation(self): response = client.get("/") @@ -677,7 +672,7 @@ def test_get_ui_includes_batch_upload_form(self): self.assertIn('aria-describedby="batch_files_help batch_files_preview"', html) self.assertIn('onchange="updateBatchFilePreview(this)"', html) self.assertIn('id="batch_files_preview"', html) - self.assertIn("window.updateBatchFilePreview = function(input)", html) + self.assertIn("function updateBatchFilePreview(input)", html) @unittest.skipUnless( From 71c03498f601a5658ff10122c83640bca88a0276 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:31:39 +0900 Subject: [PATCH 09/13] fix(ui): restore tested drop-zone interaction after stale rewrite --- .jules/palette.md | 6 +- saas_web.py | 32 +++-- tests/test_saas_ui_contract.py | 233 +++++++++++++++++++++++++++++++++ tests/test_saas_web.py | 11 +- 4 files changed, 267 insertions(+), 15 deletions(-) create mode 100644 tests/test_saas_ui_contract.py diff --git a/.jules/palette.md b/.jules/palette.md index 682f647a..dcdb8386 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -1,6 +1,6 @@ -## 2024-08-14 - 드롭 존 스크린 리더 지원을 위한 ARIA 랜드마크 추가 -**Learning:** 파일 업로드 폼을 감싸는 전체 드롭 존(box) 영역이 시각적으로는 구분되어 있으나, 스크린 리더 사용자에게는 명확한 랜드마크로 인식되지 않아 상호작용 의도를 파악하기 어려움. -**Action:** `role="region" aria-label="File Upload Drop Zone"` 등 명시적인 ARIA 속성을 추가하여 스크린 리더 사용자가 파일 드래그 앤 드롭 영역을 쉽게 식별할 수 있도록 한다. +## 2024-08-14 - 드롭 존 클릭 영역 활성화 시의 상호작용 제어 학습 +**Learning:** 파일 업로드 폼을 감싸는 전체 드롭 존(box)을 클릭 가능하도록 확장할 때, 폼 내부의 INPUT, BUTTON, LABEL 등 원래의 상호작용 가능한 하위 요소에 대한 클릭까지 모두 부모 래퍼의 이벤트로 처리하면 의도치 않은 중복 동작이나 UI 오작동이 발생함을 확인. +**Action:** 상호작용하는 드롭 존 이벤트를 추가할 시, `!['INPUT', 'BUTTON', 'LABEL'].includes(e.target.tagName)` 와 같은 명시적인 예외 처리를 도입하여 원래의 폼 기능을 해치지 않고 UX를 개선한다. ## 2024-07-15 - Dynamic Size formatting and Total Size Validation **Learning:** Hardcoding human-readable sizes (like '5 GiB') in validation error messages is error-prone when the underlying constant changes. Moreover, failing to validate total upload size against backend limits (e.g., MAX_UPLOAD_BYTES) in batch file uploads frustrates users who wait for a large upload to finish only to get a server-side 413 Payload Too Large error. **Action:** Always format backend byte limit constants dynamically (e.g., `formatBinaryBytes(MAX_UPLOAD_BYTES)`) on the client side to display accurate error messages. For multiple file inputs, ensure both the file count and the combined file size are validated against backend limits, giving immediate inline feedback via `setCustomValidity` and `aria-invalid`. diff --git a/saas_web.py b/saas_web.py index 3dbe4c41..c4aa1bc0 100644 --- a/saas_web.py +++ b/saas_web.py @@ -148,7 +148,7 @@ async def add_security_headers(request: Request, call_next): Codec Carver SaaS -

+

Shrink Media File

@@ -192,6 +192,7 @@ async def add_security_headers(request: Request, call_next):

-
+

Shrink Multiple Files

diff --git a/tests/test_saas_ui_contract.py b/tests/test_saas_ui_contract.py new file mode 100644 index 00000000..4ac83be3 --- /dev/null +++ b/tests/test_saas_ui_contract.py @@ -0,0 +1,233 @@ +"""Executable contracts for the embedded SaaS browser UI.""" + +import json +import re +import shutil +import subprocess +import textwrap +import unittest +from pathlib import Path + +import saas_web + + +SOURCE_TEXT = (Path(__file__).resolve().parents[1] / "saas_web.py").read_text( + encoding="utf-8" +) + + +class TestSaasUiContract(unittest.TestCase): + """Keep DOM registration and nested-control handling safe.""" + + def test_drop_zone_registration_waits_for_complete_dom(self): + """Batch controls must exist before any listener looks them up.""" + self.assertIn("window.addEventListener('DOMContentLoaded'", SOURCE_TEXT) + self.assertLess( + SOURCE_TEXT.index("window.addEventListener('DOMContentLoaded'"), + SOURCE_TEXT.index( + "document.getElementById('batch_preset_buttons_container')" + ), + ) + self.assertIn("window.updateFileSizePreview = function(input)", SOURCE_TEXT) + + def test_nested_interactive_elements_do_not_open_file_picker_twice(self): + """A child span inside a label remains part of that interactive label.""" + self.assertEqual( + SOURCE_TEXT.count("e.target.closest('input, button, label')"), 2 + ) + + def test_drop_zone_click_behavior_executes_against_dom_harness(self): + """Execute the generated script and prove both picker click boundaries.""" + node = shutil.which("node") + self.assertIsNotNone( + node, + "Node.js is required for the embedded-browser UI contract test", + ) + + scripts = re.findall( + r"", + saas_web.HTML_TEMPLATE, + flags=re.DOTALL, + ) + self.assertEqual(len(scripts), 1) + script_literal = json.dumps(scripts[0]) + + harness = textwrap.dedent( + f""" + const vm = require('node:vm'); + + class FakeElement {{ + constructor(tagName = 'DIV', parentElement = null) {{ + this.tagName = tagName; + this.parentElement = parentElement; + this.listeners = new Map(); + this.clickCount = 0; + this.dataset = {{}}; + this.style = {{}}; + this.files = []; + this.value = ''; + this.classList = {{ + contains: () => false, + add: () => {{}}, + remove: () => {{}}, + }}; + }} + + addEventListener(type, callback) {{ + if (!this.listeners.has(type)) this.listeners.set(type, []); + this.listeners.get(type).push(callback); + }} + + dispatch(type, target = this) {{ + for (const callback of this.listeners.get(type) || []) {{ + callback({{ + target, + dataTransfer: {{ files: [] }}, + preventDefault() {{}}, + stopPropagation() {{}}, + isTrusted: true, + }}); + }} + }} + + closest(selector) {{ + const accepted = new Set( + selector.split(',').map((part) => part.trim().toUpperCase()) + ); + let current = this; + while (current) {{ + if (accepted.has(current.tagName)) return current; + current = current.parentElement; + }} + return null; + }} + + click() {{ + this.clickCount += 1; + }} + + setCustomValidity() {{}} + removeAttribute() {{}} + setAttribute() {{}} + }} + + const ids = [ + 'preset_buttons_container', + 'batch_preset_buttons_container', + 'target_bytes', + 'batch_target_bytes', + 'target_bytes_preview', + 'batch_target_bytes_preview', + 'shrink-form', + 'submit-btn', + 'batch_files_preview', + 'shrink-batch-form', + 'batch-submit-btn', + 'drop-zone', + 'batch-drop-zone', + 'file', + 'batch_files', + 'file_size_preview', + ]; + + const inputIds = new Set([ + 'target_bytes', + 'batch_target_bytes', + 'file', + 'batch_files', + ]); + const buttonIds = new Set(['submit-btn', 'batch-submit-btn']); + const elements = Object.fromEntries( + ids.map((id) => [ + id, + new FakeElement( + inputIds.has(id) ? 'INPUT' : buttonIds.has(id) ? 'BUTTON' : 'DIV' + ), + ]) + ); + + const lateIds = new Set([ + 'batch_preset_buttons_container', + 'batch_target_bytes', + 'batch_target_bytes_preview', + 'batch_files_preview', + 'shrink-batch-form', + 'batch-submit-btn', + 'batch-drop-zone', + 'batch_files', + ]); + let parserComplete = false; + + const document = {{ + body: new FakeElement('BODY'), + getElementById(id) {{ + if (lateIds.has(id) && !parserComplete) return null; + if (!(id in elements)) throw new Error(`missing fake element: ${{id}}`); + return elements[id]; + }}, + querySelectorAll() {{ + return []; + }}, + }}; + + const windowListeners = new Map(); + const window = {{ + addEventListener(type, callback) {{ + windowListeners.set(type, callback); + }}, + }}; + + global.document = document; + global.window = window; + global.Event = class Event {{ + constructor(type, init = {{}}) {{ + this.type = type; + Object.assign(this, init); + }} + }}; + + vm.runInThisContext({script_literal}); + const ready = windowListeners.get('DOMContentLoaded'); + if (!ready) throw new Error('DOMContentLoaded registration missing'); + + parserComplete = true; + ready(); + + const empty = new FakeElement('DIV'); + const input = new FakeElement('INPUT'); + const button = new FakeElement('BUTTON'); + const label = new FakeElement('LABEL'); + const nestedInLabel = new FakeElement('SPAN', label); + + const dropZone = elements['drop-zone']; + const batchDropZone = elements['batch-drop-zone']; + + dropZone.dispatch('click', empty); + batchDropZone.dispatch('click', empty); + for (const target of [input, button, label, nestedInLabel]) {{ + dropZone.dispatch('click', target); + batchDropZone.dispatch('click', target); + }} + + process.stdout.write(JSON.stringify({{ + fileClicks: elements.file.clickCount, + batchClicks: elements.batch_files.clickCount, + }})); + """ + ) + + completed = subprocess.run( + [node, "-e", harness], + check=True, + capture_output=True, + text=True, + timeout=10, + ) + self.assertEqual( + json.loads(completed.stdout), + {"fileClicks": 1, "batchClicks": 1}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_saas_web.py b/tests/test_saas_web.py index ab098f5e..1d6c53f7 100644 --- a/tests/test_saas_web.py +++ b/tests/test_saas_web.py @@ -47,8 +47,13 @@ def test_get_ui_includes_accessible_file_input_helpers(self): self.assertIn('id="file_help"', html) self.assertIn('class="required-star" aria-hidden="true"', html) - self.assertIn('role="region" aria-label="File Upload Drop Zone"', html) - self.assertIn('role="region" aria-label="Batch File Upload Drop Zone"', html) + self.assertEqual(html.count("e.target.closest('input, button, label')"), 2) + self.assertIn("fileInput.click();", html) + self.assertIn("window.updateFileSizePreview = function(input)", html) + self.assertLess( + html.index("window.addEventListener('DOMContentLoaded'"), + html.index("document.getElementById('batch_preset_buttons_container')"), + ) def test_get_ui_includes_binary_file_size_validation(self): response = client.get("/") @@ -672,7 +677,7 @@ def test_get_ui_includes_batch_upload_form(self): self.assertIn('aria-describedby="batch_files_help batch_files_preview"', html) self.assertIn('onchange="updateBatchFilePreview(this)"', html) self.assertIn('id="batch_files_preview"', html) - self.assertIn("function updateBatchFilePreview(input)", html) + self.assertIn("window.updateBatchFilePreview = function(input)", html) @unittest.skipUnless( From a8dce403487dfcf31e25e32ab21e5c5197296bd1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:02:32 +0900 Subject: [PATCH 10/13] docs(a11y): preserve nested control click guard --- .jules/palette.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.jules/palette.md b/.jules/palette.md index dcdb8386..24f98ae3 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -1,6 +1,6 @@ ## 2024-08-14 - 드롭 존 클릭 영역 활성화 시의 상호작용 제어 학습 **Learning:** 파일 업로드 폼을 감싸는 전체 드롭 존(box)을 클릭 가능하도록 확장할 때, 폼 내부의 INPUT, BUTTON, LABEL 등 원래의 상호작용 가능한 하위 요소에 대한 클릭까지 모두 부모 래퍼의 이벤트로 처리하면 의도치 않은 중복 동작이나 UI 오작동이 발생함을 확인. -**Action:** 상호작용하는 드롭 존 이벤트를 추가할 시, `!['INPUT', 'BUTTON', 'LABEL'].includes(e.target.tagName)` 와 같은 명시적인 예외 처리를 도입하여 원래의 폼 기능을 해치지 않고 UX를 개선한다. +**Action:** 상호작용하는 드롭 존 이벤트를 추가할 시, `!e.target.closest('input, button, label')` 같은 조상 기반 예외 처리를 사용하여 LABEL 내부의 SPAN 등 중첩 요소까지 원래 폼 상호작용으로 취급하고, 부모 드롭 존이 파일 선택을 중복 실행하지 않도록 한다. ## 2024-07-15 - Dynamic Size formatting and Total Size Validation **Learning:** Hardcoding human-readable sizes (like '5 GiB') in validation error messages is error-prone when the underlying constant changes. Moreover, failing to validate total upload size against backend limits (e.g., MAX_UPLOAD_BYTES) in batch file uploads frustrates users who wait for a large upload to finish only to get a server-side 413 Payload Too Large error. **Action:** Always format backend byte limit constants dynamically (e.g., `formatBinaryBytes(MAX_UPLOAD_BYTES)`) on the client side to display accurate error messages. For multiple file inputs, ensure both the file count and the combined file size are validated against backend limits, giving immediate inline feedback via `setCustomValidity` and `aria-invalid`. From 2208840421f865d6fa02c35b19673902cdd4162f Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:35:20 +0000 Subject: [PATCH 11/13] Fix UI contract test missing fastapi import --- tests/test_saas_ui_contract.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_saas_ui_contract.py b/tests/test_saas_ui_contract.py index 4ac83be3..6ae994f8 100644 --- a/tests/test_saas_ui_contract.py +++ b/tests/test_saas_ui_contract.py @@ -8,7 +8,11 @@ import unittest from pathlib import Path -import saas_web +try: + import saas_web + _HAS_FASTAPI = True +except ImportError: + _HAS_FASTAPI = False SOURCE_TEXT = (Path(__file__).resolve().parents[1] / "saas_web.py").read_text( @@ -16,6 +20,7 @@ ) +@unittest.skipUnless(_HAS_FASTAPI, "fastapi not installed") class TestSaasUiContract(unittest.TestCase): """Keep DOM registration and nested-control handling safe.""" From 05c86b6dd33def842bdf29902643e56c78523110 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:41:16 +0000 Subject: [PATCH 12/13] Fix UI contract test missing fastapi import From 114205060aace6f1509e96f2fdfa303301e3a293 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:40:18 +0000 Subject: [PATCH 13/13] Fix UI contract test missing fastapi import