diff --git a/MAIN_PLAN.md b/MAIN_PLAN.md index a0c1f3c..a89658e 100644 --- a/MAIN_PLAN.md +++ b/MAIN_PLAN.md @@ -248,6 +248,56 @@ - 이동하지 않은 축은 기존 좌표 유지하여 비의도 축 보정 방지 - 결과: 전체 테스트 그린(168 파일 / 295 테스트) +### CI E2E 수정: 마퀴 시작 판정 완화 (2025-11-17) +- 원인: 빈 영역 드래그 시에도 마퀴가 시작해야 하나, 캔버스 data-testid 직접 비교로 인해 실제 히트박스에서 시작 누락 → aria-live "선택 0개" 유지 +- 조치: `components/wysiwyg/Canvas.tsx`의 `onMouseDown`에서 객체/핸들 근접 여부 검사(`closest`)로 판정 완화, 객체/핸들이 아닌 영역이면 항상 마퀴 시작 +- 결과: E2E 라이브리전 멀티선택 문구 안정화, 전체 테스트 그린 유지 + +### E2E 스모크: 그룹 드래그 스냅 델타 동일 적용 (2025-11-17) +- 테스트: `tests/e2e/wysiwyg-group-drag.e2e.ts` + - 마퀴로 2개 선택 후, 선택 중 하나를 +16px 드래그 → 8px 스냅 반영 동일 델타로 양 객체 이동 검증 +- 기대/결과: 스냅된 델타(dx)가 선택 전체에 동일하게 적용됨 확인 + +### E2E 스모크 확장: 그룹 리사이즈(E)/회전(15°) + 가이드 라이브리전 (2025-11-17) +- 테스트: `tests/e2e/wysiwyg-group-resize-rotate.e2e.ts` + - 그룹 E 핸들 리사이즈: 좌표 고정, width만 스냅 증가, 두 객체 동일 증분 + - 회전 핸들: 15° 단위 스냅, 드래그 중 라이브리전에 가이드 좌표 문구 포함 +- 결과: 그룹 액션 스냅/가이드 UX 안정화 스모크 통과 + +### 이미지 최적화 옵션 2차 TDD (2025-11-17) +- 테스트: `lib/exporter/image.responsive.options.matrix.test.ts` + - loading/decoding/fetchpriority 적용 + - srcset + autoSizes + preset 동시 제공 시 preset 우선 + - explicit sizes가 있으면 preset/autoSizes보다 우선 +- 구현 상태: `lib/exporter/html.ts`의 기존 로직이 요구사항 충족(우선순위: explicit > preset > default(autoSizes)) + +### Export CSS 토큰 딥 회귀 (폼/테마) (2025-11-17) +- 테스트: `lib/wysiwyg/export.style.tokens.form.deep.test.ts` + - 토큰 존재: `--ring`, `--ring-offset`, `--disabled-bg`, `--disabled-fg` + - 사용 검증: `:focus-visible` 링/오프셋, `:disabled` BG, `[readonly]` FG + - 테마 프리셋: `[data-theme="light|dark"]`에서 `--bg` 제공, `.frame{background:var(--bg)}` 유지 +- 결과: 토큰/참조/테마 오버라이드 회귀 보강, 전체 테스트 그린(170 파일/300 테스트) + +### 스냅 우선순위 E2E (에지 > 센터 > 그리드) (2025-11-17) +- 테스트: `tests/e2e/wysiwyg-snap-priority.e2e.ts` + - 에지 스냅 우선: 인접 객체 에지 정렬이 그리드보다 우선 적용 + - 센터 스냅 우선: 중심 정렬 후보 시 그리드보다 센터 스냅 우선 + - 그리드 단독: 인접 후보가 없을 때 13px → 16px 그리드 스냅 +- 결과: 스냅 우선순위 정책(에지 > 센터 > 그리드) 회귀 스모크 확보 + +### 회전 후 스냅 유지 E2E (에지 > 그리드) (2025-11-17) +- 테스트: `tests/e2e/wysiwyg-rotate-snap.e2e.ts` + - 객체를 15° 회전 후 드래그해도 에지 스냅이 그리드보다 우선 적용됨 검증 + - 드래그 중 라이브리전 가이드 텍스트 노출 확인(가이드 x/y) +- 결과: 회전 상태에서도 스냅 우선순위 일관성 확보, 라이브리전 가이드 동작 스모크 통과 + +### Export 폼 a11y: radio fieldset aria-describedby/help/id 연결 (2025-11-18) +- 테스트: `lib/exporter/formFieldset.a11y.test.ts` + - fieldset에 `aria-describedby`로 help id 연결(`field-*-help`) + - legend는 label과 동일 텍스트 사용 + - 각 option은 고유 id(`field-*-opt-*`)와 label for 매핑, 동일 name 공유 +- 결과: 라디오 그룹 a11y 구조 회귀 스냅샷 확보(legend/fieldset/help/id 일관성) + ## 목표 - 비개발자도 사용 가능한 단일 페이지 랜딩 페이지 빌더. - 결과물: 정적 ZIP(HTML/CSS/JS) 다운로드. 이미지: 외부 URL 또는 로컬 업로드(서버 저장 없음, ZIP에 패키징). 폰트는 외부 CDN 허용. diff --git a/lib/exporter/formError.regression.test.ts b/lib/exporter/formError.regression.test.ts new file mode 100644 index 0000000..87732c0 --- /dev/null +++ b/lib/exporter/formError.regression.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest' +import { exportPage } from '@/lib/exporter/html' +import type { Page } from '@/lib/schema/page' + +const makePage = (overrides: Partial = {}): Page => ({ + title: 'Form Error Regression', + locale: 'en', + theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' }, + sections: [], + form: { + actionUrl: '', + fields: [ + { type: 'text', name: 'name', label: 'Name', required: true, pattern: '^[A-Za-z ]+$', maxLength: 30, help: 'Enter your name' }, + { type: 'email', name: 'email', label: 'Email', required: true, help: 'We will contact you' }, + { type: 'textarea', name: 'msg', label: 'Message', required: false, maxLength: 200, help: 'Optional' }, + ], + spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 }, + }, + analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' }, + seo: { title: 'T', description: 'D' }, + ...overrides, +}) + +describe('Exporter - form error/required/pattern regression', () => { + it('adds required/pattern/maxlength attributes and aria-describedby links', () => { + const page = makePage() + const { html } = exportPage(page) + + // required/pattern on text + expect(html).toMatch(/]*name="name"[^>]*required/) + expect(html).toMatch(/]*name="name"[^>]*pattern="\^\[A-Za-z \]\+\$"/) + expect(html).toMatch(/]*name="name"[^>]*maxlength="30"/) + // describedby via help + expect(html).toMatch(/