Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cb0d974
🎨 Palette: 폼 유효성 검사 피드백 개선 및 인라인 스타일 제거
seonghobae Sep 6, 2026
cfe17a1
🎨 Palette: 폼 유효성 검사 피드백 개선 및 인라인 스타일 제거
seonghobae Sep 6, 2026
ea60b2b
🎨 Palette: 폼 유효성 검사 피드백 개선 및 인라인 스타일 제거
seonghobae Sep 6, 2026
55da6a7
🎨 Palette: 폼 유효성 검사 피드백 개선 및 인라인 스타일 제거
seonghobae Sep 6, 2026
f6c8211
🎨 Palette: 폼 유효성 검사 피드백 개선 및 인라인 스타일 제거
seonghobae Sep 7, 2026
a0e796e
🎨 Palette: 폼 유효성 검사 피드백 개선 및 인라인 스타일 제거
seonghobae Sep 7, 2026
2868e7c
test(ui): lock required file and batch listener contracts
seonghobae Sep 8, 2026
7febacc
docs(product): baseline required upload feedback
seonghobae Sep 8, 2026
0e488ce
docs(changelog): record required upload feedback repair
seonghobae Sep 8, 2026
fb44501
docs(ui): align required-field guidance with current contract
seonghobae Sep 8, 2026
9e8f2ac
test(ui): cover initial required file rejection
seonghobae Sep 8, 2026
fe02333
🎨 Palette: 폼 유효성 검사 피드백 개선 및 인라인 스타일 제거
seonghobae Sep 8, 2026
25951ba
repair(required-input): restore validated contract and baseline
seonghobae Sep 8, 2026
652a867
test(ui): cover required target presentation state
seonghobae Sep 8, 2026
77d59e0
🎨 Palette: 폼 유효성 검사 피드백 개선 및 인라인 스타일 제거
seonghobae Sep 8, 2026
9183ad6
test(ui): restore required-feedback coverage
seonghobae Sep 9, 2026
bda7934
🎨 Palette: 폼 유효성 검사 피드백 개선 및 인라인 스타일 제거
seonghobae Sep 9, 2026
b376531
🎨 Palette: 폼 유효성 검사 피드백 개선 및 인라인 스타일 제거
seonghobae Sep 9, 2026
063745b
🎨 Palette: 입력 폼 검증(Validation) 관련 인라인 피드백 UI 추가 및 버그 수정
seonghobae Sep 9, 2026
8794983
🎨 Palette: CHANGELOG.md 업데이트 및 불필요한 패치 파일 정리
seonghobae Sep 11, 2026
c2b9e68
🎨 Palette: 테스트 및 인프라 CI 환경 관련 검토 완료
seonghobae Sep 11, 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
3 changes: 3 additions & 0 deletions .jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,6 @@
## 2024-08-04 - 숫자 입력 필드 빈 문자열 상태 초기화 처리
**학습:** 숫자 입력 필드에서 빈 문자열('')을 입력할 때 브라우저는 이전의 유효하지 않은 상태를 암시적으로 유지하므로, 사용자 정의 검증을 명시적으로 초기화하지 않으면 네이티브 HTML5 유효성 검사가 정상 작동하지 않을 수 있음을 확인했습니다.
**실행:** 인라인 검증 스크립트 작성 시 빈 문자열 상태를 별도로 확인하여 this.setCustomValidity('') 및 this.removeAttribute('aria-invalid')를 명시적으로 호출하는 로직을 추가해야 합니다.
## 2024-09-09 - 일괄 업로드 폼 입력 필수 항목 검증 (Validation) 및 인라인 피드백 접근성 개선
**Learning:** 파일 및 용량(target_bytes) 입력 필드가 비어있을 때 HTML5 툴팁만으로는 오류 상태가 명확하지 않으며, 보조 기기(스크린 리더) 사용자가 입력 누락을 파악하기 어렵다는 점을 확인했습니다.
**Action:** 필수 입력 항목이 비어있을 경우 명시적으로 "This field is required."라는 인라인 에러 메시지를 표시하고, `aria-invalid="true"` 속성 및 시각적 에러 스타일(적색 텍스트 및 별표 아이콘)을 동적으로 적용하여 사용성과 접근성을 향상시켜야 합니다.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- 다중 파일 업로드 선택 시 즉각적인 파일 개수 피드백 및 제한 초과 경고 메시지 추가
- 일괄 업로드 폼에 대상 바이트 프리셋 버튼과 총 파일 크기 미리보기를 추가하여 사용성을 개선했습니다.
- 클라이언트 측 폼 검증 시 하드코딩된 '5 GiB' 텍스트를 동적으로 변환되도록 수정하고 일괄 업로드 폼에 최대 크기(MAX_UPLOAD_BYTES) 검증 피드백을 추가했습니다.
- 단일 및 일괄 업로드 폼에서 파일 또는 대상 크기 입력이 비어있을 때 명확한 "This field is required." 인라인 메시지와 `aria-invalid=true` 속성을 노출하도록 클라이언트 측 검증 로직을 강화했습니다.

### Changed
- 순수 영숫자 토큰은 정규식 호출을 건너뛰되 다국어·문장부호 토큰화 결과는 기존 의미와 동일하게 유지합니다. 근거, 한계, APA 7 참고문헌은 [`docs/doctoring/token-fast-path-equivalence.md`](docs/doctoring/token-fast-path-equivalence.md)에 기록했습니다.
Expand Down
89 changes: 55 additions & 34 deletions saas_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,8 @@ async def add_security_headers(request: Request, call_next):
button:hover:not(:disabled) { background-color: #004085; }
button:disabled { background-color: #6c757d; cursor: not-allowed; }
button:focus-visible, input:focus-visible { outline: 2px solid #004085; outline-offset: 2px; }
.required-star { color: #dc3545; }
.help-text { color: #6c757d; font-size: 0.85em; display: inline-block; margin-top: 4px; }
.required-star { color: #dc3545; }
.spinner { display: inline-block; width: 1em; height: 1em; vertical-align: -0.125em; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spinner-border .75s linear infinite; margin-right: 8px; }
@keyframes spinner-border { to { transform: rotate(360deg); } }
.box { transition: background-color 0.2s, border-color 0.2s; }
Expand Down Expand Up @@ -190,6 +190,31 @@ async def add_security_headers(request: Request, call_next):
</p>
<button type="submit" id="submit-btn">Upload and Shrink</button>
</form>
</div>
<div class="box" id="batch-drop-zone" style="margin-top: 20px;">
<h2>Shrink Multiple Files</h2>
<form action="/shrink-batch" method="post" enctype="multipart/form-data" id="shrink-batch-form">
<p>
<label for="batch_files">Media Files (up to 20): <span class="required-star" aria-hidden="true">*</span></label><br>
<input type="file" id="batch_files" name="files" accept="audio/*,video/*" multiple aria-describedby="batch_files_help batch_files_preview" required onchange="updateBatchFilePreview(this)">
<br><span id="batch_files_help" class="help-text">Select several audio or video files, or drag and drop them here. You get back one zip with every output plus a results.json manifest.</span>
<br><span id="batch_files_preview" class="help-text" aria-live="polite" style="font-weight: bold; color: #0f6674;"></span>
</p>
<p>
<label for="batch_target_bytes">Target Bytes (per file): <span class="required-star" aria-hidden="true">*</span></label><br>
<input type="number" id="batch_target_bytes" name="target_bytes" value="2000000000" min="1" aria-describedby="batch_target_bytes_help batch_target_bytes_preview" required>
<br><span id="batch_target_bytes_help" class="help-text">Maximum allowed size in bytes for each output file</span>
<br><span id="batch_target_bytes_preview" class="help-text" aria-live="polite" style="font-weight: bold; color: #1e7e34;">1.86 GiB</span>
<div id="batch_preset_buttons_container" class="preset-container" role="group" aria-label="Preset target sizes for batch">
<button type="button" class="preset-btn" data-bytes="26214400" aria-pressed="false">25 MiB</button>
<button type="button" class="preset-btn" data-bytes="104857600" aria-pressed="false">100 MiB</button>
<button type="button" class="preset-btn" data-bytes="524288000" aria-pressed="false">500 MiB</button>
<button type="button" class="preset-btn" data-bytes="1073741824" aria-pressed="false">1 GiB</button>
</div>
</p>
<button type="submit" id="batch-submit-btn">Upload and Shrink Batch</button>
</form>
</div>
<script>
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024 * 1024;
function formatBinaryBytes(value) {
Expand Down Expand Up @@ -223,9 +248,14 @@ async def add_security_headers(request: Request, call_next):
const preview = document.getElementById('file_size_preview');
input.setCustomValidity('');
input.removeAttribute('aria-invalid');
preview.classList.remove('required-star');
preview.style.color = '#0f6674';
if (!file) {
preview.innerText = '';
preview.innerText = 'This field is required.';
preview.style.color = '';
preview.classList.add('required-star');
Comment thread
seonghobae marked this conversation as resolved.
input.setCustomValidity('This field is required.');
input.setAttribute('aria-invalid', 'true');
return;
}
const text = formatBinaryBytes(file.size);
Expand All @@ -245,6 +275,7 @@ async def add_security_headers(request: Request, call_next):
const preview = document.getElementById('target_bytes_preview');
this.setCustomValidity('');
this.removeAttribute('aria-invalid');
preview.classList.remove('required-star');
preview.style.color = '#1e7e34';

const buttons = document.querySelectorAll('#preset_buttons_container .preset-btn');
Expand All @@ -257,9 +288,11 @@ async def add_security_headers(request: Request, call_next):
});

if (this.value === '') {
preview.innerText = '';
this.setCustomValidity('');
this.removeAttribute('aria-invalid');
preview.innerText = 'This field is required.';
preview.style.color = '';
preview.classList.add('required-star');
this.setCustomValidity('This field is required.');
this.setAttribute('aria-invalid', 'true');
return;
}

Expand All @@ -279,6 +312,7 @@ async def add_security_headers(request: Request, call_next):
const preview = document.getElementById('batch_target_bytes_preview');
this.setCustomValidity('');
this.removeAttribute('aria-invalid');
preview.classList.remove('required-star');
preview.style.color = '#1e7e34';

const buttons = document.querySelectorAll('#batch_preset_buttons_container .preset-btn');
Expand All @@ -291,9 +325,11 @@ async def add_security_headers(request: Request, call_next):
});

if (this.value === '') {
preview.innerText = '';
this.setCustomValidity('');
this.removeAttribute('aria-invalid');
preview.innerText = 'This field is required.';
preview.style.color = '';
preview.classList.add('required-star');
this.setCustomValidity('This field is required.');
this.setAttribute('aria-invalid', 'true');
return;
}

Expand All @@ -320,11 +356,16 @@ async def add_security_headers(request: Request, call_next):
const preview = document.getElementById('batch_files_preview');
input.setCustomValidity('');
input.removeAttribute('aria-invalid');
preview.classList.remove('required-star');
preview.style.color = '#0f6674';

const files = input.files;
if (!files || files.length === 0) {
preview.innerText = '';
preview.innerText = 'This field is required.';
preview.style.color = '';
preview.classList.add('required-star');
input.setCustomValidity('This field is required.');
input.setAttribute('aria-invalid', 'true');
return;
}

Expand Down Expand Up @@ -366,6 +407,11 @@ async def add_security_headers(request: Request, call_next):
const fileInput = document.getElementById('file');
const batchFileInput = document.getElementById('batch_files');

fileInput.addEventListener('invalid', () => updateFileSizePreview(fileInput));
if (batchFileInput) {
batchFileInput.addEventListener('invalid', () => updateBatchFilePreview(batchFileInput));
}

[dropZone, batchDropZone].forEach(zone => {
if (!zone) return;
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
Expand Down Expand Up @@ -404,31 +450,6 @@ async def add_security_headers(request: Request, call_next):
}, false);
}
</script>
</div>
<div class="box" id="batch-drop-zone" style="margin-top: 20px;">
<h2>Shrink Multiple Files</h2>
<form action="/shrink-batch" method="post" enctype="multipart/form-data" id="shrink-batch-form">
<p>
<label for="batch_files">Media Files (up to 20): <span class="required-star" aria-hidden="true">*</span></label><br>
<input type="file" id="batch_files" name="files" accept="audio/*,video/*" multiple aria-describedby="batch_files_help batch_files_preview" required onchange="updateBatchFilePreview(this)">
<br><span id="batch_files_help" class="help-text">Select several audio or video files, or drag and drop them here. You get back one zip with every output plus a results.json manifest.</span>
<br><span id="batch_files_preview" class="help-text" aria-live="polite" style="font-weight: bold; color: #0f6674;"></span>
</p>
<p>
<label for="batch_target_bytes">Target Bytes (per file): <span class="required-star" aria-hidden="true">*</span></label><br>
<input type="number" id="batch_target_bytes" name="target_bytes" value="2000000000" min="1" aria-describedby="batch_target_bytes_help batch_target_bytes_preview" required>
<br><span id="batch_target_bytes_help" class="help-text">Maximum allowed size in bytes for each output file</span>
<br><span id="batch_target_bytes_preview" class="help-text" aria-live="polite" style="font-weight: bold; color: #1e7e34;">1.86 GiB</span>
<div id="batch_preset_buttons_container" class="preset-container" role="group" aria-label="Preset target sizes for batch">
<button type="button" class="preset-btn" data-bytes="26214400" aria-pressed="false">25 MiB</button>
<button type="button" class="preset-btn" data-bytes="104857600" aria-pressed="false">100 MiB</button>
<button type="button" class="preset-btn" data-bytes="524288000" aria-pressed="false">500 MiB</button>
<button type="button" class="preset-btn" data-bytes="1073741824" aria-pressed="false">1 GiB</button>
</div>
</p>
<button type="submit" id="batch-submit-btn">Upload and Shrink Batch</button>
</form>
</div>
</body>
</html>
"""
Expand Down
89 changes: 89 additions & 0 deletions tests/test_empty_file_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Contracts for required file-input feedback and batch listener readiness."""

from __future__ import annotations

import unittest
from pathlib import Path


SOURCE_TEXT = (Path(__file__).resolve().parents[1] / "saas_web.py").read_text(
encoding="utf-8"
)


class EmptyFileValidationTests(unittest.TestCase):
"""Single and batch file inputs must expose the same missing-required state."""

@staticmethod
def _handler_between(start_marker: str, end_marker: str) -> str:
"""Return one JavaScript function body from the web source text."""

start = SOURCE_TEXT.index(start_marker)
end = SOURCE_TEXT.index(end_marker, start)
return SOURCE_TEXT[start:end]

def test_single_file_empty_state_is_explicit(self) -> None:
"""Clearing the single-file control keeps visible and semantic feedback."""

handler = self._handler_between(
"function updateFileSizePreview(input) {",
"document.getElementById('target_bytes').addEventListener('input'",
)
self._assert_required_empty_state(handler, "if (!file) {")

def test_batch_file_empty_state_is_explicit(self) -> None:
"""Clearing the batch-file control follows the same required contract."""

handler = self._handler_between(
"function updateBatchFilePreview(input) {",
"document.getElementById('shrink-batch-form').addEventListener('submit'",
)
self._assert_required_empty_state(
handler,
"if (!files || files.length === 0) {",
)

def test_initial_required_file_submission_uses_same_inline_feedback(self) -> None:
"""Native required rejection must populate the same visible/semantic state."""

self.assertIn(
"fileInput.addEventListener('invalid', () => updateFileSizePreview(fileInput));",
SOURCE_TEXT,
)
self.assertIn(
"batchFileInput.addEventListener('invalid', () => "
"updateBatchFilePreview(batchFileInput));",
SOURCE_TEXT,
)

def test_batch_controls_exist_before_script_binds_listeners(self) -> None:
"""The inline script must not dereference batch controls before they exist."""

batch_form = SOURCE_TEXT.index('id="shrink-batch-form"')
script = SOURCE_TEXT.index("<script>", batch_form)
batch_listener = SOURCE_TEXT.index(
"document.getElementById('batch_preset_buttons_container').addEventListener",
script,
)
self.assertLess(batch_form, script)
self.assertLess(script, batch_listener)

def _assert_required_empty_state(self, handler: str, empty_marker: str) -> None:
"""Require stale-error cleanup followed by one explicit missing-file verdict."""

cleanup_marker = "preview.classList.remove('required-star');"
self.assertIn(cleanup_marker, handler)
self.assertLess(handler.index(cleanup_marker), handler.index(empty_marker))

empty_start = handler.index(empty_marker)
empty_end = handler.index("return;", empty_start)
empty_branch = handler[empty_start:empty_end]
self.assertIn("preview.innerText = 'This field is required.';", empty_branch)
self.assertIn("preview.style.color = '';", empty_branch)
self.assertIn("preview.classList.add('required-star');", empty_branch)
self.assertIn("input.setCustomValidity('This field is required.');", empty_branch)
self.assertIn("input.setAttribute('aria-invalid', 'true');", empty_branch)


if __name__ == "__main__":
unittest.main()
6 changes: 3 additions & 3 deletions tests/test_empty_target_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ def _assert_empty_branch(self, handler: str) -> None:
empty_marker = "if (this.value === '') {"
invalid_marker = "if (isNaN(val) || val <= 0) {"
self.assertIn(empty_marker, handler)
self.assertIn("preview.innerText = '';", handler)
self.assertIn("this.setCustomValidity('');", handler)
self.assertIn("this.removeAttribute('aria-invalid');", handler)
self.assertIn("preview.innerText = 'This field is required.';", handler)
self.assertIn("this.setCustomValidity('This field is required.');", handler)
self.assertIn("this.setAttribute('aria-invalid', 'true');", handler)
self.assertIn(
"return;",
handler[handler.index(empty_marker) : handler.index(invalid_marker)],
Expand Down
Loading