Compare commits

...

4 Commits

Author SHA1 Message Date
ttipo f4a85fda27 docs: 쓰기 화면 스크롤과 목록 고르기 칸 고침을 디자인 README · 스킬에 적는다
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 23m1s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 14m22s
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 15m10s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 3m50s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m41s
CI (Flutter) / Flutter (analyze · test) (push) Successful in 22m57s
CI / Backend (lint · type-check · test · e2e · build) (push) Successful in 13m47s
CI / Frontend (lint · type-check · test · build) (push) Successful in 13m15s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Successful in 1m51s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:14:52 +09:00
ttipo 0ff1ec46af fix(frontend): 목록 보기에서 고르기 상자를 누르면 메일까지 열리던 것을 고친다
목록 보기의 줄은 줄 전체의 click 에서 event.target.closest('button') 로 단추를 가렸다. 상자 가운데(그림 rect)를 누르면 단추 처리기가 고르기를 바꾼 뒤
처리기 사이에 Vue 가 다시 그려 그 그림이 문서에서 떨어지고, 줄 처리기의 closest 가 비어 메일까지 열렸다(사용자 보고).

- 무엇을 눌렀는지는 사건이 지나간 길(composedPath — eventPathHas)로 가르고, 상자 · 별 그림은 pointer-events: none 이다
- 고르기 칸의 빈자리를 눌러도 고른다(Shift 는 범위 · Ctrl 은 하나씩), 별 칸의 빈자리는 아무 일도 없다. 대화로 묶은 목록의 줄도 같다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:14:52 +09:00
ttipo f3586abcd7 fix(frontend): 받는 사람이 많으면 쓰기 화면의 [보내기]가 화면 밖으로 숨던 것을 고친다
받는 사람 · 참조 · 숨은참조가 여러 줄이 되면 편집 영역(최소 300)이 줄지 않아 쓰기 칸 전체가 화면을 넘쳤다. 바깥 스크롤과 본문 스크롤이 겹치고,
흐름 끝의 [보내기] 줄이 화면 밖으로 밀렸다(사용자 보고 — 전 직원을 숨은참조에 넣었을 때).

- [보내기] 줄은 스크롤하는 칸의 아래에 붙는다(sticky). 겹침 차례는 편집기 툴바 2 < [보내기] 줄 3 < 받는 사람 추천 목록 · 메뉴를 연 툴바 4
- 칩 · 입력 칸은 96px 을 넘으면 그 칸 안에서 스크롤한다(초점 · 칩 추가 · 입력 때 맨 아래로). 넓은 화면은 편집 영역이 200 까지 먼저 줄어 보통 높이에서 바깥 스크롤이 생기지 않는다
- 넓은 화면에서 숨긴 낭독 글이 앱 틀 밖에 놓여 문서까지 스크롤하던 것을 고치고(.compose position: relative), [보내기] 줄에 가린 칸으로 옮긴 초점은 가린 만큼 올린다(useFocusAboveFooter)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 22:14:52 +09:00
ttipo 6eba013416 docs(backend): 업데이트 내역 첫 글의 날짜를 배포 날(11월 12일)로 정한다
배포 일정이 11월 12일(목) 오전 1시로 정해져 파일 이름을 2026-11-12.md 로 바꾸고 날짜 메모 줄을 지웠다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:35:24 +09:00
20 changed files with 1152 additions and 23 deletions
+7 -4
View File
@@ -42,9 +42,10 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 화면 이동 규칙 | `src/composables/useMailNavigation.ts` | 이전/다음은 replace, "목록으로"는 뒤로 가기 또는 replace (기록이 쌓이지 않게) |
| 발송 키 규칙 | `src/shared/utils/idempotency-key.ts` 의 `shouldRenewSendKey()` | `Idempotency-Key` 는 성공과 `MAIL_005` 때만 바꾼다 — 그 밖의 실패 뒤 다시 보내기는 같은 키 (계약 2절 "발송 본문"). UUID 만들기는 `shared/utils/uuid.ts` 하나(필터 id 와 공용 — `crypto.randomUUID` 가 없으면 대체 경로) |
| 첨부 사전 검사 | `src/shared/utils/mail-attachment.ts` 의 `precheckAttachment()` | 형식·파일당·합계 한도(`mail.store` 의 `attachmentMaxBytes`·`attachmentTotalMaxBytes`, 서버 값이 없으면 대체값). 넘는 파일은 올리지 않는다 |
| 메일 본문 편집기 | `src/components/mail/editor/` — `MailEditor.vue` (쓰기 화면이 `defineAsyncComponent` 로 불러온다) | TipTap core (MIT 무료 패키지만, `@tiptap/*` 는 모두 같은 버전으로 고정). 툴바 배치·좁은 폭에서 접는 순서·단축키는 `docs/design/webmail/README.md` "메일 쓰기". 보낼 모양은 `mail-serialize.ts` — 서식 본문 `bodyHtml`(기본 글꼴 div, 인라인 style) + 텍스트 대체 본문 `body` (계약 2절 "서식 본문"). 답장·전달 초안 한도는 `fit-draft.ts`(처음 맞출 때 내 글 몫 5%를 남기고, 보내기 직전 `getContentForSend()` 가 다시 재서 인용을 더 줄인다 — 내 글만으로 넘으면 보내지 않고 칸 아래에 알린다), 붙여 넣거나 끌어다 놓은 파일은 첨부로(`shared/utils/editor-files.ts`, 화면 어디에 놓아도 — `composables/useWindowFileDrop.ts`). **읽어 들이는 style 값은 서버 정화와 같은 형식만** 받는다 — 규칙 `shared/utils/outgoing-html-rules.ts`(짝: backend `sanitize-outgoing-html.ts`, 같은 표본 표 `test-utils/outgoing-style-samples.ts` — 한쪽을 고치면 둘 다), 적용 `editor/mail-format-extensions.ts`. 편집기 청크를 못 받으면 `ComposeEditorLoadError.vue`("다시 시도"), 단축키 표기는 플랫폼별(`shared/utils/keyboard-shortcut.ts`). **그림은 서명 그림만** 받는다 — 노드 `editor/signature-image.ts`(쓰기 본문·서명 칸 공용, 규칙은 `outgoing-html-rules.ts` 의 그림 부분 — 짝: backend `html/signature-images.ts`·`keepSignatureImage`, 표본 `IMAGE_SAMPLES`). 메뉴(`editor/EditorMenu.vue` + `EditorPopup.vue` — 이동 ▾ · 메일함 ⋯ · 읽기 화면 ⋯ 도 같은 부품)의 꺼진 항목은 `disabled` 가 아니라 `aria-disabled` — 화살표로 가서 초점은 받되 고를 수 없다(WAI-ARIA 메뉴). 누를 수 없는 까닭은 항목의 `note`(이름 아래 작은 글, `aria-describedby`). 메뉴 · 팝오버의 좌우는 바라는 끝(`align`)으로 그린 뒤 보이는 영역 밖으로 넘치면 덜 넘치는 반대 끝에 맞춘다(15단계 — `EditorPopup` 이 그린 직후 · 초점을 주기 전에 스스로 잰다: `composables/usePopupPlacement.ts` 의 `measurePopupAlign` · 판단 `shared/utils/popup-placement.ts` 의 `popupAlignFor`). 항목은 들여쓰기(`depth` — 10 · 28 · 44 · 56)와 따로 읽힐 이름(`ariaLabel`)을 받는다. **서명 자리**(16단계 — `editor/mail-signature-block.ts`의 `mailSignature` 블록 노드): 보낼 때 · 저장할 때 빈 문단 뒤의 **속성 없는 `<div>`**(앱과 같은 경계 — 계약 2절 "앱이 만드는 모양")로 나가고, 다시 열 때 "맨 위 단계에서 빈 문단 바로 뒤의 속성 없는 div 이고 글이나 그림이 있다"로 되찾는다(원문 인용 안 · 꾸민 · 빈 div 는 아니다). 서명 자리는 하나 — 붙여 넣기로 하나 더 생기면 처음 것만 두고 푼다. **서명 자리 앞에는 늘 빈 줄** — 그 빈 줄에 쓰거나 지우면 다시 넣는다(같은 appendTransaction — 한글 조합 중(`composition` 표시)에는 미루고, 조합이 끝나면 플러그인 view 가 `compositionend` 뒤 ProseMirror 가 조합을 마무리한 다음 차례(50ms)에 표시만 단 빈 거래로 깨운다 — 그사이 새 조합이 시작됐으면 그 조합이 끝날 때. 화면에 붙인 편집기에서만 돈다 · 시험은 `editor.mount` + `CompositionEvent` + 가짜 setTimeout), 보내고 저장하는 모양은 `withSignatureBoundary` 로 한 번 더(`MailEditor` 의 `contentOf` · 저장한 뒤 견줄 `markSaved` 의 settled) — 리뷰 M3. |
| 메일 본문 편집기 | `src/components/mail/editor/` — `MailEditor.vue` (쓰기 화면이 `defineAsyncComponent` 로 불러온다) | TipTap core (MIT 무료 패키지만, `@tiptap/*` 는 모두 같은 버전으로 고정). 툴바 배치·좁은 폭에서 접는 순서·단축키는 `docs/design/webmail/README.md` "메일 쓰기". 보낼 모양은 `mail-serialize.ts` — 서식 본문 `bodyHtml`(기본 글꼴 div, 인라인 style) + 텍스트 대체 본문 `body` (계약 2절 "서식 본문"). 답장·전달 초안 한도는 `fit-draft.ts`(처음 맞출 때 내 글 몫 5%를 남기고, 보내기 직전 `getContentForSend()` 가 다시 재서 인용을 더 줄인다 — 내 글만으로 넘으면 보내지 않고 칸 아래에 알린다), 붙여 넣거나 끌어다 놓은 파일은 첨부로(`shared/utils/editor-files.ts`, 화면 어디에 놓아도 — `composables/useWindowFileDrop.ts`). **읽어 들이는 style 값은 서버 정화와 같은 형식만** 받는다 — 규칙 `shared/utils/outgoing-html-rules.ts`(짝: backend `sanitize-outgoing-html.ts`, 같은 표본 표 `test-utils/outgoing-style-samples.ts` — 한쪽을 고치면 둘 다), 적용 `editor/mail-format-extensions.ts`. 편집기 청크를 못 받으면 `ComposeEditorLoadError.vue`("다시 시도"), 단축키 표기는 플랫폼별(`shared/utils/keyboard-shortcut.ts`). **그림은 서명 그림만** 받는다 — 노드 `editor/signature-image.ts`(쓰기 본문·서명 칸 공용, 규칙은 `outgoing-html-rules.ts` 의 그림 부분 — 짝: backend `html/signature-images.ts`·`keepSignatureImage`, 표본 `IMAGE_SAMPLES`). 메뉴(`editor/EditorMenu.vue` + `EditorPopup.vue` — 이동 ▾ · 메일함 ⋯ · 읽기 화면 ⋯ 도 같은 부품)의 꺼진 항목은 `disabled` 가 아니라 `aria-disabled` — 화살표로 가서 초점은 받되 고를 수 없다(WAI-ARIA 메뉴). 누를 수 없는 까닭은 항목의 `note`(이름 아래 작은 글, `aria-describedby`). 메뉴 · 팝오버의 좌우는 바라는 끝(`align`)으로 그린 뒤 보이는 영역 밖으로 넘치면 덜 넘치는 반대 끝에 맞춘다(15단계 — `EditorPopup` 이 그린 직후 · 초점을 주기 전에 스스로 잰다: `composables/usePopupPlacement.ts` 의 `measurePopupAlign` · 판단 `shared/utils/popup-placement.ts` 의 `popupAlignFor`). 항목은 들여쓰기(`depth` — 10 · 28 · 44 · 56)와 따로 읽힐 이름(`ariaLabel`)을 받는다. **서명 자리**(16단계 — `editor/mail-signature-block.ts`의 `mailSignature` 블록 노드): 보낼 때 · 저장할 때 빈 문단 뒤의 **속성 없는 `<div>`**(앱과 같은 경계 — 계약 2절 "앱이 만드는 모양")로 나가고, 다시 열 때 "맨 위 단계에서 빈 문단 바로 뒤의 속성 없는 div 이고 글이나 그림이 있다"로 되찾는다(원문 인용 안 · 꾸민 · 빈 div 는 아니다). 서명 자리는 하나 — 붙여 넣기로 하나 더 생기면 처음 것만 두고 푼다. **서명 자리 앞에는 늘 빈 줄** — 그 빈 줄에 쓰거나 지우면 다시 넣는다(같은 appendTransaction — 한글 조합 중(`composition` 표시)에는 미루고, 조합이 끝나면 플러그인 view 가 `compositionend` 뒤 ProseMirror 가 조합을 마무리한 다음 차례(50ms)에 표시만 단 빈 거래로 깨운다 — 그사이 새 조합이 시작됐으면 그 조합이 끝날 때. 화면에 붙인 편집기에서만 돈다 · 시험은 `editor.mount` + `CompositionEvent` + 가짜 setTimeout), 보내고 저장하는 모양은 `withSignatureBoundary` 로 한 번 더(`MailEditor` 의 `contentOf` · 저장한 뒤 견줄 `markSaved` 의 settled) — 리뷰 M3. 툴바는 z-index 2 이고 메뉴 · 팝오버를 연 동안만 4(`editor-toolbar--popup-open` — 쓰기 화면 아래에 붙은 [보내기] 줄(3) 위로). 넓은 화면 편집 영역 최소 200 은 「쓰기 화면 스크롤」 행 |
| 임시 저장 | `src/composables/useDraftSaving.ts` · `components/mail/ComposeDraftStatus.vue` · `composables/useComposeDraftLink.ts`(주소 맞추기 · 옛 id 기록 · 보내지 못한 메일을 저장본으로) · `composables/useDraftTrash.ts`(휴지통 · 되돌리기) | 3초 멈춤 · 쉬지 않고 쓰면 30초 · 10초 간격 · 한 번에 하나(계약 2절 "임시 저장", 상수 `shared/constants/mail.ts` 의 `DRAFT_*`). `MAIL_008`·PUT 404 는 막지 않고 POST 로 새로. 찾지 못한 첨부가 남았거나 본문이 한도를 넘는 동안은 저장하지 않고, 실을 수 없는 첨부가 든 남의 저장본은 첫 저장을 POST 로(원본 보존). 보내기·버리기 전에는 `suspend`+`settle` — 보내기는 settle 뒤 첨부 상태를 다시 본다. 저장할 때마다 주소를 `?draft=<새 id>` 로 맞추고(떠나기 전 저장 제외), 옛 id 로 돌아오면 `mail.store` 의 `latestDraftId()` 로 마지막 저장본을 연다. 쓰기 화면 휴지통은 DELETE 가 아니라 PATCH(휴지통으로 이동) — 이미 휴지통인 메일은 DELETE 가 영구 삭제한다 |
| 받는 사람 자동 완성 | `src/composables/useRecipientSuggestions.ts` · `shared/utils/recipient-match.ts` · `components/mail/RecipientField.vue` · `RecipientSuggestionList.vue` | 검색어는 input · compositionupdate 의 `target.value`(한글 조합 중 글자 포함)로 따로 두고, 칩 확정은 v-model. Enter 등의 조합 판정은 `shared/utils/keyboard-shortcut.ts` 의 `isImeComposing()`(`isComposing` 또는 keyCode 229 — Safari · macOS) — 새 입력 칸의 Enter 처리도 이것을 쓴다. 쓰기 화면을 열 때 `limit=100` 한 번, 모자랄 때만 250ms 뒤 `q` 로(8단계부터 초성 · 조합 중 글자도 묻는다 — 서버의 q 가 같은 맞추기다: 짝 backend `modules/mail/recipient-match.ts`, 같은 표본 표 `test-utils/recipient-match-samples.ts` — 한쪽을 고치면 둘 다. 늦은 응답은 버린다). 초성 검색·맞은 글자 강조는 `recipient-match.ts`(강조는 `<strong>` 조각 — v-html 없음). combobox 접근성(aria-activedescendant · listbox · 조합 중 Enter 무시), 완전한 주소면 미리 고르지 않는다 |
| 쓰기 화면 스크롤 (2026-11-10 사용자 보고) | `src/components/mail/ComposeFooter.vue`(아래에 붙는 [보내기] 줄) · `RecipientField.vue`(칩 · 입력 칸 안 스크롤) · `pages/mail/ComposePage.vue`(넓은 화면 편집 영역 · 위치) · `components/mail/editor/MailEditor.vue` · `EditorToolbar.vue`(`editor-toolbar--popup-open`) · `composables/useFocusAboveFooter.ts` · 토큰 `--size-recipient-chips-max`(96) · `--size-editor-body-min-fit`(200) · 시험 `pages/mail/ComposePage.scroll.spec.ts`(원본 style 읽기 + 초점) | 받는 사람이 많으면 바깥 · 본문 이중 스크롤에 [보내기] 줄이 화면 밖으로 숨었다. **[보내기] 줄은 스크롤하는 칸(넓은 화면 `.compose` · 좁은 화면 문서)의 아래에 붙는다**(`position: sticky; bottom: 0` + 흰 바탕 — 모양 그대로). **겹침 차례: 툴바 2 < [보내기] 줄 3 < 받는 사람 추천 목록 4 · 메뉴를 연 툴바 4**(`.editor-toolbar--popup-open` — 툴바는 줄 밑으로 지나가고 툴바의 메뉴는 줄 위에 뜬다). 줄의 메뉴([보내기 ▾] · [서명 ▾])는 위로 연다(`usePopupPlacement`). **받는 사람 · 참조 · 숨은참조의 칩 · 입력 칸(`.recipient-field__control`)은 96(+ 위 여백 4)을 넘으면 그 칸 안에서 스크롤** — 맨 위 칩 세 줄 · 맨 아래 칩 두 줄 + 입력 줄이 줄 사이에서 끊긴다(반쯤 잘린 칩 없음). 위 · 좌우 안쪽 여백 4 를 바깥 여백으로 당겨 자리 · 줄바꿈은 그대로 두고 칩 ✕ 의 초점 고리가 잘리지 않게(아래는 당기지 않는다 — 줄의 초점 선을 덮지 않게). 초점이 오거나 · 칩이 늘거나(`flush: 'post'`) · 글을 치면 칸을 맨 아래로, 칩 ✕ 는 `focus({ preventScroll: true })` 로 자리를 그대로(가운데 칩을 이어 지운다). 추천 목록은 그 칸 밖(`.recipient-field` 기준)에 뜬다. **넓은 화면(70rem 이상)은 편집 영역이 300 에서 200 까지 먼저 줄어** 보통 높이(1366×768 · 1440×900)에서는 쓰기 칸 전체가 화면 안에 든다(좁은 화면은 300 그대로). **`.compose` 는 `position: relative`** — 안의 `.visually-hidden`(파일 고르기 칸 · 단추 줄의 낭독 글)이 화면 전체를 기준으로 놓여 넓은 화면에서 문서까지 스크롤했다(168px). **[보내기] 줄에 가린 칸으로 온 초점은 올린다**(`useFocusAboveFooter` — form 의 `@focusin` · `@pointerdown.capture`: 다음 그림에 재어 가린 만큼(+8) 스크롤 상자를 올린다, 쓰기 칸의 줄은 `.compose-field` 전체, 누른 칸(1초 안 · 그 줄 안)은 그대로 — 누른 채 움직이면 글이 골라진다, WCAG 2.4.11). 시험: vitest 는 CSS 파일을 비운다(`tokens.css?raw` 도 빈 글) — 토큰 값은 브라우저로 보고 시험은 컴포넌트가 토큰을 쓰는지만 — 디자인 README "메일 쓰기 › 받는 사람이 많을 때 · 낮은 화면" |
| 받는 사람 자동 완성 | `src/composables/useRecipientSuggestions.ts` · `shared/utils/recipient-match.ts` · `components/mail/RecipientField.vue` · `RecipientSuggestionList.vue` | 검색어는 input · compositionupdate 의 `target.value`(한글 조합 중 글자 포함)로 따로 두고, 칩 확정은 v-model. Enter 등의 조합 판정은 `shared/utils/keyboard-shortcut.ts` 의 `isImeComposing()`(`isComposing` 또는 keyCode 229 — Safari · macOS) — 새 입력 칸의 Enter 처리도 이것을 쓴다. 쓰기 화면을 열 때 `limit=100` 한 번, 모자랄 때만 250ms 뒤 `q` 로(8단계부터 초성 · 조합 중 글자도 묻는다 — 서버의 q 가 같은 맞추기다: 짝 backend `modules/mail/recipient-match.ts`, 같은 표본 표 `test-utils/recipient-match-samples.ts` — 한쪽을 고치면 둘 다. 늦은 응답은 버린다). 초성 검색·맞은 글자 강조는 `recipient-match.ts`(강조는 `<strong>` 조각 — v-html 없음). combobox 접근성(aria-activedescendant · listbox · 조합 중 Enter 무시), 완전한 주소면 미리 고르지 않는다 칩이 세 줄을 넘으면 칩 · 입력 칸 안에서 스크롤 — 초점 · 칩 늘어남 · 입력 때 맨 아래로, 칩 ✕ 는 자리 그대로(「쓰기 화면 스크롤」 행) |
| 메일 서명 (16단계 — 여러 서명) | `src/stores/mail-signature.store.ts` · API `composables/useMailSignatures.ts` · 타입 `shared/types/mail-signature.ts` · `components/mail/editor/signature/`(`SignatureSettings` 화면 · `SignatureList` · `SignatureDefaultsField` · `SignatureForm` · `SignatureEmpty` · `SignatureDeleteDialog` · 공용 편집기 `SignatureEditor` + `SignatureToolbar`) · `shared/utils/mail-signature.ts` · `shared/constants/mail-signature.ts` · `pages/settings/MailSignaturePage.vue` · 쓰기 화면 [서명 ▾] `components/mail/ComposeSignatureMenu.vue`(ComposeFooter 의 `signature` 슬롯) · 서명 자리 `components/mail/editor/mail-signature-block.ts` | 서명은 직원마다 10개(만든 차례)와 기본 둘(새 메일 · 답장과 전달 — `GET · PUT /mail-signature-defaults`, null = 넣지 않음) — 계약 2절 "서명". 스토어는 목록과 기본 둘을 함께 받는다(`ensureLoaded` — 메일 화면에 들어올 때 · 쓰기 화면이 열 때, 실패는 알리지 않고 서명 없이). 만들기 · 고치기 · 지우기는 받아 둔 목록을 그 자리에서 고치고(첫 서명은 기본 둘 — 서버와 같게, 지운 서명의 기본은 null), 기본 고르기(`chooseDefault`)는 화면을 먼저 바꾸고 **저장은 한 번에 하나**(보내기 · 읽기 설정과 같은 결 — 끝내 실패하면 서버가 확인한 값으로). 저장 거절(`VAL_001`)은 `refresh()`(조용히 — 그사이 바꿨으면 버림)로 다른 창의 같은 이름을 가른다. 설정 화면은 들어올 때마다 `load` 하고 TipTap 청크(`SignatureSettings`)를 지연 로딩한다 — 목록 208 · 고치는 칸, 칸이 640 보다 좁으면 목록을 위로(컨테이너 쿼리 `signature-settings`). 고른 서명마다 `SignatureForm` 을 key 로 새로 만들되 새 서명을 처음 저장한 것은 같은 칸이다. 저장하지 않은 채 다른 서명 · [새 서명] · [취소]는 `LeaveConfirmDialog`(`signature-switch`), 다른 화면은 `useLeaveConfirm`(`signature-leave`). 이름 검사 `signatureNameProblem`(앞뒤 공백 · 영문 대소문자 무시 — 서버와 같다). 미리 보기는 편집기 스키마의 `DOMSerializer` 로 고른 서명을 늘 그린다. 그림 3장은 서명 칸 안의 그림 수로(한 사람 보관 20장은 서버가 센다 — 올리기 `VAL_001` 은 알림). **쓰기 화면**: 새 메일은 새 메일 기본을 끝에, 답장 · 전달은 답장과 전달 기본을 인용 위에 서명 자리(`signatureBlockHtml` — `data-mail-signature` 표시 div)로 넣고, 이어 쓰기는 다시 넣지 않는다. [서명 ▾](임시 저장 다음 · 44 · 편집기 메뉴 220 · `menuitemradio` ✓ · 키보드로 열면 첫 항목 — `EditorMenu` 의 `initialFocus`)는 `MailEditorHandle` 의 `replaceSignature`(그 자리만 · 실행 취소 됨 · 넣지 않음은 앞 빈 줄까지 · 없으면 처음 넣는 자리) · `signaturePlace` · `signatureFingerprint`(글 · 그림으로 견줘 ✓ — 고쳤으면 없음, 단추 이름 "서명: 대외" · "서명")를 쓰고 바꾼 뒤 role=status 에만 "서명을 ‘사내’로 바꿨습니다."(조사 `directionParticle`). 서명 관리는 설정 › 메일 › 서명(나가기 규칙대로) — 디자인 README "16단계" |
| 메일 스토어 구성 | `src/stores/mail.store.ts`(조립 · 공개 API) + `src/stores/mail/` — `mailboxes.ts` · `list.ts` · `selection.ts` · `message.ts` · `bulk.ts` · `mailbox-admin.ts`(4단계 메일함 관리) · `conversation.ts`(14단계 같은 대화 칸) | Pinia 스토어는 `useMailStore` 하나이고 기능별 setup 모듈을 조립한다(스토어를 여러 개로 나누지 않는다 — 서로 부르는 고리가 생긴다). 목록 출처는 `shared/utils/mail-list-source.ts`(메일함 · 검색 · 대화로 묶은 메일함 `conversations` — 14단계). **목록에 든 메일인지 볼 때는 `messages` 대신 줄 기준 도우미**(`rowCount` · `rowIndexOfMessage` · `rowMessageIdAt` · `isListedMessage` · `listedSummaries`)를 쓴다 — 대화 목록에서는 `messages` 가 비어 있다. 목록 요청은 출처가 바뀌면 `listAbort`(AbortController)로 끊는다(끊긴 요청은 알리지 않는다). **늦게 온 응답 버리기**: 목록 맞추기는 이 탭의 목록 변경 세대(`localGeneration` — 읽음 · 중요 · 빼기 · 옮기기마다 오른다)가 그사이 바뀌었으면 응답을 버리고 한 번 더 맞춘다. 여러 통 처리 · 한 통 삭제는 시작할 때의 출처 순번(`sourceSeq`)과 다르면 목록을 건드리지 않고 개수만 맞춘다 |
| 새 메일 확인 · 탭 제목 | `src/composables/useMailStatePolling.ts`(메일 화면 `MailLayout` 이 시작) · `useListArrivalNotice.ts` + `components/mail/MailNewNotice.vue` · `useMailTabTitle.ts` + `shared/utils/document-title.ts` | 화면이 보이는 동안 60초마다 `GET /mail-state`, 탭으로 돌아오면 10초 지났을 때 바로, 한 번에 하나, 실패는 `silent`. 바뀌면 `syncCounts()` + 보이는 목록 맞추기(불러온 만큼 · 최대 100통, 검색 결과는 건너뜀). 목록을 내려 본 채면 "새 메일 N통 · 맨 위로" 줄. 받은편지함 안 읽음이 있으면 탭 제목 앞 `(N) ` (메일 라우트에서만) — 계약 2절 "새 메일 확인" |
@@ -55,7 +56,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 메일 검색 — 14단계 (조건 · 강조 · 준비 중) | `src/components/mail/MailSearchConditions.vue`(조건 패널 — 칸 `MailSearchConditionFields.vue` · 기간 `MailSearchPeriodField.vue` · [조건] `MailSearchConditionsToggle.vue`, 고치는 값 `composables/useSearchConditionsDraft.ts` · 열고 닫기 `useSearchConditionsPopup.ts`) · `MailSearchConditionLine.vue`(조건 줄) · `MailSearchNotices.vue`(준비 중 · 한 글자 안내) · `MailSearchMore.vue`([더 찾기]) · `HighlightedText.vue` · `shared/utils/mail-search.ts`(조건 ↔ 주소 ↔ API · 기간 · 조건 줄 글) · `search-highlight.ts` · `search-match.ts` · `shared/constants/mail-search.ts` | 조건 이름 · 화면 주소(`since` · `until` 은 브라우저 시간대 날짜 · 양 끝 포함, 첨부 `attachment=1`) · 이어 찾기(`MAIL_SEARCH_FILL_MAX_REQUESTS` 5번)는 계약 2절 "검색 — 14단계"의 클라이언트 줄. **`search-match.ts` 는 backend `shared/utils/search-query.ts` · `search-text.ts` · `subject-base.ts` 와 짝** — 같은 표본 표 `test-utils/search-text-samples.ts`(backend `test/search-text-samples.ts` 와 행 · 차례까지 같다, 보이지 않는 글자는 `\u` 로 적는다 — 한쪽을 고치면 둘 다). 맞은 말은 `HighlightedText` 가 글 조각 + `<mark>` 로 그린다(v-html 없음 — 위치가 글 밖 · 거꾸로 · 정수가 아니면 버리고, 서로게이트 쌍을 가르지 않는다). **강조가 든 응답은 보이기만 한다** — `openMessage(id, highlight)` 는 강조 쿼리가 다르면 다시 받고, 답장 · 전달 · 인쇄 · 예약 · 대용량 첨부 다시 받기는 강조 쿼리 없이 받는다. 손으로 고친 주소도 패널과 같은 검사(8낱말 넘으면 묻지 않고 "찾을 말이 너무 많습니다"). 강조 노랑은 `--color-search-mark` — 본문 틀(`mail-html.ts`)의 같은 값과 짝. 미리 보기 줄(회색 글) 안의 강조만 글자를 `--color-text-secondary` 로 진하게(노랑 위 대비 — `MessageListItem.vue`). 패널의 [조건 지우기]는 칸만 비운다 — 패널은 열린 채 · 찾지 않음, 걸린 조건은 [찾기] · 조건 줄의 [조건 지우기]가 뺀다(2026-11-07) |
| 같은 대화 (14단계) | `src/components/mail/MessageConversation.vue`(읽기 화면 첨부 아래 칸) · `ConversationMessageRow.vue`(줄) · `ConversationMessagePanel.vue`(펼친 안) · `stores/mail/conversation.ts` · `composables/useConversationSync.ts`(언제 받을지 — 시계 주입) · `shared/utils/mail-conversation.ts`(이름 자리 · 쪽 맞추기 · 갈 곳) · `shared/constants/mail-conversation.ts` · `shared/utils/format.ts` 의 `formatConversationTime` | 받기 · 다시 받기는 계약 2절 "대화 — 14단계"의 클라이언트 줄(100통 · 이어 묻기 5번 · [더 보기], 실패는 칸 안 안내 — 404 면 칸 없음). **다시 받기 판정은 `mail.store` 의 `mailboxTotalsKey`**(메일함마다 `id:totalCount` — 바뀔 때만 `requestConversationRefresh()`, MailLayout 의 바뀜 소식에서는 부르지 않는다 — 읽음 · 중요 표시만 바뀐 소식으로 대화를 다시 받지 않게 — 칸의 안 읽은 줄은 목록처럼 굵게 그리고, 이 탭이 바꾼 읽음은 `list.ts` 의 `watchLocalReads` 로 바로 적는다: 받는 동안 바꾼 것은 `conversation.ts` 의 `withLocalReads` 가 받은 값보다 앞세우고, 다른 탭의 읽음은 다음 받기 때 — 2026-11-07), 간격은 받기가 끝난 때(`conversationBusy` 가 꺼질 때)부터 10초. 펼친 본문은 `MailBodyFrame` · 강조 쿼리 없이, 안 읽은 메일은 `markRead`(`Promise<boolean>` — 같은 목록의 `pendingFlags` 에 같은 읽음이 있으면 건너뛴다)로. `closeMessage` 가 칸도 비운다. 초점은 누른 단추가 사라지면 줄의 펼침 단추 → 새 [다시 시도] → 칸 h3(`tabindex="-1"`) → 칸이 없어지면 읽기 화면에 `settled`. 상태 영역은 늘 두고 글만 바꾼다(다시 실패해도 읽게). [이 메일만 열기]는 분할 보기 push · 목록 보기 replace(layout prop). 줄 차례는 `orderHeldLast`(붙잡힌 메일은 끝 — 보낼 시각 차례), 미리 보기는 `previewWithoutOwnQuote`(우리 원문 인용 머리부터 잘라 냄 — `mail-conversation.ts`), 펼친 줄의 첨부 수 안내는 `ConversationMessagePanel`. 15단계: 칸 머리 오른쪽 [모두 펼치기] · [모두 접기](36 — `MAIL_CONVERSATION_TEXT.expandAll` · `collapseAll`, 판정 `conversationAllExpanded` — 하나씩 다 펼쳐도 [모두 접기]). 모두 펼친 줄의 본문은 화면에 들어온 줄부터 하나씩(`composables/useConversationPanelVisibility.ts` — IntersectionObserver 하나로 `[data-conversation-panel]` 을 보고 스토어의 `noteConversationPanelVisible` 에 알린다 · 차례 `detailQueue`, 차례 전에 화면 밖으로 나간 줄은 건너뛴다), 읽음은 본문이 화면에 보였을 때만(`lazyRows` — 손으로 펼친 줄은 지금처럼 받는 대로). 펼친 줄 머리 [답장](`data-conversation-head-reply` — 머리 틀 `.conversation-row__head` 안에 펼침 단추와 나란히, 임시 저장본 · 예약 · 지워진 메일엔 없다) |
| 대화로 묶어 보기 (14단계) | `src/components/mail/MailConversationToggle.vue`(목록 머리 [대화로 묶기]) · `ConversationListItem.vue`(분할 보기 줄) · `ConversationTableRow.vue`(목록 보기 표 줄) · `composables/useConversationRows.ts` · `shared/utils/mail-conversation-list.ts`(응답 맞추기 · 할 일 id · 참여자 · 머리 글 · 표시 적용 · 맞추기 병합 · 삭제 뒤 갈 곳) · `stores/mail/list.ts`(출처 `conversations`) · 설정 `stores/mail-settings.store.ts` 의 `groupByConversation`(+ 설정 › 보내기 · 읽기 한 줄) · 글 `shared/constants/mail-conversation.ts` 의 `MAIL_CONVERSATION_LIST_TEXT` | 받기 · 맞추기 · 줄에 하는 일은 계약 2절 "대화 — 14단계"의 클라이언트 줄(20줄 · 맞추기는 불러온 줄 수만큼 최대 100줄 `silent` · 할 일 = `messageIds` − 붙잡힌 메일 · 줄 전체 표시는 모두 바꿨을 때만 · 옮기기 · 삭제 뒤 한 번 다시 받기). **첫 진입 짐작**은 localStorage `groupByConversationHintKey(주소)` — 설정을 받기 전 · 받지 못했으면 이 값, 받으면 서버 값, 읽기 · 쓰기가 던져도 끔. 로그아웃 · 세션 만료에 설정 스토어를 비워도 화면이 보는 값은 그대로 둔다(뒤집히면 watcher 가 떠나는 화면의 목록을 다시 부른다 — 2026-11-06 리뷰). **목록을 부르는 watcher 는 `authStore.canUseApp` 이 false 면 부르지 않는다**(소스에도 넣는다). 붙잡힌 메일이 든 줄은 고르기 칸 · 별이 `aria-disabled` + 까닭(`SEND_LATER_TEXT.heldNotSelectable`). 목록 줄의 화면 낭독 글("안 읽음, " · "중요 표시" · "첨부 있음")은 `shared/constants/mail.ts` 의 `MAIL_LIST_ROW_TEXT` — 메일 줄은 아직 직접 적은 글이라 함께 고친다. 읽기 화면 [이전] · [다음] · 위치는 대화 단위(`ListReadNav.vue`), 한 통 삭제 뒤 갈 곳은 `conversationNeighborAfterRemoval`. 분할 보기 머리의 줄인 개수 글 `listHeadShortCountText`(전체 글은 title · 숨긴 글) · 도구 줄 `selectedShort`(숨긴 글 "대화 N개 선택됨") · 표 머리 "참여자"(`participantsColumn`) · 줄 알림 `announceMove(…, { from })` → `MAIL_BULK_TEXT.movedFrom`("{메일함}에 있는 메일 N통을 …"). 영구 삭제는 `MailPurgeDialog` 의 `from` + `announcePurge(…, { from })` → `purgeTitleFrom` · `purgedFrom`(값은 `useBulkMailActions` 의 `conversationFrom` — 묶지 않은 목록 · 읽기 화면은 null). **설정 다시 받기** `composables/useMailSettingsRefresh.ts`(MailLayout — 탭이 보일 때 60초 `MAIL_SETTINGS_REFRESH_AFTER_MS` · `canUseApp`) + 스토어 `refresh()`(silent · 받는 동안 loading 으로 바꾸지 않음 · 저장 중이면 하지 않음 · 받는 사이 고른 것(`choiceSeq`) · 다시 받기(`loadSeq`) · 세션이 바뀌면 버림) · `fetchSeq` — 디자인 README "14단계 구현하며 정한 보완 — 목록 대화로 묶어 보기" |
| 여러 통 처리 · 삭제 | `src/components/mail/MailCheckbox.vue` · `MailSelectionToolbar.vue`(+ `MailBulkButton` · `MailBulkMoveButton`) · `MailPurgeDialog.vue` · `MessageDeleteButton.vue` · `composables/useBulkMailActions.ts` · `useMessageRemoval.ts` · `useMailMoveFeedback.ts` · `shared/utils/mail-selection.ts` · `mail-bulk.ts` | 100통씩 차례로. 읽음 · 중요는 먼저 바꾸고 `failedIds` 만 되돌리고, 이동 · 삭제는 응답의 id 만 뺀다. **웹은 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 휴지통 밖 삭제는 `PATCH` 이동(+ 되돌리기), 휴지통에서는 확인 대화상자(처음 초점 「취소」) 뒤 `POST /message-deletions`. 읽기 화면도 그 메일의 `mailboxIds` 로 같은 규칙을 탄다. 도구 줄은 role="toolbar" 로빙 tabindex(바쁜 · 꺼진 단추는 건너뛴다). [되돌리기] 뒤 알림은 한 메일함이면 그 이름(`restoredMessageText(…, mailboxName)` — "메일 N통을 받은편지함으로 되돌렸습니다.") — 계약 2절 "여러 통 한꺼번에". [이동 ▾]의 옮길 곳 · 스팸 표시 · 끌어다 놓기는 아래 「메일 옮기기 · 끌어다 놓기 · 좁은 읽기 칸 (15단계)」 행 |
| 여러 통 처리 · 삭제 | `src/components/mail/MailCheckbox.vue` · `MailSelectionToolbar.vue`(+ `MailBulkButton` · `MailBulkMoveButton`) · `MailPurgeDialog.vue` · `MessageDeleteButton.vue` · `composables/useBulkMailActions.ts` · `useMessageRemoval.ts` · `useMailMoveFeedback.ts` · `shared/utils/mail-selection.ts` · `mail-bulk.ts` | 100통씩 차례로. 읽음 · 중요는 먼저 바꾸고 `failedIds` 만 되돌리고, 이동 · 삭제는 응답의 id 만 뺀다. **웹은 `DELETE /api/messages/{id}` 를 쓰지 않는다** — 휴지통 밖 삭제는 `PATCH` 이동(+ 되돌리기), 휴지통에서는 확인 대화상자(처음 초점 「취소」) 뒤 `POST /message-deletions`. 읽기 화면도 그 메일의 `mailboxIds` 로 같은 규칙을 탄다. 도구 줄은 role="toolbar" 로빙 tabindex(바쁜 · 꺼진 단추는 건너뛴다). [되돌리기] 뒤 알림은 한 메일함이면 그 이름(`restoredMessageText(…, mailboxName)` — "메일 N통을 받은편지함으로 되돌렸습니다.") — 계약 2절 "여러 통 한꺼번에". [이동 ▾]의 옮길 곳 · 스팸 표시 · 끌어다 놓기는 아래 「메일 옮기기 · 끌어다 놓기 · 좁은 읽기 칸 (15단계)」 행 목록 보기의 줄(`MessageTableRow` · `ConversationTableRow`)은 줄 전체에 click 을 걸고 단추 · 링크 · 고르기 칸 · 별 칸이면 열지 않는다 — **무엇을 눌렀는지는 `shared/utils/event-path.ts` 의 `eventPathHas`(사건이 지나간 길 — dispatch 때 정해진다)로 가르고 `event.target.closest` 로 가르지 않는다**: 고르기 상자 가운데를 누르면 target 은 빈 상자 그림(`<rect>`)인데, 단추의 처리기가 고르기를 바꾸면 브라우저가 처리기 사이에 microtask 를 돌려 Vue 가 다시 그리고 그 그림이 문서에서 떨어져 줄의 처리기에서 closest('button') 이 비었다 — 고르면서 메일까지 열렸다(2026-11-10 사용자 보고). 상자 그림(`MailCheckbox` 의 svg) · 표의 별 그림은 `pointer-events: none` — 누른 것이 늘 단추다. 고르기 칸의 빈자리(단추 밖 · td 안)는 고르기 상자를 누른 것과 같다(Shift 는 범위 · 고를 수 없는 줄이면 아무 일도 없다), 별 칸의 빈자리는 아무 일도 없다. Ctrl(⌘) · Shift + 클릭은 줄의 다른 곳에서 지금처럼 고른다. 분할 보기는 고르기 칸(36)이 링크 밖이라 탈이 없다. 시험 `components/mail/MailListRows.check.spec.ts` · `shared/utils/event-path.spec.ts` — 스크립트의 dispatchEvent 는 처리기 사이에 microtask 를 돌리지 않아 칸의 처리기에서 그림을 떼어 내 흉내 낸다(실제 다시 그리기는 브라우저로 확인) |
| 설정 › 비밀번호 · 부재중 (4단계) | `src/pages/settings/AccountPasswordPage.vue` · `MailVacationPage.vue` · `components/settings/`(`SettingsPageFrame` 공용 틀 — 머리 오른쪽 단추는 `actions` 슬롯 · `PasswordChangeForm` · `PasswordField` · `Vacation*`) · `stores/mail-vacation.store.ts` · `shared/utils/password-change.ts` · `mail-vacation.ts` · 메뉴 `shared/constants/settings-menu.ts` | 메뉴는 묶음(계정 › 비밀번호 / 메일 › 서명 · 부재중 자동 응답 · 필터(6단계) · 알림(5단계) / 정보 › 업데이트 내역), 라우트 이름은 `SETTINGS_ROUTES` 하나. 비밀번호는 서버와 같은 규칙을 먼저 보고, 서버 거절(`AUTH_005` · `VAL_001`)은 칸 아래에만(알림 없음) — 성공하면 세 칸을 비운다. 부재중은 날짜만 고르고 브라우저 시간대의 시작일 0시 · 끝나는 날 다음 날 0시를 UTC 로 보낸다(변환은 `mail-vacation.ts` 한 곳 — 되읽을 때는 1ms 앞 날짜). 켤 때만 검사하고 꺼 둔 채로는 빈 값도 저장한다(길이 150 · 600 도 켤 때만 — 꺼 둔 채로는 4단계 한도까지, 끄기는 늘 저장되게 — 16단계부터 내용은 편집기, 아래 「부재중 서식 본문 · 회사 밖 응답 (16단계)」 행). 부재중 상태는 메일 화면에 들어올 때 받고, 패널 안내 `components/mail/MailVacationNotice.vue` 가 1분마다 다시 계산한다(`useClock` · `VACATION_NOTICE_REFRESH_MS`). 저장 응답은 세션 세대(`authStore.generation`)가 바뀌었으면 쓰지 않는다. 저장한 값이 켜짐인데 엔진에서 규칙이 꺼져 있으면(응답의 `isActive: false` — 7단계) 머리 아래 노란 줄(`MailFilterNotice` 를 단추 없이 — `actionLabel` 을 주지 않는다). 칸을 고친 값이 아니라 저장한 값으로 보고, `isActive` 는 PUT 본문에 넣지 않는다(`useMail().saveVacation` 이 일곱 칸만 — 16단계의 `htmlBody` · `external` 까지) — 계약 1절 · 2절 "부재중 자동 응답" |
| 메일함 관리 · 오래 걸리는 처리 (4단계) | `src/components/mail/MailboxTree.vue` · `MailboxPanelRow.vue` · `MailboxMoreButton.vue`(⋯ 메뉴) · `MailboxDialogs.vue`(+ `Mailbox*Dialog.vue`) · `MailboxEmptyButton.vue`(목록 머리 「비우기」) · `composables/useMailboxActions.ts` · `stores/mail/mailbox-admin.ts` · `shared/utils/mailbox-operation.ts` · 상수 `shared/constants/mailbox-manage.ts` | 만들기 · 이름 바꾸기는 칸 안 실패(`MAIL_009` · `VAL_001`)를 알림 없이 칸 아래에. 모두 읽음 · 비우기 · 지우기는 남은 수가 0(지우기는 `deleted: true`)이 될 때까지 다시 보내며 진행 알림을 고친다 — 진행은 응답의 `processed`, 세션이 바뀌면 멈춘다(`epoch` — 늦게 온 만들기 · 이름 바꾸기 응답도 버린다), 같은 메일함의 같은 처리는 하나, **휴지통 비우기와 메일함 지우기는 함께 돌리지 않는다**(비우기는 그사이 휴지통에 들어온 메일도 지운다 — 판정은 스토어의 `isMailboxOperationBlocked` 하나, 메뉴 · 단추 · 대화상자 확인이 모두 이것을 본다). 끝난 뒤 누른 단추가 사라져 초점을 잃었으면 패널의 지금 메일함 항목으로 옮긴다. ⋯ 메뉴는 보이는 영역 안에 다 들게 연다(`shared/utils/popup-placement.ts` — 아래가 모자라면 위로 · 좌우가 넘치면 반대 끝으로(15단계 — EditorPopup)). 되돌릴 수 없는 일은 확인 대화상자(처음 초점 「취소」) 뒤에, 확인한 그 메일함만 — 계약 2절 "메일함 관리" |
| 스팸 · 원문 (4단계) | `src/components/mail/MessageSpamButton.vue`(읽기 화면) · 도구 줄의 스팸 단추(`MailSelectionToolbar.vue`) · `MessageMoreMenu.vue`(⋯ › 원문 내려받기) | 스팸 신고 · 스팸 아님은 이동과 `isJunk` 를 한 요청에 보내고(`mailStore.moveMessages(…, { isJunk })`), [되돌리기]는 원래 메일함으로 반대 값을 보낸다. 원문(.eml)은 `useMail().downloadMessageSource()` — 첨부처럼 `confirmSession()` 을 먼저 거친다 — 계약 2절 "스팸 · 원문" |
@@ -63,7 +64,7 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
| 이미 받은 메일에 필터 적용 (7단계) | `src/components/settings/MailFilterApplyField.vue`(고치기 창의 「받은편지함에 이미 있는 메일도 정리하기」 — 창을 열 때마다 꺼짐) · `composables/useMailFilterRun.ts`(진행 · 결과 알림) · `stores/mail-filter-run.store.ts`(cursor 를 이어 보내기 — 탭마다 하나 `running`) · `shared/utils/mail-filter-run.ts`(할 일 판정 · 보낼 규칙 · 끝 알림의 메일함 · 문구) | 저장이 성공한 뒤에만 `POST /api/mail-filter-runs` 를 `cursor` 가 null 이 될 때까지 다시 보내며 진행 알림을 고친다(4단계 오래 걸리는 처리와 같은 결 — 전체는 확인 + `remaining`, 첫 응답 전에는 받은편지함의 메일 수). 전달은 보내지 않는다. 이미 받은 메일에 할 일이 없는 필터(옮길 메일함이 지워짐 · 받은편지함으로 옮기기만 · 전달만)는 칸을 끄고 시작하지도 않는다 — 판정은 `filterRunIdleReason` 하나(칸 `filterApplyBlock` · 시작 `filterRunRuleOf` 가 함께 쓴다), 이 탭에서 적용이 도는 중에도 칸을 끈다. 요청은 `silent` 로 보내고 실패는 응답이 온 때 세션이 그대로일 때만 스토어가 알린다(`REQ_001` — 다른 창 · PC 에서 적용 중 — 은 화면이 "다른 창에서 …"로). 멈추면 [다시 시도](받은 `cursor` 부터 — 수도 이어 센다), 나아가지 못한 응답이 거듭되면(`MAIL_FILTER_RUN_MAX_STALLS`) 멈춘다. 다른 화면으로 가도 이어 하고, 세션이 바뀌면 다음 요청을 보내지 않고 알림을 닫는다. 끝났거나 나아간 뒤 멈췄으면 개수 · 목록을 맞춘다 — 계약 2절 "이미 받은 메일에 적용", 디자인 README "7단계" |
| 주소록 (8단계) | `src/pages/contacts/`(`ContactsLayout.vue` — 패널 · 창 묶음 `ContactsDialogs` 의 자리 · `ContactsPage.vue`) · `components/contacts/`(패널 `ContactsPanel` · 그룹 줄 `ContactsGroupRow` · 목록 `ContactsList` · `ContactsListItem` · 상세 `ContactDetail` · 찾기 `ContactsSearchBox` · 연락처 창 `ContactDialog` + 줄 `ContactEntryRows` · 그룹 이름 창 · 구성원 넣기 창 `ContactGroupMembersDialog` · ⋯ `ContactMoreMenu` · `ContactAvatar` · `contacts-ui.ts`) · `stores/contacts.store.ts` · `composables/useContacts.ts`(API) · `useContactActions.ts`(지우기 · [되돌리기] · 그룹에 메일 쓰기 · 고치기 열기) · `useContactsDialogs.ts`(창 상태) · `useContactDuplicateCheck.ts`(같은 주소 경고) · `shared/utils/contacts.ts`(차례 `compareContacts` — **backend `contact-search.ts` 와 짝** · 칸 정리 · 바꾼 칸 `contactPatchOf` · 검사) · `contacts-route.ts` · `shared/constants/contacts.ts`(라우트 `CONTACTS_ROUTES` · 한도) · `shared/utils/clipboard.ts` | 레일의 주소록 모듈(`app-modules.ts`). 고치기는 창을 열 때 그 연락처를 새로 받고 **바꾼 칸만 `PATCH`** 한다(검사도 바꾼 칸만 — 손대지 않은 남의 값은 그대로, 바꾼 것이 없으면 보내지 않는다). 지우기는 확인 없이 하고 [되돌리기]는 지우기 응답의 `restoreToken` 으로 `POST /contact-restorations`(표가 없으면 단추 없이 알린다). 바꾼 연락처는 응답을 서버의 차례로 받은 쪽 안의 자리에 넣는다(스토어의 `placeContact` — 자리를 모를 때만 첫 쪽을 한 번 다시 받는다, 바꿀 때마다 불러온 쪽을 모두 다시 받지 않는다). 구성원 넣기 창은 `excludeGroupId` 로 받고, 주소로 연 사내 항목은 `GET /directory-entries/{id}` 로. 창은 주소록 틀(`ContactsLayout`)에 둔다 — 주소록 안에서 옮겨 다녀도 남고, 떠날 때는 나가기 확인(`useDialogLeaveConfirm`), 세션이 바뀌면 닫는다. 저장 중에 창을 닫았으면 창 안에 보일 거절도 `notifyApiError` 로. 이메일은 서버보다 좁게 본다(`mail-address.ts` 의 `isStrictEmail` — 필터의 전달 주소와 공용), 이메일 칸은 `type="text"` + `inputmode="email"`(브라우저가 한글 도메인을 punycode 로 바꾸지 않게). 전환은 `.segmented--outline`(base.css), 도구 단추 `components/mail/MailToolButton.vue` 의 `unavailable`(aria-disabled — 클릭 무시) · `busy`(aria-busy) · `describedby` — 계약 3절, 디자인 README "8단계" |
| 메일과 함께 (8단계) | `shared/utils/compose-prefill.ts` · `components/mail/MailContactDialogHost.vue` + `composables/useMailContactDialog.ts` · `MessageMoreMenu.vue`(⋯ 「보낸 사람을 주소록에 추가」 · 「주소록에서 보기」) · 받는 사람 칸의 그룹(`useRecipientSuggestions` 의 `optionsFor` · `expandGroup`, `recipient-match.ts` 의 `matchContactGroups` · `arrangeRecipientOptions`) | 쓰기 화면을 받는 사람을 채워 열 때는 라우터 상태(`composeTo` — 주소는 URL 에 싣지 않는다)에 연 직원의 주소를 함께 싣고, 지금 직원과 다르면 쓰지 않는다(같은 탭에서 다른 직원이 로그인한 뒤 뒤로 가기). 읽기 화면의 연락처 추가 창은 메일 레이아웃(`MailLayout` 이 provide)에 둔다 — 메일을 바꾸거나 목록으로 돌아가도 남는다(읽기 화면 안 `v-if="message"` 에 두면 사라진다). 받는 사람 추천은 주소록 → 그룹 → 사내 → 최근. 그룹은 쓰기 화면을 열 때 받아 둔 목록에서 이름으로 찾고, 고르면 구성원을 받아 대표 주소를 넣는다 — 그룹마다 따로 기다려 이어 골라도 앞 그룹이 빠지지 않는다 — 계약 3절 "메일과 함께" |
| 보내기 취소 · 예약 발송 (9단계) | `src/components/mail/ComposeFooter.vue`([보내기 ▾] — 메뉴 자리는 `composables/usePopupPlacement.ts`, 메일함 ⋯ 과 공용) · `ComposeScheduleDialog.vue` · `shared/utils/schedule-send.ts`(고르기 · 한도 · `rejectedScheduleProblem`) · `shared/utils/pending-send.ts`(`isHeldMessage` · `isScheduledMessage` — 응답의 `pendingSend` 는 이것으로만 읽는다) · `stores/mail/scheduled.ts` · `composables/useScheduledMessageActions.ts` · `usePendingSendRefresh.ts` · `components/mail/MailboxScheduledRow.vue` · `ScheduledMessageList.vue` · `ScheduledMessageItem.vue` · `MessageReaderNotices.vue` · 마감 알림 `toast.store` 의 `expiresAt` + `components/feedback/ToastCountdown.vue` | 기다리는 곳은 엔진이다(계약 2절 "보내기 취소 · 예약 발송"). 보내기 취소 알림의 마감은 설정한 초를 알면 "지금 + 초 − 1초"(설정 스토어의 `knownUndoSendSeconds` — 메일 화면에 들어올 때 `ensureLoaded`), 모르면 min(sendAt, 지금 + 30초). 마감 알림은 멈추지 않고 넘쳐도 먼저 닫히지 않는다. 알림 단추는 `toastStore.takeAction(id)` 로 한 번만, 같은 메일의 취소는 스토어가 요청 하나로 묶는다. 초점이 든 알림이 닫히면 들어오기 전 요소로(없으면 `<main>` — `data-focus-fallback`). **붙잡힌 메일은 취소로만 보낸편지함을 떠난다** — 고르기 칸 `MailCheckbox` 의 `unavailable` · `note`(aria-disabled + 까닭), 고르기 · 범위 · 모두 고르기 · `checkedMessages` 가 건너뛰고, 읽기 화면 [삭제]는 확인(처음 초점 「취소」) → 취소 → 휴지통(서버도 `MAIL_015` 로 막는다). 엔진이 풀어도 상태가 바뀌지 않아 `usePendingSendRefresh` 가 가장 이른 시각 + 2초(남으면 5초 × 3)에 목록 · 열린 메일 · 「예약됨」 수를 다시 받는다. 응답을 기다린 뒤 다음 메일로 옮기는 일(`useMessageRemoval` 의 `leaveRemoved(…, messageId)`)은 같은 목록 · 같은 메일을 보고 있을 때만 — 디자인 README "9단계" |
| 보내기 취소 · 예약 발송 (9단계) | `src/components/mail/ComposeFooter.vue`([보내기 ▾] — 메뉴 자리는 `composables/usePopupPlacement.ts`, 메일함 ⋯ 과 공용) · `ComposeScheduleDialog.vue` · `shared/utils/schedule-send.ts`(고르기 · 한도 · `rejectedScheduleProblem`) · `shared/utils/pending-send.ts`(`isHeldMessage` · `isScheduledMessage` — 응답의 `pendingSend` 는 이것으로만 읽는다) · `stores/mail/scheduled.ts` · `composables/useScheduledMessageActions.ts` · `usePendingSendRefresh.ts` · `components/mail/MailboxScheduledRow.vue` · `ScheduledMessageList.vue` · `ScheduledMessageItem.vue` · `MessageReaderNotices.vue` · 마감 알림 `toast.store` 의 `expiresAt` + `components/feedback/ToastCountdown.vue` | 기다리는 곳은 엔진이다(계약 2절 "보내기 취소 · 예약 발송"). 보내기 취소 알림의 마감은 설정한 초를 알면 "지금 + 초 − 1초"(설정 스토어의 `knownUndoSendSeconds` — 메일 화면에 들어올 때 `ensureLoaded`), 모르면 min(sendAt, 지금 + 30초). 마감 알림은 멈추지 않고 넘쳐도 먼저 닫히지 않는다. 알림 단추는 `toastStore.takeAction(id)` 로 한 번만, 같은 메일의 취소는 스토어가 요청 하나로 묶는다. 초점이 든 알림이 닫히면 들어오기 전 요소로(없으면 `<main>` — `data-focus-fallback`). **붙잡힌 메일은 취소로만 보낸편지함을 떠난다** — 고르기 칸 `MailCheckbox` 의 `unavailable` · `note`(aria-disabled + 까닭), 고르기 · 범위 · 모두 고르기 · `checkedMessages` 가 건너뛰고, 읽기 화면 [삭제]는 확인(처음 초점 「취소」) → 취소 → 휴지통(서버도 `MAIL_015` 로 막는다). 엔진이 풀어도 상태가 바뀌지 않아 `usePendingSendRefresh` 가 가장 이른 시각 + 2초(남으면 5초 × 3)에 목록 · 열린 메일 · 「예약됨」 수를 다시 받는다. 응답을 기다린 뒤 다음 메일로 옮기는 일(`useMessageRemoval` 의 `leaveRemoved(…, messageId)`)은 같은 목록 · 같은 메일을 보고 있을 때만 — 디자인 README "9단계" 줄은 스크롤하는 칸의 아래에 붙는다(sticky · 2026-11-10) |
| 외부 이미지 · 인쇄 (9단계) | `src/components/mail/MailBodyFrame.vue`(`imagesShown`) · `shared/utils/mail-html.ts`(`mailFrameCspImagesShown` · `PROXIED_IMAGE_PATTERN`) · `mailStore.showImages()` · 인쇄 `pages/mail/MailPrintPage.vue` + `composables/useMailPrint.ts` · `shared/constants/mail-print.ts` | [이미지 보기]는 `GET /messages/{id}?images=show` 로 그 메일의 본문 칸만 다시 받는다. 본문 틀의 CSP 는 보일 때만 그림에 **우리 출처의 중계 경로**(`<origin>/api/mail-images/`)를 연다 — `'self'` 로 열면 정화를 빠져나간 CSS `url()` 이 다른 API 를 부를 수 있다(2026-10-29 리뷰). 중계 주소 모양은 짝: backend `images/mail-image-token.ts` 의 `TOKEN_PATTERN`. 인쇄는 새 탭(`window.open` 은 누름 처리 안에서 바로), A4 폭(210mm — @page 와 같은 값, 함께 고친다)에서 본문 높이를 다시 잰 뒤 `window.print()` 한 번. Ctrl+P 판정은 `keyboard-shortcut.ts`(조합 중 · 다른 수식키는 브라우저 인쇄, `code === 'KeyP'` 도) |
| 보낸 사람 차단 · 보내기 · 읽기 설정 (9단계) | `src/composables/useSenderBlock.ts` · `components/mail/MessageBlockDialog.vue` · `stores/mail-block.store.ts` · `shared/utils/mail-block.ts` · `shared/constants/mail-block.ts`(`PUBLIC_MAIL_DOMAINS`) · `pages/settings/MailBlocksPage.vue` · `MailSendReadPage.vue` · `stores/mail-settings.store.ts` · `composables/useMailSettings.ts` · 설정 틀의 `title-aside` 슬롯(`SettingsPageFrame`) | 차단은 엔진의 Sieve(필터 · 부재중과 같은 스크립트 — 계약 2절 "보낸 사람 차단"). 여러 사람이 쓰는 메일 서비스 목록은 **짝: backend `mail.constants.ts`**(30개 · 같은 차례 — 한쪽을 고치면 둘 다). 차단 · 풀기가 성공하면 엔진 규칙이 켜진 것으로 본다(`isActive`). [되돌리기]는 원래 메일함으로. 설정은 고르는 대로 `PATCH` — **칸마다 저장은 한 번에 하나**(보내는 동안 고른 값은 끝난 뒤 다르면 보낸다, 끝내 실패하면 서버가 확인한 값으로 — 6단계 필터의 `saving` 과 같은 결) |
| 대용량 첨부 (11단계) | 올리기 엔진 `src/composables/useLargeFileUploads.ts` · 쓰기 화면 잇기 `useComposeLargeFiles.ts` · 첨부 칸 `components/mail/ComposeAttachmentGroups.vue` · `ComposeLargeFileItem.vue` · 읽기 `MessageAttachments.vue`(+ `MessageLargeFileItem` · `MessageLargeFileLinkBar` · `LargeFileRevokeDialog`) · 검사 다시 묻기 `useLargeFileScanRefresh.ts` · 위험 알림 `useLargeFileAlerts.ts`(실시간 `large-file`) · 설정 `pages/settings/MailLargeFilesPage.vue` + `components/settings/LargeFile*.vue` · `stores/large-files.store.ts` · API `useLargeFiles.ts` · 공개 화면 `pages/share/SharePage.vue` + `usePublicShare.ts` · `shared/utils/large-file.ts` · `large-file-status.ts` · `kst-date.ts` · `share-links.ts` · `shared/constants/large-file.ts` | 20MB 넘는 파일 · 합계 25MB 를 넘기는 파일 · 메일에 못 담는 형식은 저절로 링크로(묻는 창 없음). **올리기**: 고르자마자 서버에 모두 만들고(임시 저장본에 바로 실림) 조각(16MiB · `Content-Digest`)은 파일 3개까지만 함께, 끊기면 2 · 5 · 15 · 30초로 다시, 1분 넘으면 멈춤 + [다시 시도], 404 는 저절로 다시 올리지 않는다, 새로고침 뒤 같은 파일(이름 · 크기 · 고친 시각 + 맨 앞 조각 SHA-256)을 다시 고르면 잇는다. 다 올릴 때까지 [보내기] · ▾ 는 `aria-disabled` + 까닭(누르면 role=status). **본문**: 대용량 파일이 있을 때만 첫 원문 인용 앞에 `<div data-ara-quote-start="">` 와 `bodyQuoteStart`(텍스트 인용 줄의 처음)를 보낸다 — 서버가 그 자리에 덩이를 넣는다. 다시 연 저장본의 원문 인용은 `mail-extensions.ts` 의 `isMailQuoteBlock`("원본 메일" 머리 문단 + 머리 표)으로 알아본다. **읽기**: 첨부 칸의 대용량 항목은 서버의 `largeFiles` 에서만 오고, 본문 덩이는 서버만 붙일 수 있는 `data-ara-large-files` 요소만 `<template>` 안에서 접는다(`mail-html.ts`). **공개 화면** `/s/:token` 은 `meta.public`(세션 복원을 건너뛴다 — 이 라우트 하나뿐), 요청은 `credentials: 'omit'` · `no-store` · `no-referrer`, v-html 없음. 서버 거절은 사전 문구(`getErrorMessage`), 화면 상수 글은 서버에 묻기 전 검사뿐 — 계약 4절, 디자인 README "11단계" |
@@ -284,6 +285,8 @@ router.beforeEach((to) => {
편집기로 읽어 들이는 서식은 **서버가 남기는 형식만** 받는다 (`outgoing-html-rules.ts`) — 서버만 지우면 편집기에서 숨은 글(`font-size:0` 등)이
받는 사람에게 보이는 식으로 화면과 보낸 메일이 달라진다 (2026-10-27 리뷰).
이어 쓰는 임시 저장본의 `bodyHtml`·서명 HTML 도 같은 경로로 편집기에 옮긴다 (다른 메일 프로그램이 만든 초안도 있다).
- [ ] **아래에 붙는 줄(sticky)을 새로 만들 때**: 겹침 차례(z-index — 그 줄 위로 뜰 메뉴 · 목록)와 그 줄에 가리는 초점(키보드 · 코드로 옮긴 초점 — 쓰기 화면은 `useFocusAboveFooter`)을 함께 본다
- [ ] **줄 · 칸 전체에 click 을 걸 때**: 안의 단추 · 링크를 가르는 것은 `event.target.closest` 가 아니라 `eventPathHas(event, 선택자)`(`shared/utils/event-path.ts`) — 앞선 처리기가 상태를 바꾸면 처리기 사이에 Vue 가 다시 그려 target(아이콘 그림 등)이 문서에서 떨어진다. 단추 안의 그림은 `pointer-events: none`
- [ ] **lint/test**: 커밋 전 `npm run lint` (`--max-warnings 0`) + `npm run type-check` + `npm run test:unit` 통과. 자동 수정은 `npm run lint:fix`.
테스트 시간 한도는 전역 20초(`vitest.config.ts` — jsdom·편집기 테스트가 부하에서 5~13초)이고, 긴 초안을 맞추는 테스트는 `src/test-utils/timeouts.ts` 의 한도를 쓴다.
"몇 ms 안에 끝난다"를 검사하는 성능 단언은 벽시계 대신 계산량(CPU 시간·비용)으로 잰다 — 벽시계는 다른 작업이 많으면 몇 배로 늘어
@@ -1,5 +1,4 @@
# 휴대폰 앱이 나왔고, 메일 검색과 여러 설정이 좋아졌습니다
<!-- 배포 날짜가 정해지면 파일 이름을 그날로 바꾸고 이 줄을 지웁니다 -->
- 11월 5일 새 웹메일로 옮긴 뒤 새로 생기거나 바뀐 것을 모았습니다. 앞으로 바뀌는 것도 이곳에서 알려 드리겠습니다.
+23
View File
@@ -229,6 +229,24 @@
받지 않는 곳에 놓으면 브라우저가 그 파일을 열어 쓰던 메일을 잃기 때문이다 (2026-10-27 구현하며 정함).
- 이미지를 첨부로 돌렸을 때만 성공 알림 "이미지는 본문 대신 첨부 파일로 올렸습니다."를 띄운다(성공 알림과 같은 모양 · 5초).
### 받는 사람이 많을 때 · 낮은 화면 — 스크롤 (2026-11-10 사용자 보고 — 캔버스에는 없다)
전 직원 공지를 숨은참조로 쓰자 칸이 늘어 쓰기 칸이 넘쳤고, 바깥 스크롤 + 본문 스크롤의 이중 스크롤 속에 맨 아래 [보내기] 줄이 화면 밖으로 숨었다
(본문 위에서 휠을 돌리면 본문만 움직였다). 모양 · 크기는 그대로 두고 아래처럼 고쳤다.
- **[보내기] 줄은 늘 보인다** — 스크롤하는 칸(넓은 화면은 쓰기 칸, 좁은 화면은 문서)의 아래에 붙는다. 위 선 · 흰 바탕 · 여백은 그대로이고 넘칠 때만 내용 위에 뜬다.
[보내기 ▾] · [서명 ▾] 메뉴는 위로 연다. 겹침은 툴바 < [보내기] 줄 < 받는 사람 추천 목록 · 툴바의 메뉴(연 동안)다.
- **받는 사람 · 참조 · 숨은참조는 칩이 세 줄을 넘으면 그 칸 안에서 스크롤한다** — 보이는 높이 96: 맨 위에서는 칩 세 줄, 맨 아래(입력 자리)에서는
칩 두 줄 + 입력 줄로 줄 사이에서 끊겨 반쯤 잘린 칩이 보이지 않는다. 초점이 오거나 · 칩이 늘거나 · 글을 치면 맨 아래로 간다.
칩의 ✕ 로 지우면 초점은 입력칸으로 가되 칸의 자리는 그대로다(가운데 칩을 이어 지운다). 추천 목록은 칸 밖(줄 아래)에 뜬다.
- **넓은 화면(1120 이상)은 본문 편집 영역이 300 에서 200 까지 먼저 줄어든다** — 보통 높이(1366×768 · 1440×900)에서는 숨은참조 30명이어도
쓰기 칸 전체가 화면 안에 들고 본문만 스크롤한다(1366×768 에서 편집 영역 226). 그래도 넘치면(첨부가 많음 · 아주 낮은 창) 쓰기 칸이 스크롤하고
[보내기] 줄은 아래에 붙어 있다. 좁은 화면은 300 그대로이고 문서가 스크롤한다.
- [보내기] 줄에 가린 칸으로 키보드 · 코드의 초점이 오면 가린 만큼 올린다(WCAG 2.4.11 — 좁은 화면에서 Tab 으로 간 [파일 첨부]가 가렸다).
마우스 · 손가락으로 누른 칸은 그대로다 — 누른 채 내용이 움직이면 글이 골라진다.
- 넓은 화면에서 문서까지 168px 스크롤하던 것(화면 낭독 전용 글이 앱 틀 밖에 놓였다)도 고쳤다 — 쓰기 칸이 위치를 잡는다.
- 알려진 한계: 휴대폰 폭(360)에서는 [보내기] 줄이 세 줄(보내기 · 임시 저장 / 서명 · 저장 상태 / 보내는 사람 — 161px)로 화면의 1/5 을 덮는다 — 나중에 다듬는다.
## 2단계 — 임시 저장 · 서명 · 받는 사람 자동 완성 (2026-10-27 확정 — `Settings` · `ComposeAssist`)
### 임시 저장
@@ -302,6 +320,11 @@ API 와 서버 규칙은 계약 2절 "새 메일 확인" · "검색" · "여러
- **목록 보기 표**: 맨 앞에 고르기 칸(32 — 별 칸과 같은 밀도 예외). 상자 16px — 비었을 때 테두리 #8A94A3 1.5px, 고르면 #0E7C86 채움에 흰 체크.
머리의 상자는 불러온 메일 모두 고르기 · 풀기(일부만 골랐으면 – 표시). 고른 행 바탕 #E8F4F5(마우스를 올리면 #DDEFF0).
칸 = 고르기 32 · 별 32 · 보낸 사람 168 · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12). 좁아질 때는 지금 규칙 그대로(1100 미만 크기 칸 숨김 · 600 미만 보낸 사람 112 · 날짜 72).
행 어디를 눌러도 메일을 열되 **고르기 칸 · 별 칸은 어디를 눌러도 열지 않는다** — 고르기 칸은 빈자리(단추 밖)를 눌러도 상자를 누른 것과 같이 고르고
(Shift 는 범위 · 고를 수 없는 줄이면 아무 일도 없다), 별 칸은 별 단추만 — 별 칸의 빈자리는 아무 일도 없다
(2026-11-10 사용자 보고 — 고르기 상자 가운데를 누르면 고르면서 메일까지 열렸다: 고르자 상자 그림이 다시 그려져 문서에서 떨어진 탓.
줄은 사건이 지나간 길로 가르고 상자 · 별 그림은 누름을 받지 않는다 — 대화로 묶은 표도 같다). Ctrl(⌘) · Shift + 클릭은 칸의 빈자리에서도 지금처럼 고른다.
분할 보기는 고르기 칸(36)이 줄의 링크 밖이라 이 탈이 없었다(같은 시험으로 확인).
- **분할 보기**: 행에 마우스를 올리거나 초점이 가면 왼쪽 36px 에 고르기 상자가 나타나고, 하나라도 고르면 모든 행에 보인다.
행을 누르면 지금처럼 메일을 연다. Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위, Esc 는 선택 풀기 (두 보기 공통).
여러 통을 고르는 동안 본문 칸은 열어 둔 메일을 그대로 보인다.
+13 -1
View File
@@ -4,8 +4,10 @@
// [보내기 ▾](9단계): 단추를 둘로 나눈다 — 왼쪽은 지금의 [보내기], 오른쪽 ▾(44 — 보드는 40, 밀도 예외를 새로 만들지 않는다)는 메뉴 「예약 발송…」(시계).
// 메뉴는 편집기 메뉴(EditorMenu)를 쓴다 — 키보드로 열면(Enter · Space · 아래 화살표) 첫 항목에, 마우스로 열면 메뉴 자체에 초점, Esc 는 ▾ 로 돌아간다.
// 두 단추는 같은 규칙으로 함께 꺼진다(보내는 중 · 편집기를 불러오는 중 등 — canSend · 첨부의 까닭 — blockedReason)
// 메뉴는 단추 아래로 열되 보이는 영역 아래가 모자라면 위로 연다(usePopupPlacement) — 단추 줄은 편집기가 늘어나 화면 맨 아래에 붙으므로
// 메뉴는 단추 아래로 열되 보이는 영역 아래가 모자라면 위로 연다(usePopupPlacement) — 단추 줄은 화면 맨 아래에 붙으므로
// 보통은 위로 열린다 (아래로 열면 화면 밖에 생기고, 초점이 들어갈 때 쓰기 화면이 튀어 올랐다 — 2026-10-29 리뷰)
// 줄은 스크롤하는 칸(넓은 화면은 쓰기 칸, 좁은 화면은 문서)의 아래에 붙는다(position: sticky) — 받는 사람이 많거나 화면이 낮아 쓰기 칸이 넘쳐도
// [보내기]가 늘 보인다 (2026-11-10 사용자 보고 — 전 직원을 숨은참조에 넣자 이 줄이 화면 밖으로 숨었다). 겹침 차례는 아래 style 의 주석
// 꺼진 [보내기] · ▾ 는 disabled 가 아니라 aria-disabled 다(11단계 — 초점은 받는다). 첨부 때문에 막혔으면(올리는 중 · 다 올리지 못함 · 빼야 하는 파일)
// [임시 저장] 오른쪽에 까닭 글(ⓘ 12.5 — 두 단추의 aria-describedby #compose-send-why)을 두고, 누르면 보내지 않고 까닭을 화면 낭독기에 읽어 준다(role=status).
// 「예약 발송…」도 같다. 임시 저장은 그대로 된다 (디자인 README "11단계 › 다 올릴 때까지 보내기를 막는다")
@@ -227,13 +229,23 @@ watch(unavailable, (off) => {
</template>
<style scoped>
/*
* 스크롤하는 칸의 아래에 붙는다 — 모양(위 선 · 흰 바탕 · 여백)은 그대로이고 넘치는 동안만 내용 위에 떠 있다 (2026-11-10 사용자 보고).
* 겹침 차례: 편집기 툴바(2) < 이 줄(3) < 받는 사람 추천 목록 · 메뉴를 연 툴바(4) — 툴바는 이 줄 밑으로 지나가고,
* 추천 목록과 툴바의 메뉴는 이 줄 위에 뜬다. 이 줄의 메뉴([보내기 ▾] · [서명 ▾])는 이 줄 안에서 위로 열려 툴바 위에 온다.
* 위치를 잡은 줄이라 안의 화면 낭독 글(.visually-hidden)도 이 줄 안에 든다 — 넓은 화면에서 문서가 스크롤되지 않게
*/
.compose__footer {
position: sticky;
bottom: 0;
z-index: 3;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-8);
padding: var(--space-10) var(--content-inline);
border-top: var(--border-width) solid var(--color-border);
background: var(--color-surface);
}
/* [보내기 ▾] — 둘을 한 덩어리로 (디자인 원본 MailSendLater: 모서리 8 · 사이에 옅은 흰 선). 메뉴는 이 덩어리 아래(모자라면 위)에서 연다 */
@@ -3,6 +3,7 @@
// - 칸은 메일 표(MessageTableRow)와 같은 자리 · 폭이다. 크기 칸은 없다 — 대화 하나의 크기는 마지막 메일의 크기가 아니다
// - 참여자 뒤에 회색 "4통"(한 통이면 없다) — 참여자가 길면 이름이 먼저 줄고 통 수는 남는다
// - 행 어디를 눌러도 그 메일함의 마지막 메일이 열리고(open), 제목은 키보드로 갈 수 있는 링크다. Ctrl(⌘) · Shift + 클릭은 고른다 (메일 표와 같다)
// 고르기 · 별 칸은 어디를 눌러도 열지 않는다 — 고르기 칸은 빈자리도 고르기 상자를 누른 것과 같고, 별 칸의 빈자리는 아무 일도 없다 (2026-11-10 사용자 보고)
// - 줄의 메일이 모두 보내기를 기다리는 메일이면 고르기 · 별을 쓸 수 없다(aria-disabled + 까닭은 title)
// - 임시보관함의 제목 없는 저장본은 메일 표처럼 흐린 "(제목 없음)" (리뷰 L5)
// 칸 폭 · 칸 공통 여백은 표(MessageTable)가 정한다.
@@ -18,6 +19,7 @@ import { MAIL_BULK_TEXT, MAIL_LIST_ROW_TEXT, MAIL_NO_SUBJECT } from '@/shared/co
import { MAIL_CONVERSATION_LIST_TEXT } from '@/shared/constants/mail-conversation'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import type { ConversationSummary } from '@/shared/types/mail'
import { eventPathHas } from '@/shared/utils/event-path'
import { formatMailTableDate } from '@/shared/utils/format'
import { conversationCountText } from '@/shared/utils/mail-conversation-list'
import { pendingSendLabel, type RowWho } from '@/shared/utils/mail-list-row'
@@ -50,6 +52,13 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
/** 스스로 처리하는 것 — 고르기 · 별 단추 (메일 표와 같다) */
const ROW_CONTROLS = 'button, input'
/** 고르기 칸 — 단추 밖의 빈자리를 눌러도 고르기 상자를 누른 것과 같다 */
const CHECK_CELL = '.mail-table__check'
/** 눌러도 줄을 열지 않는 곳 — 제목 링크 · 별 칸(단추 밖의 빈자리 — 아무 일도 없다) */
const ROW_NO_OPEN = 'a, .mail-table__star'
const drag = useMailRowDrag()
const subject = computed(() => props.row.latest.subject || MAIL_NO_SUBJECT)
@@ -57,16 +66,23 @@ const countText = computed(() => conversationCountText(props.row))
/** 마지막 메일이 붙잡아 둔 발송이면 "예약 · …" · "보내는 중" (9단계) */
const pendingLabel = computed(() => pendingSendLabel(props.row.latest))
/** 행 어디를 눌러도 연다 — 버튼 · 제목 링크(스스로 연다) · 글자를 고르는 중이면 그대로 둔다 (메일 표와 같다) */
/**
* 행 어디를 눌러도 연다 — 단추 · 제목 링크(스스로 연다) · 별 칸의 빈자리 · 글자를 고르는 중이면 그대로 둔다 (메일 표와 같다).
* 고르기 칸의 빈자리(단추 밖)는 고르기 상자를 누른 것과 같다 — 고르고(Shift 는 범위) 열지 않는다, 고를 수 없는 줄이면 아무 일도 없다.
* 무엇을 눌렀는지는 사건이 지나간 길로 가른다 — 고르기 상자 그림은 고르면 다시 그려져 target 이 문서에서 떨어진다 (2026-11-10 사용자 보고)
*/
function onRowClick(event: MouseEvent) {
const target = event.target instanceof Element ? event.target : null
if (target?.closest('button')) return
if (eventPathHas(event, ROW_CONTROLS)) return
if (eventPathHas(event, CHECK_CELL)) {
if (!props.held) emit('check', props.row.id, event.shiftKey)
return
}
if (event.ctrlKey || event.metaKey || event.shiftKey) {
event.preventDefault()
if (!props.held) emit('check', props.row.id, event.shiftKey)
return
}
if (target?.closest('a')) return
if (eventPathHas(event, ROW_NO_OPEN)) return
if (window.getSelection()?.toString()) return
emit('open', props.to)
}
@@ -215,6 +231,11 @@ function onStar() {
color: var(--color-star);
}
/* 별 그림은 누름을 받지 않는다 — 누른 것(target)이 늘 단추다 (메일 표와 같다) */
.thread-table-row__star .app-icon {
pointer-events: none;
}
.thread-table-row__star[aria-disabled='true'] {
cursor: not-allowed;
}
@@ -94,10 +94,15 @@ function onClick(event: MouseEvent) {
background: transparent;
}
/*
* 상자 그림은 누름을 받지 않는다 — 누른 것(target)이 늘 단추다. 고르면 그림이 바뀌며(빈 상자 → 채운 상자) 누른 <rect> 가 문서에서 떨어져,
* 줄 전체에 click 을 건 목록 보기가 단추를 누른 줄 모르고 메일까지 열었다 (2026-11-10 사용자 보고 — 줄은 사건의 길로도 가른다, event-path.ts)
*/
.mail-checkbox__box {
flex: 0 0 auto;
width: var(--size-checkbox);
height: var(--size-checkbox);
pointer-events: none;
}
.mail-checkbox__empty {
@@ -0,0 +1,225 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import type { Component } from 'vue'
import type { ConversationSummary, MessageSummary } from '@/shared/types/mail'
import { flushPromises } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import ConversationTableRow from './ConversationTableRow.vue'
import MessageListItem from './MessageListItem.vue'
import MessageTableRow from './MessageTableRow.vue'
// 고르기 칸을 누르면 메일까지 열렸다 (2026-11-10 사용자 보고 — 목록 보기에서 고르기 상자 가운데를 눌렀다).
// 목록 보기의 줄(tr)은 줄 전체에 click 을 걸고 "단추 · 링크를 눌렀는가"를 target.closest 로 갈랐다. 상자 가운데를 누르면 target 은 빈 상자 그림(<rect>)이고,
// 단추의 처리기가 먼저 돌아 고르기를 바꾸면 브라우저는 처리기 사이에 microtask 를 돌려 Vue 가 다시 그린다 — 빈 상자 그림이 빠져 target 이 문서에서 떨어지고,
// 줄의 처리기에서 closest('button') 이 비어 줄을 누른 것으로 보고 열었다. 고르기 칸 · 별 칸의 빈자리(단추 밖)를 눌러도 열었다.
// 이제 줄은 사건이 지나간 길(event-path.ts — dispatch 때 정해진다)로 가르고, 상자 · 별 그림은 누름을 받지 않는다(pointer-events: none — 브라우저 시험).
// 고르기 칸의 빈자리는 고르기 상자를 누른 것과 같고(고를 수 없는 줄이면 아무 일도 없다), 별 칸의 빈자리는 아무 일도 없다.
// 스크립트의 dispatchEvent 는 처리기 사이에 microtask 를 돌리지 않는다 — 단추와 줄 사이(칸의 처리기)에서 그림을 떼어 내 브라우저의 다시 그리기를 흉내 낸다
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: ['inbox'],
from: { name: '이도윤', email: 'doyun.lee@arasystem.test' },
to: [{ name: '김아라', email: 'ara.kim@arasystem.test' }],
subject: `제목 ${id}`,
preview: '',
receivedAt: '2026-11-10T01:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 2048,
...partial,
}
}
function conversation(id: string): ConversationSummary {
const latest = summary(`${id}-latest`)
return {
id,
latest,
participants: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
messageCount: 3,
messageIds: [latest.id, `${id}-2`, `${id}-3`],
pendingSendIds: [],
isRead: true,
isFlagged: false,
hasAttachment: false,
}
}
interface RowCase {
name: string
component: Component
props: Record<string, unknown>
/** 고를 수 없는 줄로 바꾸는 props — 보내기를 기다리는 메일(9단계) */
heldProps: Record<string, unknown>
/** 고르기 · 별 사건에 실리는 줄 id */
rowId: string
}
const ROWS: RowCase[] = [
{
name: '메일 줄 (MessageTableRow)',
component: MessageTableRow,
props: { message: summary('m1'), to: '/mail/inbox/m1', role: 'inbox' },
heldProps: { message: summary('m1', { pendingSend: { kind: 'scheduled', sendAt: '2026-11-11T00:00:00Z' } }) },
rowId: 'm1',
},
{
name: '대화 줄 (ConversationTableRow — 대화로 묶기)',
component: ConversationTableRow,
props: {
row: conversation('c1'),
who: { text: '이도윤', placeholder: false },
to: '/mail/inbox/c1-latest',
},
heldProps: { held: true },
rowId: 'c1',
},
]
describe('목록 보기의 줄 — 고르기 칸 · 별 칸을 눌러도 메일을 열지 않는다 (2026-11-10 사용자 보고)', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
})
async function mountRow(row: RowCase): Promise<VueWrapper> {
const router = createTestRouter()
await router.push('/mail/inbox')
await router.isReady()
return mount(row.component, { props: row.props, global: { plugins: [pinia, router] } })
}
/** 누른 그림을 단추와 줄 사이(칸의 처리기)에서 떼어 낸다 — 브라우저에서 단추의 처리기 뒤 Vue 가 다시 그린 것처럼 */
function detachBeforeRow(wrapper: VueWrapper, cellSelector: string, picture: Element) {
wrapper.get(cellSelector).element.addEventListener('click', () => picture.remove())
}
function click(element: Element, init: MouseEventInit = {}) {
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, ...init }))
}
describe.each(ROWS)('$name', (row) => {
it('고르기 상자 그림(빈 상자)을 누르면 고르기만 한다 — 고르며 다시 그려 그림이 문서에서 떨어져도 열지 않는다', async () => {
const wrapper = await mountRow(row)
const picture = wrapper.get('td.mail-table__check .mail-checkbox__empty').element
detachBeforeRow(wrapper, 'td.mail-table__check', picture)
click(picture)
await flushPromises()
expect(picture.isConnected).toBe(false)
expect(wrapper.emitted('check')).toEqual([[row.rowId, false]])
expect(wrapper.emitted('open')).toBeUndefined()
})
it('Shift + 상자 그림은 범위로 고른다 — 열지 않는다', async () => {
const wrapper = await mountRow(row)
const picture = wrapper.get('td.mail-table__check .mail-checkbox__empty').element
detachBeforeRow(wrapper, 'td.mail-table__check', picture)
click(picture, { shiftKey: true })
expect(wrapper.emitted('check')).toEqual([[row.rowId, true]])
expect(wrapper.emitted('open')).toBeUndefined()
})
it('고르기 칸의 빈자리(단추 밖)는 고르기 상자를 누른 것과 같다 — 고르고 · Shift 는 범위 · Ctrl(⌘)은 하나씩, 열지 않는다', async () => {
const wrapper = await mountRow(row)
const cell = wrapper.get('td.mail-table__check').element
click(cell)
click(cell, { shiftKey: true })
click(cell, { ctrlKey: true })
click(cell, { metaKey: true })
expect(wrapper.emitted('check')).toEqual([
[row.rowId, false],
[row.rowId, true],
[row.rowId, false],
[row.rowId, false],
])
expect(wrapper.emitted('open')).toBeUndefined()
})
it('고를 수 없는 줄(보내기를 기다리는 메일)이면 고르기 칸의 빈자리를 눌러도 아무 일도 없다', async () => {
const wrapper = await mountRow({ ...row, props: { ...row.props, ...row.heldProps } })
click(wrapper.get('td.mail-table__check').element)
click(wrapper.get('td.mail-table__check').element, { shiftKey: true })
expect(wrapper.emitted('check')).toBeUndefined()
expect(wrapper.emitted('open')).toBeUndefined()
})
it('별 칸의 빈자리(단추 밖)는 아무 일도 없다 — 열지도 · 별을 바꾸지도 · 고르지도 않는다', async () => {
const wrapper = await mountRow(row)
click(wrapper.get('td.mail-table__star').element)
expect(wrapper.emitted('open')).toBeUndefined()
expect(wrapper.emitted('check')).toBeUndefined()
expect(wrapper.emitted('toggle-flag')).toBeUndefined()
})
it('별 그림을 누르면 중요 표시만 한다 — 그림이 떨어져도 열지 않는다', async () => {
const wrapper = await mountRow(row)
const picture = wrapper.get('td.mail-table__star svg').element
detachBeforeRow(wrapper, 'td.mail-table__star', picture)
click(picture)
expect(wrapper.emitted('toggle-flag')).toEqual([[row.rowId]])
expect(wrapper.emitted('open')).toBeUndefined()
})
it('줄의 다른 곳(사람 · 날짜 칸)을 누르면 지금처럼 연다 · Ctrl(⌘) · Shift + 클릭은 열지 않고 고른다', async () => {
const wrapper = await mountRow(row)
const cells = wrapper.findAll('td')
click(cells[2]!.element)
expect(wrapper.emitted('open')).toHaveLength(1)
click(wrapper.get('td.mail-table__date').element, { ctrlKey: true })
click(cells[2]!.element, { shiftKey: true })
expect(wrapper.emitted('check')).toEqual([
[row.rowId, false],
[row.rowId, true],
])
expect(wrapper.emitted('open')).toHaveLength(1)
})
})
})
describe('분할 보기의 줄 — 고르기 칸(36)은 링크 밖이라 열지 않는다 (지금처럼)', () => {
it('상자 그림을 눌러도 고르기만 하고 주소는 그대로다 · 줄(링크)을 누르면 연다', async () => {
setActivePinia(createPinia())
const router = createTestRouter()
await router.push('/mail/inbox')
await router.isReady()
const wrapper = mount(MessageListItem, {
props: { message: summary('m1'), to: '/mail/inbox/m1', role: 'inbox' },
global: { plugins: [router] },
})
click(wrapper.get('.message-row__check .mail-checkbox__empty').element)
await flushPromises()
expect(wrapper.emitted('check')).toEqual([['m1', false]])
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
await wrapper.get('.message-row__main').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
})
function click(element: Element) {
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
}
})
@@ -1,6 +1,8 @@
<script setup lang="ts">
// 목록 보기 표의 한 행(38) — 고르기 · 별 · 보낸 사람 · [메일함] · 제목(링크 + 첨부 클립) · 날짜 · 크기 (디자인 원본 MainList · BulkSelect)
// - 행 어디를 눌러도 메일이 열리고(open), 제목은 키보드로 갈 수 있는 링크다. 고르기 · 별 버튼은 따로 동작한다
// - 행 어디를 눌러도 메일이 열리고(open), 제목은 키보드로 갈 수 있는 링크다. 고르기 · 별 버튼은 따로 동작한다 —
// 고르기 · 별 칸은 어디를 눌러도(상자 · 별 그림 위 · 단추 밖의 빈자리) 열지 않는다 — 고르기 칸은 빈자리도 고르기 상자를 누른 것과 같고,
// 별 칸의 빈자리는 아무 일도 없다 (2026-11-10 사용자 보고 — 고르기 상자 가운데를 누르면 메일까지 열렸다)
// - Ctrl(⌘)+클릭은 하나씩, Shift+클릭은 범위로 고른다 — 메일을 열지 않고, 제목 링크를 눌러도 새 탭 · 새 창으로 열지 않는다
// - 보낸 사람 자리 · 읽음 표시 · 누르면 갈 곳은 메일함 규칙을 따른다 (mail-list-row.ts)
// - 아직 보내지 않은 내 메일(9단계)은 날짜 칸이 "예약 · 10월 30일 오전 9:00" · "보내는 중"(청록) — 칸(84)보다 길면 줄이고 전체는 title 로.
@@ -19,6 +21,7 @@ import { useMailRowDrag } from '@/composables/useMailDrag'
import { MAIL_BULK_TEXT, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import { SEND_LATER_TEXT } from '@/shared/constants/mail-send-later'
import type { MailboxRole, MessageListItem } from '@/shared/types/mail'
import { eventPathHas } from '@/shared/utils/event-path'
import { formatFileSize, formatMailTableDate } from '@/shared/utils/format'
import { isDraftRole, isRowUnread, pendingSendLabel, rowWho } from '@/shared/utils/mail-list-row'
import { isHeldMessage } from '@/shared/utils/pending-send'
@@ -48,6 +51,13 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
/** 스스로 처리하는 것 — 고르기 · 별 단추 (Ctrl · Shift + 클릭으로도 고르지 않는다) */
const ROW_CONTROLS = 'button, input'
/** 고르기 칸 — 단추 밖의 빈자리를 눌러도 고르기 상자를 누른 것과 같다 */
const CHECK_CELL = '.mail-table__check'
/** 눌러도 줄을 열지 않는 곳 — 제목 링크(스스로 연다) · 별 칸(단추 밖의 빈자리 — 아무 일도 없다) */
const ROW_NO_OPEN = 'a, .mail-table__star'
const drag = useMailRowDrag()
const who = computed(() => rowWho(props.message, { role: props.role, mixed: props.mixed, view: 'table' }))
@@ -61,16 +71,24 @@ const held = computed(() => isHeldMessage(props.message))
/** 검색 강조 (14단계) — "(제목 없음)"에는 없다 */
const subjectRanges = computed(() => (props.message.subject ? (props.message.searchHighlight?.subject ?? null) : null))
/** 행 어디를 눌러도 연다 — 버튼을 누른 경우 · 제목 링크(스스로 연다) · 글자를 고르는 중이면 그대로 둔다 */
/**
* 행 어디를 눌러도 연다 — 단추(고르기 · 별)를 누른 경우 · 제목 링크(스스로 연다) · 별 칸의 빈자리 · 글자를 고르는 중이면 그대로 둔다.
* 고르기 칸의 빈자리(단추 밖)는 고르기 상자를 누른 것과 같다 — 고르고(Shift 는 범위) 열지 않는다, 고를 수 없는 줄이면 아무 일도 없다.
* 무엇을 눌렀는지는 사건이 지나간 길(eventPathHas)로 가른다 — 고르기 상자 가운데를 누르면 target 은 상자 그림인데 단추의 처리기가
* 고르기를 바꾸며 다시 그려 문서에서 떨어진다(target.closest 로 보다가 메일까지 열렸다 — 2026-11-10 사용자 보고)
*/
function onRowClick(event: MouseEvent) {
const target = event.target instanceof Element ? event.target : null
if (target?.closest('button')) return
if (eventPathHas(event, ROW_CONTROLS)) return
if (eventPathHas(event, CHECK_CELL)) {
if (!held.value) emit('check', props.message.id, event.shiftKey)
return
}
if (event.ctrlKey || event.metaKey || event.shiftKey) {
event.preventDefault()
if (!held.value) emit('check', props.message.id, event.shiftKey)
return
}
if (target?.closest('a')) return
if (eventPathHas(event, ROW_NO_OPEN)) return
if (window.getSelection()?.toString()) return
emit('open', props.to)
}
@@ -216,6 +234,11 @@ function onMouseDown(event: MouseEvent) {
color: var(--color-star);
}
/* 별 그림은 누름을 받지 않는다 — 누른 것(target)이 늘 단추다 (고르기 상자 그림과 같다 — MailCheckbox) */
.mail-table__star-button .app-icon {
pointer-events: none;
}
.mail-table__who {
font-weight: 450;
color: var(--color-text-strong);
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { shallowRef } from 'vue'
@@ -626,3 +626,104 @@ describe('RecipientField', () => {
})
})
})
describe('RecipientField — 칩이 세 줄을 넘으면 칸 안에서 스크롤 (2026-11-10 사용자 보고)', () => {
// 전 직원을 숨은참조에 넣자 칸이 늘어 쓰기 화면이 넘치고 [보내기]가 화면 밖으로 숨었다 — 칩 · 입력 칸(.recipient-field__control)은
// 높이 한도(--size-recipient-chips-max)에서 그 칸 안이 스크롤한다(값은 ComposePage.scroll.spec 이 원본 style 에서 본다).
// jsdom 은 배치를 하지 않으므로 칸의 높이를 정해 주고 scrollTop 을 붙잡는다
const MANY: RecipientToken[] = Array.from({ length: 30 }, (_, i) =>
tokenFromAddress({ name: '', email: `notice-staff${i + 1}@example.com` }),
)
/** 칩 · 입력 칸의 scrollTop — 칸의 내용 높이는 400 */
function scrollOf(wrapper: VueWrapper) {
const control = wrapper.get('.recipient-field__control').element
Object.defineProperty(control, 'scrollHeight', { configurable: true, value: 400 })
const box = { top: 0 }
Object.defineProperty(control, 'scrollTop', {
configurable: true,
get: () => box.top,
set: (value: number) => {
box.top = value
},
})
return box
}
it('초점이 오면 칸을 맨 아래로 — 입력칸은 늘 칸의 끝에 있어 입력 자리가 보인다', async () => {
const wrapper = mountField(MANY)
const scroll = scrollOf(wrapper)
await wrapper.get('input').trigger('focus')
expect(scroll.top).toBe(400)
})
it('칩이 늘면(확정) 그린 뒤 맨 아래로 · 줄면(빈 입력칸 Backspace) 칸의 자리를 그대로 둔다', async () => {
const wrapper = mountField(MANY)
const scroll = scrollOf(wrapper)
const input = wrapper.get('input')
await input.setValue('new.one@example.com')
scroll.top = 0
await input.trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(tokensOf(wrapper)).toHaveLength(31)
expect(scroll.top).toBe(400)
scroll.top = 120
await input.trigger('keydown', { key: 'Backspace' })
await flushPromises()
expect(tokensOf(wrapper)).toHaveLength(30)
expect(scroll.top).toBe(120)
})
it('글을 치면 맨 아래로 — 칸을 위로 올려 본 채 쳐도 치는 자리가 보인다 (한글 조합 중 글자도)', async () => {
const wrapper = mountField(MANY)
const scroll = scrollOf(wrapper)
const input = wrapper.get('input')
await input.setValue('kim')
expect(scroll.top).toBe(400)
scroll.top = 0
await input.trigger('compositionupdate')
expect(scroll.top).toBe(400)
})
it('가운데 칩의 ✕ 는 그 칩만 지우고 초점은 지금처럼 입력칸으로 — 칸의 스크롤은 그대로 둔다(이어 지우게)', async () => {
const wrapper = mountField(MANY, suggestionsOf([]))
const scroll = scrollOf(wrapper)
const input = wrapper.get('input').element as HTMLInputElement
const focusSpy = vi.spyOn(input, 'focus')
scroll.top = 120
await wrapper.get('button[aria-label="notice-staff5@example.com 삭제"]').trigger('click')
expect(tokensOf(wrapper)).toHaveLength(29)
expect(tokensOf(wrapper).some((token) => token.address.email === 'notice-staff5@example.com')).toBe(false)
expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true })
expect(document.activeElement).toBe(input)
expect(scroll.top).toBe(120)
// 다시 들어오면(Tab · 누름) 맨 아래로
input.blur()
input.focus()
expect(scroll.top).toBe(400)
wrapper.unmount()
})
it('추천 목록은 칩 · 입력 칸 밖(줄 아래)에 뜬다 — 칸이 스크롤해도 목록이 잘리지 않게', async () => {
const suggestions = suggestionsOf([{ name: '이도윤', email: 'doyun.lee@arasystem.it.kr', source: 'directory' }])
const wrapper = mountField(MANY, suggestions)
const input = wrapper.get('input')
;(input.element as HTMLInputElement).focus()
await input.trigger('focus')
await input.setValue('도윤')
const list = wrapper.get('.recipient-suggestions')
expect(list.element.closest('.recipient-field')).toBe(wrapper.get('.recipient-field').element)
expect(list.element.closest('.recipient-field__control')).toBeNull()
wrapper.unmount()
})
})
@@ -16,6 +16,10 @@
// input · compositionupdate 의 값을 따로 둔다(query). 주소 토큰 확정은 v-model(draft) 그대로다
// - 한글 조합 중의 Enter · Tab 은 글자 확정이다 — 토큰으로도 추천으로도 넣지 않는다 (isImeComposing — Safari 의 keyCode 229 포함)
// 목록은 RecipientSuggestionList 가 그린다 — 고르기 · 넣기 키는 이 입력칸이 받는다(초점은 늘 입력칸).
// 칩이 세 줄을 넘으면 칩 · 입력 칸(.recipient-field__control) 안에서 스크롤한다 (--size-recipient-chips-max — 2026-11-10 사용자 보고:
// 전 직원을 숨은참조에 넣자 칸이 늘어 쓰기 화면이 넘쳤다). 추천 목록은 그 칸 밖(줄 아래)에 뜬다.
// 초점이 오거나 · 칩이 늘거나 · 글을 치면 칸을 맨 아래로(입력 자리가 보이게). 칩의 ✕ 로 지우면 초점은 지금처럼 입력칸으로 가되
// 칸의 스크롤은 그대로 둔다 — 가운데 칩을 이어 지울 때 자리가 맨 아래로 튀지 않게
// 고정 id — 목록 #{inputId}-suggestions · 추천 #{inputId}-option-{n} · 안내 #{inputId}-suggestions-hint (recipient-suggestion-ids.ts)
// 사용 예: <RecipientField v-model="to" label="받는 사람" input-id="compose-to" error-id="compose-recipient-error" />
import { computed, inject, nextTick, onBeforeUnmount, ref, watch } from 'vue'
@@ -79,6 +83,10 @@ const draft = ref<string>('')
/** 추천 검색어 — 입력칸에 보이는 글 그대로(한글 조합 중인 글자 포함). 조합이 끝나면 draft 와 같아진다 */
const query = ref<string>('')
const inputRef = ref<HTMLInputElement | null>(null)
/** 칩 · 입력 칸 — 칩이 세 줄을 넘으면 이 칸 안에서 스크롤한다 */
const controlRef = ref<HTMLElement | null>(null)
/** 칩을 지우며 입력칸에 초점을 줄 때 — 칸의 스크롤을 그대로 둔다 (onFocus 가 맨 아래로 옮기지 않게) */
let keepScrollOnFocus = false
/** 입력칸에 초점이 있다 — 그동안만 추천 목록을 연다 */
const focused = ref<boolean>(false)
/** Esc 로 닫았다 — 다시 치면 연다 */
@@ -260,13 +268,24 @@ function onPaste(event: ClipboardEvent) {
commit(`${draft.value}${text}`)
}
/** 입력칸에 보이는 글 — 한글 조합 중인 글자도 (v-model 은 조합 중의 input 을 건너뛴다) */
/**
* 칩 · 입력 칸을 맨 아래로 — 입력칸은 늘 칸의 끝에 있으므로 칩이 세 줄을 넘어 칸 안에서 스크롤할 때 입력 자리가 보인다.
* 넘치지 않으면 아무 일도 없다
*/
function revealInput() {
const control = controlRef.value
if (control) control.scrollTop = control.scrollHeight
}
/** 입력칸에 보이는 글 — 한글 조합 중인 글자도 (v-model 은 조합 중의 input 을 건너뛴다). 치는 자리가 보이게 칸을 맨 아래로 */
function onTextInput(event: Event) {
query.value = (event.target as HTMLInputElement).value
revealInput()
}
function onFocus() {
focused.value = true
if (!keepScrollOnFocus) revealInput()
// 미리 받기가 실패했으면 다시 받아 본다 (받았거나 받는 중이면 아무것도 하지 않는다)
suggestions?.preload()
}
@@ -282,7 +301,10 @@ function remove(token: RecipientToken) {
'update:modelValue',
props.modelValue.filter((item) => recipientTokenKey(item) !== key),
)
inputRef.value?.focus()
// 초점은 지금처럼 입력칸으로 — 칩이 많아 칸 안에서 스크롤하는 중이면 그 자리를 그대로 둔다 (가운데 칩을 이어 지우게)
keepScrollOnFocus = true
inputRef.value?.focus({ preventScroll: true })
keepScrollOnFocus = false
}
function focus() {
@@ -321,6 +343,15 @@ watch(query, (text) => {
suggestions?.search(text)
})
// 칩이 늘면(확정 · 추천 · 그룹 · 붙여 넣기 · 저장본) 그린 뒤 칸을 맨 아래로 — 입력 자리가 보이게. 줄 때(지우기)는 그대로 둔다
watch(
() => props.modelValue.length,
(count, previous) => {
if (count > previous) revealInput()
},
{ flush: 'post' },
)
// 추천이 늘거나 줄면(서버 결과 · 이미 넣은 주소) 고른 자리를 목록 안에 둔다
watch(
() => options.value.length,
@@ -344,7 +375,10 @@ defineExpose({ commit, focus, clearDraft, hasDraft })
:for="inputId"
class="recipient-field__label"
>{{ label }}</label>
<div class="recipient-field__control">
<div
ref="controlRef"
class="recipient-field__control"
>
<span
v-for="token in modelValue"
:key="recipientTokenKey(token)"
@@ -444,13 +478,23 @@ defineExpose({ commit, focus, clearDraft, hasDraft })
color: var(--color-text-secondary);
}
/*
* 칩 · 입력 — 칩이 세 줄을 넘으면 이 칸 안에서 스크롤한다 (--size-recipient-chips-max 96 + 위 여백 4 = 100 · 2026-11-10 사용자 보고).
* 위 · 좌우에 안쪽 여백 4 를 두고 바깥 여백을 같은 만큼 당긴다 — 자리 · 폭 · 줄바꿈은 그대로이고, 칸 가장자리 칩의 ✕ 초점 고리(바깥 4)가
* 잘리지 않는다. 아래는 당기지 않는다 — 입력이 든 마지막 줄(34)은 칩(28)보다 높아 고리가 들고, 줄의 아래 선(초점 선)을 덮지 않게
*/
.recipient-field__control {
flex: 1 1 20rem;
flex: 1 1 calc(20rem + var(--space-4) * 2);
min-width: 0;
max-height: calc(var(--size-recipient-chips-max) + var(--space-4));
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-6);
margin: calc(var(--space-4) * -1) calc(var(--space-4) * -1) 0;
padding: var(--space-4) var(--space-4) 0;
overflow-x: hidden;
overflow-y: auto;
}
.recipient-field__input {
@@ -222,6 +222,22 @@ describe('EditorToolbar (본문 서식 툴바)', () => {
expect(document.activeElement?.getAttribute('data-tool')).toBe('more')
})
it('메뉴 · 팝오버가 열린 동안만 툴바를 쓰기 화면 아래에 붙은 [보내기] 줄(z-index 3) 위로 올린다 — editor-toolbar--popup-open (2026-11-10)', async () => {
const view = mountEditor()
const toolbar = view.get('.editor-toolbar')
expect(toolbar.classes()).not.toContain('editor-toolbar--popup-open')
await tool(view, 'more').trigger('click')
expect(toolbar.classes()).toContain('editor-toolbar--popup-open')
await view.get('[role="menu"][aria-label="서식 더보기"]').trigger('keydown', { key: 'Escape' })
await nextTick()
expect(toolbar.classes()).not.toContain('editor-toolbar--popup-open')
await tool(view, 'textColor').trigger('click')
expect(toolbar.classes()).toContain('editor-toolbar--popup-open')
})
it('마우스로 열면 메뉴 자체에 초점(고른 항목에 키보드 고리가 뜨지 않게) — 아래 화살표로 항목에 들어간다. 키보드로 열면 켜진 항목에', async () => {
const view = mountEditor()
editorOf(view.element).commands.focus()
@@ -341,7 +341,7 @@ defineExpose({
<template>
<div
ref="rootRef"
class="editor-toolbar"
:class="['editor-toolbar', { 'editor-toolbar--popup-open': popup !== null }]"
role="toolbar"
aria-label="본문 서식"
:aria-controls="MAIL_EDITOR_BODY_ID"
@@ -676,6 +676,14 @@ defineExpose({
background: var(--color-surface);
}
/*
* 메뉴 · 팝오버를 연 동안은 쓰기 화면 아래에 붙은 단추 줄(z-index 3 — ComposeFooter) 위에 — 툴바 아래로 연 메뉴가 그 줄에 가리지 않게.
* 닫으면 2 로 돌아가 툴바가 그 줄 밑으로 지나간다 (2026-11-10)
*/
.editor-toolbar--popup-open {
z-index: 4;
}
.editor-toolbar__group {
flex: 0 0 auto;
display: flex;
@@ -367,6 +367,16 @@ defineExpose({
overflow-y: auto;
}
/*
* 넓은 화면(70rem 이상)에서는 쓰기 화면이 화면 높이에 묶여 따로 스크롤한다 — 자리가 모자라면 편집 영역이 200 까지 먼저 줄어
* 쓰기 칸 전체가 화면 안에 든다(바깥 스크롤 + 본문 스크롤의 이중 스크롤을 막는다). 자리가 넉넉하면 지금처럼 늘어난다 (ComposePage · 2026-11-10)
*/
@media (min-width: 70rem) {
.mail-editor__frame {
min-height: var(--size-editor-body-min-fit);
}
}
/* 끌어다 놓는 동안의 덮개 — 2px 점선(포커스 선은 실선)이라 모양이 다르다 */
.mail-editor__drop {
position: absolute;
@@ -0,0 +1,216 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import { useFocusAboveFooter } from './useFocusAboveFooter'
// 아래에 붙은 [보내기] 줄 밑에 가린 칸으로 초점이 오면 가린 만큼 올린다 (2026-11-10 — 좁은 화면에서 Tab 으로 간 [파일 첨부]가 그 줄에 가렸다).
// jsdom 은 배치를 하지 않는다 — 상자 크기를 정해 주고, 스크롤 상자의 scrollTop 을 잡아 본다
function rectOf(top: number, bottom: number): DOMRect {
return { top, bottom, height: bottom - top, left: 0, right: 0, width: 0, x: 0, y: top, toJSON: () => ({}) } as DOMRect
}
/** scrollTop 을 담는 칸 — jsdom 은 스크롤하지 않으므로 값만 붙잡는다 */
function trackScroll(element: Element, start = 0): { value: number } {
const box = { value: start }
Object.defineProperty(element, 'scrollTop', {
configurable: true,
get: () => box.value,
set: (value: number) => {
box.value = value
},
})
return box
}
describe('useFocusAboveFooter — [보내기] 줄 밑에 초점이 가리지 않게', () => {
let clock: ManualClock
let frames: (() => void)[]
let scroller: HTMLElement
let row: HTMLElement
let input: HTMLInputElement
let attach: HTMLButtonElement
let footer: HTMLElement
let send: HTMLButtonElement
let rects: Map<Element, DOMRect>
beforeEach(() => {
clock = createManualClock(10_000)
frames = []
// 넓은 화면 — 쓰기 칸이 스크롤 상자다 (overflow-y: auto · 넘친다)
scroller = document.createElement('main')
scroller.style.overflowY = 'auto'
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1200 })
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 600 })
row = document.createElement('div')
row.className = 'compose-field'
input = document.createElement('input')
row.append(input)
attach = document.createElement('button')
footer = document.createElement('div')
send = document.createElement('button')
footer.append(send)
scroller.append(row, attach, footer)
document.body.append(scroller)
rects = new Map<Element, DOMRect>([
[scroller, rectOf(56, 656)],
[footer, rectOf(591, 656)],
[row, rectOf(560, 604)],
[input, rectOf(565, 599)],
[attach, rectOf(600, 644)],
[send, rectOf(601, 645)],
])
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
return rects.get(this) ?? rectOf(0, 0)
})
})
afterEach(() => {
vi.restoreAllMocks()
document.body.innerHTML = ''
})
function setup() {
return useFocusAboveFooter({ footer: () => footer, clock, nextFrame: (callback) => frames.push(callback) })
}
/** 초점을 옮기고 그 focusin 을 넘긴다 (재는 것은 다음 그림 — frames) */
function focusIn(handler: ReturnType<typeof setup>, element: HTMLElement) {
element.focus()
const event = new FocusEvent('focusin', { bubbles: true })
Object.defineProperty(event, 'target', { value: element })
handler.onFocusIn(event)
}
function pointerDown(handler: ReturnType<typeof setup>, element: Element) {
const event = new Event('pointerdown', { bubbles: true }) as PointerEvent
Object.defineProperty(event, 'target', { value: element })
handler.onPointerDown(event)
}
function runFrames() {
for (const frame of frames.splice(0)) frame()
}
it('키보드로 간 칸이 그 줄에 가렸으면 가린 만큼(+ 8) 쓰기 칸을 올린다 — 줄 전체(.compose-field)를 드러낸다', () => {
const scroll = trackScroll(scroller, 100)
const handler = setup()
focusIn(handler, input)
// 그 자리에서 재지 않는다 — 브라우저가 초점에 맞춰 스크롤한 다음 그림에
expect(scroll.value).toBe(100)
runFrames()
// 줄 아래 604 + 8 − 그 줄의 위 591 = 21
expect(scroll.value).toBe(121)
})
it('줄 밖의 요소([파일 첨부])는 그 요소를 드러낸다', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
focusIn(handler, attach)
runFrames()
expect(scroll.value).toBe(644 + 8 - 591)
})
it('가리지 않았으면 · 아래 줄 안의 단추면 그대로 둔다', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
rects.set(attach, rectOf(500, 544))
focusIn(handler, attach)
focusIn(handler, send)
runFrames()
expect(scroll.value).toBe(0)
})
it('마우스 · 손가락으로 누른 칸은 그대로 둔다 — 누른 채 내용이 움직이면 글이 골라진다. 1초가 지난 뒤의 키보드 초점은 올린다', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
pointerDown(handler, input)
focusIn(handler, input)
runFrames()
expect(scroll.value).toBe(0)
clock.advance(1000)
attach.focus()
focusIn(handler, input)
runFrames()
expect(scroll.value).toBe(21)
})
it('다른 곳을 누른 뒤 코드가 옮긴 초점(보내기 전 검사 → 받는 사람 칸 등)은 올린다', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
pointerDown(handler, send)
focusIn(handler, input)
runFrames()
expect(scroll.value).toBe(21)
})
it('칸이 보이는 자리보다 크면 칸의 위를 맞춘다 — 위가 화면 밖으로 나가지 않게', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
// 쓰기 칸 위 56 · 그 줄 위 591 — 보이는 자리는 535. 칸 300..900
rects.set(row, rectOf(300, 900))
focusIn(handler, input)
runFrames()
// 가린 만큼은 900 + 8 − 591 = 317 이지만 칸의 위가 56 + 8 까지만 올라간다 — 300 − 56 − 8 = 236
expect(scroll.value).toBe(236)
})
it('그린 차례 전에 초점이 다른 곳으로 갔으면 올리지 않는다', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
focusIn(handler, input)
send.focus()
runFrames()
expect(scroll.value).toBe(0)
})
it('쓰기 칸이 스크롤하지 않으면(좁은 화면) 문서를 올린다', () => {
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 600 })
const documentScroller = document.scrollingElement ?? document.documentElement
const scroll = trackScroll(documentScroller, 40)
const handler = setup()
try {
focusIn(handler, attach)
runFrames()
expect(scroll.value).toBe(40 + 644 + 8 - 591)
} finally {
// 문서의 scrollTop 은 다른 시험에 남기지 않는다
Reflect.deleteProperty(documentScroller, 'scrollTop')
}
})
it('쓰기 칸에 단 처리기는 안에서 올라온 focusin · pointerdown 을 받는다 (사건의 target 으로 가른다)', () => {
const scroll = trackScroll(scroller, 0)
const handler = setup()
scroller.addEventListener('focusin', (event) => handler.onFocusIn(event))
scroller.addEventListener('pointerdown', (event) => handler.onPointerDown(event as PointerEvent), { capture: true })
// 누른 [파일 첨부]는 그대로 (focus 가 focusin 을 올려 보낸다)
attach.dispatchEvent(new Event('pointerdown', { bubbles: true }))
attach.focus()
runFrames()
expect(scroll.value).toBe(0)
// 1초 뒤 Tab 으로 온 초점은 올린다 — 그사이 지나간 칸(input)은 초점이 떠나 올리지 않는다
clock.advance(1000)
input.focus()
attach.focus()
runFrames()
expect(scroll.value).toBe(644 + 8 - 591)
})
})
@@ -0,0 +1,79 @@
import type { Clock } from '@/composables/useClock'
// 아래에 붙은 줄(position: sticky — 쓰기 화면의 [보내기] 줄) 밑에 초점이 가리지 않게 한다 (WCAG 2.4.11 — 2026-11-10 사용자 보고의 고침과 함께).
// 브라우저는 초점을 준 요소가 스크롤 상자 안에 있으면 스크롤하지 않는다 — 그 줄 밑에 들어간 요소(좁은 화면에서 Tab 으로 간 [파일 첨부] 등)는
// 가린 채 남는다. 키보드 · 코드(오류 칸으로 옮기는 초점 등)로 초점이 들어오면, 브라우저가 스스로 스크롤한 다음 그림에 재어 가린 만큼 올린다.
// 마우스 · 손가락으로 누른 요소는 보이는 곳을 누른 것이므로 그대로 둔다 — 누른 채 내용이 움직이면 글이 골라진다.
// 드러내는 것은 쓰기 칸의 줄 전체(.compose-field — 받는 사람 줄 · 제목 줄 · 본문 편집 영역)이고, 줄이 보이는 자리보다 크면 줄의 위를 맞춘다.
// 쓰는 곳: 쓰기 화면의 form 에 @focusin · @pointerdown.capture (ComposePage)
/** 드러낸 요소와 아래 줄 사이 · 화면 위와의 틈 (--space-8) */
const FOCUS_GAP_PX = 8
/** 누른 뒤 이 안에 누른 줄로 온 초점은 누른 것으로 본다 — 손가락은 뗄 때 초점이 온다 */
const POINTER_FOCUS_WINDOW_MS = 1000
export interface FocusAboveFooterOptions {
/** 아래에 붙은 줄 */
footer: () => HTMLElement | null
/** 누른 때를 잰다 — 시험은 손으로 돌리는 시계 */
clock: Clock
/** 다음 그림 차례에 부른다 — 기본은 requestAnimationFrame (브라우저가 초점에 맞춰 스크롤한 뒤) */
nextFrame?: (callback: () => void) => void
}
/** 드러낼 칸 — 쓰기 칸의 줄(.compose-field)이면 줄 전체, 아니면 그 요소 */
function boxOf(target: Element): Element {
return target.closest('.compose-field') ?? target
}
/** 아래 줄이 붙어 있는 스크롤 상자 — 넓은 화면은 쓰기 칸, 좁은 화면은 문서 */
function scrollerOf(element: HTMLElement): Element {
for (let node = element.parentElement; node; node = node.parentElement) {
const { overflowY } = getComputedStyle(node)
if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) return node
}
return document.scrollingElement ?? document.documentElement
}
export function useFocusAboveFooter(options: FocusAboveFooterOptions) {
const nextFrame = options.nextFrame ?? ((callback: () => void) => void requestAnimationFrame(() => callback()))
/** 마지막으로 누른 곳과 때 */
let pressed: { target: Node; at: number } | null = null
function onPointerDown(event: PointerEvent) {
if (event.target instanceof Node) pressed = { target: event.target, at: options.clock.now() }
}
/** 방금 이 칸 안을 눌렀다 — 그 누름으로 온 초점이다 */
function pressedInside(box: Element): boolean {
return (
pressed !== null && options.clock.now() - pressed.at < POINTER_FOCUS_WINDOW_MS && box.contains(pressed.target)
)
}
/** 가린 만큼 올린다 — 칸의 위가 보이는 자리 위로 나가지 않을 만큼까지 */
function reveal(target: Element, box: Element, footer: HTMLElement) {
// 그사이 초점이 다른 곳으로 갔거나 화면을 떠났다
if (document.activeElement !== target || !footer.isConnected) return
const rect = box.getBoundingClientRect()
const hidden = rect.bottom + FOCUS_GAP_PX - footer.getBoundingClientRect().top
if (hidden <= 0) return
const scroller = scrollerOf(footer)
const viewTop = scroller === document.scrollingElement ? 0 : scroller.getBoundingClientRect().top
const delta = Math.min(hidden, rect.top - viewTop - FOCUS_GAP_PX)
if (delta > 0) scroller.scrollTop += delta
}
function onFocusIn(event: FocusEvent) {
const target = event.target
if (!(target instanceof Element)) return
const footer = options.footer()
// 아래 줄 안의 단추는 늘 보인다
if (!footer || footer.contains(target)) return
const box = boxOf(target)
if (pressedInside(box)) return
nextFrame(() => reveal(target, box, footer))
}
return { onFocusIn, onPointerDown }
}
@@ -0,0 +1,224 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import footerSource from '@/components/mail/ComposeFooter.vue?raw'
import recipientSource from '@/components/mail/RecipientField.vue?raw'
import suggestionListSource from '@/components/mail/RecipientSuggestionList.vue?raw'
import editorSource from '@/components/mail/editor/MailEditor.vue?raw'
import toolbarSource from '@/components/mail/editor/EditorToolbar.vue?raw'
import { CLOCK } from '@/composables/useClock'
import { useAuthStore } from '@/stores/auth.store'
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
import { flushPromises, installMockApi, ok, restoreApiAdapter } from '@/test-utils/mock-api'
import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
import ComposePage from './ComposePage.vue'
import composeSource from './ComposePage.vue?raw'
// 쓰기 화면 스크롤 (2026-11-10 사용자 보고 — 전 직원을 숨은참조에 넣자 받는 사람 칸이 늘어 쓰기 칸이 넘쳤고, 바깥 스크롤 + 본문 스크롤의
// 이중 스크롤 속에 맨 아래 [보내기] 줄이 화면 밖으로 숨었다. 본문 위에서 휠을 돌리면 본문만 움직였다).
// - [보내기] 줄은 스크롤하는 칸의 아래에 붙는다(sticky) · 받는 사람 칸은 칩 세 줄을 넘으면 칸 안이 스크롤 · 넓은 화면은 본문 편집 영역이 200 까지 줄어든다
// - 겹침 차례: 툴바 2 < [보내기] 줄 3 < 추천 목록 · 메뉴를 연 툴바 4
// jsdom 은 크기를 재지 않는다 — 배치 값은 원본의 style 을 읽고(ContactTransferDialogs.layout.spec 과 같은 읽기), 초점 올리기는 상자 크기를 정해 준다.
// 토큰 값(tokens.css — 받는 사람 칸 96 · 넓은 화면 편집 영역 200)은 시험에서 읽지 못한다(vitest 는 CSS 파일을 비운다 — ?raw 도) — 컴포넌트가 그 토큰을
// 쓰는지만 보고, 실제 폭 · 높이(1440×900 · 1366×768 · 1000×600 · 360×800)는 브라우저로 확인했다 (디자인 README "메일 쓰기 › 받는 사람이 많을 때")
/** 원본 style 의 CSS — 주석은 뺀다 */
function cssOf(source: string): string {
const start = source.indexOf('<style')
const css = start < 0 ? source : source.slice(source.indexOf('>', start) + 1, source.lastIndexOf('</style>'))
return css.replace(/\/\*[\s\S]*?\*\//g, '')
}
/** { 로 여는 블록의 안쪽 — open 은 그 { 의 자리 */
function blockAt(css: string, open: number): { inner: string; end: number } {
let depth = 0
for (let i = open; i < css.length; i += 1) {
if (css[i] === '{') depth += 1
else if (css[i] === '}') {
depth -= 1
if (depth === 0) return { inner: css.slice(open + 1, i), end: i }
}
}
return { inner: '', end: css.length }
}
/** 미디어 쿼리 블록을 뺀 맨 위 단계의 CSS */
function topLevel(css: string): string {
let out = ''
let from = 0
for (let at = css.indexOf('@media', from); at >= 0; at = css.indexOf('@media', from)) {
out += css.slice(from, at)
from = blockAt(css, css.indexOf('{', at)).end + 1
}
return out + css.slice(from)
}
/** 한 규칙의 본문 — media 를 주면 그 미디어 쿼리 안에서, 주지 않으면 맨 위 단계에서 */
function ruleBody(source: string, selector: string, media?: string): string {
const css = cssOf(source)
let scope = topLevel(css)
if (media) {
const at = css.indexOf(`@media ${media}`)
scope = at < 0 ? '' : blockAt(css, css.indexOf('{', at)).inner
}
for (const [, found = '', body = ''] of scope.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
if (found.trim() === selector) return body
}
return ''
}
const WIDE = '(min-width: 70rem)'
describe('쓰기 화면 스크롤 — 원본 style (2026-11-10 사용자 보고)', () => {
it('[보내기] 줄은 스크롤하는 칸의 아래에 붙는다 — sticky · bottom 0 · 흰 바탕, 위 선 · 여백은 그대로', () => {
const footer = ruleBody(footerSource, '.compose__footer')
expect(footer).toMatch(/position:\s*sticky;/)
expect(footer).toMatch(/bottom:\s*0;/)
expect(footer).toMatch(/z-index:\s*3;/)
expect(footer).toMatch(/background:\s*var\(--color-surface\);/)
expect(footer).toMatch(/border-top:\s*var\(--border-width\) solid var\(--color-border\);/)
expect(footer).toMatch(/padding:\s*var\(--space-10\) var\(--content-inline\);/)
})
it('겹침 차례 — 툴바 2 < [보내기] 줄 3 < 추천 목록 · 메뉴를 연 툴바 4 (툴바는 줄 밑으로 지나가고, 툴바의 메뉴와 추천 목록은 줄 위에 뜬다)', () => {
const z = (source: string, selector: string) => Number(ruleBody(source, selector).match(/z-index:\s*(\d+);/)?.[1])
expect(z(toolbarSource, '.editor-toolbar')).toBe(2)
expect(z(footerSource, '.compose__footer')).toBe(3)
expect(z(toolbarSource, '.editor-toolbar--popup-open')).toBe(4)
expect(z(suggestionListSource, '.recipient-suggestions')).toBe(4)
})
it('받는 사람 · 참조 · 숨은참조 — 칩 · 입력 칸은 96(+ 위 여백 4)을 넘으면 그 칸 안에서 스크롤한다', () => {
const control = ruleBody(recipientSource, '.recipient-field__control')
expect(control).toMatch(/max-height:\s*calc\(var\(--size-recipient-chips-max\) \+ var\(--space-4\)\);/)
expect(control).toMatch(/overflow-y:\s*auto;/)
expect(control).toMatch(/overflow-x:\s*hidden;/)
// 안쪽 여백 4(위 · 좌우)를 바깥 여백으로 당긴다 — 자리 · 폭 · 줄바꿈은 그대로
expect(control).toMatch(/margin:\s*calc\(var\(--space-4\) \* -1\) calc\(var\(--space-4\) \* -1\) 0;/)
expect(control).toMatch(/padding:\s*var\(--space-4\) var\(--space-4\) 0;/)
expect(control).toMatch(/flex:\s*1 1 calc\(20rem \+ var\(--space-4\) \* 2\);/)
})
it('넓은 화면은 본문 편집 영역이 300 에서 200 까지 먼저 줄어 쓰기 칸 전체가 화면 안에 든다 — 좁은 화면은 300 그대로', () => {
expect(ruleBody(editorSource, '.mail-editor__frame')).toMatch(/min-height:\s*var\(--size-editor-body-min\);/)
expect(ruleBody(editorSource, '.mail-editor__frame', WIDE)).toMatch(/min-height:\s*var\(--size-editor-body-min-fit\);/)
expect(ruleBody(composeSource, '.compose__editor')).toMatch(
/min-height:\s*calc\(var\(--size-editor-toolbar\) \+ var\(--size-editor-body-min\) \+ var\(--border-width\)\);/,
)
expect(ruleBody(composeSource, '.compose__editor', WIDE)).toMatch(
/min-height:\s*calc\(var\(--size-editor-toolbar\) \+ var\(--size-editor-body-min-fit\) \+ var\(--border-width\)\);/,
)
expect(ruleBody(composeSource, '.compose', WIDE)).toMatch(/overflow-y:\s*auto;/)
})
it('쓰기 칸은 위치를 잡는다 — 안의 화면 낭독 전용 요소(.visually-hidden)가 앱 틀을 넘겨 문서까지 스크롤하지 않게', () => {
expect(ruleBody(composeSource, '.compose')).toMatch(/position:\s*relative;/)
})
})
describe('쓰기 화면 — [보내기] 줄 밑에 가린 칸으로 온 초점은 올린다 (WCAG 2.4.11)', () => {
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 지연 로딩한 편집기를 기다리는 시간 — ComposePage.spec 과 같다 (테스트 한도 20초보다 짧게) */
const EDITOR_LOAD_TIMEOUT_MS = 15_000
let wrapper: VueWrapper | null = null
let pinia: Pinia
let clock: ManualClock
let frames: FrameRequestCallback[]
beforeAll(installProseMirrorDom)
beforeAll(async () => {
await import('@/components/mail/editor/MailEditor.vue')
}, EDITOR_LOAD_TIMEOUT_MS)
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated(USER)
clock = createManualClock(1_000_000)
frames = []
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
frames.push(callback)
return frames.length
})
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
vi.restoreAllMocks()
restoreApiAdapter()
Reflect.deleteProperty(document.scrollingElement ?? document.documentElement, 'scrollTop')
document.body.innerHTML = ''
})
async function mountCompose() {
installMockApi(() => ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } }))
const router = createTestRouter()
await router.push('/mail/compose')
await router.isReady()
wrapper = mount(ComposePage, {
attachTo: document.body,
global: { plugins: [pinia, router], provide: { ...routeViewProvide(router), [CLOCK as symbol]: clock } },
})
await flushPromises()
await vi.waitFor(
() => {
if (!wrapper?.find('.ProseMirror').exists()) throw new Error('편집기를 불러오는 중')
},
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
)
await flushPromises()
return wrapper
}
function rectOf(top: number, bottom: number): DOMRect {
return { top, bottom, height: bottom - top, left: 0, right: 0, width: 0, x: 0, y: top, toJSON: () => ({}) } as DOMRect
}
function runFrames() {
for (const frame of frames.splice(0)) frame(0)
}
it('좁은 화면(문서가 스크롤) — Tab 으로 간 제목 줄이 [보내기] 줄에 가렸으면 가린 만큼(+ 8) 문서를 올리고, 누른 칸은 그대로 둔다', async () => {
const page = await mountCompose()
const subject = page.get('#compose-subject').element as HTMLInputElement
const row = subject.closest('.compose-field')
const footer = page.get('.compose__footer').element
// [보내기] 줄 위 500 · 제목 줄 470~514 — 줄 아래 14px 이 가렸다
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
if (this === footer) return rectOf(500, 565)
if (this === row) return rectOf(470, 514)
return rectOf(0, 0)
})
const documentScroller = document.scrollingElement ?? document.documentElement
const scroll = { top: 300 }
Object.defineProperty(documentScroller, 'scrollTop', {
configurable: true,
get: () => scroll.top,
set: (value: number) => {
scroll.top = value
},
})
runFrames()
// 누른 칸은 그대로 — 보이는 곳을 누른 것이다 (누른 채 내용이 움직이면 글이 골라진다)
subject.dispatchEvent(new Event('pointerdown', { bubbles: true }))
subject.focus()
runFrames()
expect(scroll.top).toBe(300)
// 1초 뒤 키보드로 다시 오면 올린다 — 514 + 8 − 500 = 22
clock.advance(1000)
subject.blur()
subject.focus()
runFrames()
expect(scroll.top).toBe(322)
// [보내기] 줄 안의 단추는 늘 보인다 — 올리지 않는다
;(page.get('.compose__send').element as HTMLButtonElement).focus()
runFrames()
expect(scroll.top).toBe(322)
})
})
+38 -2
View File
@@ -23,6 +23,11 @@
// 그렇게 맞추지 못한 옛 주소(방문 기록의 ?draft=<옛 id>)로 돌아오면 이 탭이 마지막으로 저장한 저장본을 연다 (useComposeDraftLink).
// - 휴지통 · 되돌리기는 useDraftTrash, 머리 · 아래 단추 줄 · 칸 안내 띠는 ComposeHeader · ComposeFooter · ComposeAlert.
// 받는 사람 자동 완성: 열 때 추천을 한 번 받아 두고 세 칸이 함께 쓴다 (useRecipientSuggestions → RecipientField).
// 스크롤 (2026-11-10 사용자 보고 — 받는 사람이 많으면 바깥 · 본문 이중 스크롤에 [보내기] 줄이 화면 밖으로 숨었다):
// - [보내기] 줄은 스크롤하는 칸(넓은 화면은 이 쓰기 칸, 좁은 화면은 문서)의 아래에 붙어 늘 보인다 (ComposeFooter — sticky)
// - 받는 사람 · 참조 · 숨은참조는 칩이 세 줄을 넘으면 그 칸 안에서 스크롤한다 (RecipientField)
// - 넓은 화면은 본문 편집 영역이 300 에서 200 까지 먼저 줄어 보통 높이의 화면에서는 쓰기 칸 전체가 화면 안에 든다 (아래 style · MailEditor)
// - 그 줄에 가린 칸으로 키보드 · 코드의 초점이 오면 가린 만큼 올린다 (useFocusAboveFooter)
// 본문은 서식 편집기(TipTap) — 이 화면에서만 불러온다(지연 로딩). 보낼 때 서식 본문(bodyHtml)과 같은 내용의 텍스트(body)를 함께 보낸다.
// 편집기 청크를 못 받으면 본문 자리에 사전 문구(SYS_001)와 "다시 시도"를 보인다 (ComposeEditorLoadError).
// 본문에 붙여 넣거나 끌어다 놓은 파일(이미지 포함)은 첨부로 올린다 — 본문의 그림은 서명 그림뿐이다 (사용자 확정).
@@ -91,6 +96,7 @@ import { useComposeDraftLink } from '@/composables/useComposeDraftLink'
import { useComposeLargeFiles } from '@/composables/useComposeLargeFiles'
import { useDraftSaving, type DraftSaveRejected, type DraftSaveRequest } from '@/composables/useDraftSaving'
import { useDraftTrash } from '@/composables/useDraftTrash'
import { useFocusAboveFooter } from '@/composables/useFocusAboveFooter'
import { useLeaveConfirm } from '@/composables/useLeaveConfirm'
import { useMail } from '@/composables/useMail'
import { RECIPIENT_SUGGESTIONS, useRecipientSuggestions } from '@/composables/useRecipientSuggestions'
@@ -325,6 +331,19 @@ const subjectInputRef = ref<HTMLInputElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const attachButtonRef = ref<HTMLButtonElement | null>(null)
const attachmentListRef = ref<InstanceType<typeof ComposeAttachmentGroups> | null>(null)
const footerRef = ref<InstanceType<typeof ComposeFooter> | null>(null)
/**
* 아래에 붙은 [보내기] 줄 밑에 초점이 가리지 않게 (2026-11-10) — 키보드 · 코드로 초점이 들어온 칸이 그 줄에 가렸으면 가린 만큼 올린다.
* 줄은 넘쳐도 늘 보이도록 아래에 붙어 있다 (ComposeFooter · useFocusAboveFooter)
*/
const { onFocusIn: revealFocused, onPointerDown: notePointerDown } = useFocusAboveFooter({
footer: () => {
const element: unknown = footerRef.value?.$el
return element instanceof HTMLElement ? element : null
},
clock,
})
function queryId(value: unknown): string | null {
return typeof value === 'string' && value !== '' ? value : null
@@ -1265,6 +1284,8 @@ watch(
aria-labelledby="compose-title"
novalidate
@submit.prevent="onSubmit"
@focusin="revealFocused"
@pointerdown.capture="notePointerDown"
>
<ComposeHeader
:title="title"
@@ -1423,6 +1444,7 @@ watch(
</div>
<ComposeFooter
ref="footerRef"
:can-send="canSend"
:blocked-reason="sendBlock?.text ?? null"
:sending="sending"
@@ -1466,7 +1488,12 @@ watch(
</template>
<style scoped>
/*
* 위치를 잡은 칸 — 안의 화면 낭독 전용 요소(.visually-hidden — 파일 고르기 칸 등)가 이 칸 안에 든다.
* 잡지 않으면 화면 전체가 기준이 되어 넓은 화면에서 앱 틀(100vh)이 넘치고 문서까지 스크롤했다 (2026-11-10 — 이중 스크롤의 한 갈래)
*/
.compose {
position: relative;
flex: 999 1 35rem;
min-width: 0;
display: flex;
@@ -1525,7 +1552,7 @@ watch(
}
/*
* 본문 서식 편집기 자리 — 툴바(52) + 편집 영역(300) + 아래 선.
* 본문 서식 편집기 자리 — 툴바(52) + 편집 영역(300 — 넓은 화면에서 자리가 모자라면 200 까지, 아래 미디어 쿼리) + 아래 선.
* 편집기를 내려받는 동안에도 같은 높이를 둔다. 포커스는 편집 영역의 아래 선이다 (.compose-field, styles/base.css).
*/
.compose__editor {
@@ -1574,10 +1601,19 @@ watch(
color: var(--color-text-muted);
}
/* 넓은 화면에서는 쓰기 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준) */
/*
* 넓은 화면에서는 쓰기 칸만 따로 스크롤한다 (tokens.css 화면 폭 기준).
* 쓰기 칸이 화면보다 길어지려 하면 본문 편집 영역이 300 에서 200(--size-editor-body-min-fit)까지 먼저 줄어든다 — 받는 사람이 많아도
* 보통 높이의 화면에서는 쓰기 칸 전체가 화면 안에 들고 본문만 스크롤한다(편집 영역의 같은 값은 MailEditor). 그래도 넘치면 쓰기 칸이 스크롤하고
* [보내기] 줄은 아래에 붙어 있다 (ComposeFooter — 2026-11-10 사용자 보고)
*/
@media (min-width: 70rem) {
.compose {
overflow-y: auto;
}
.compose__editor {
min-height: calc(var(--size-editor-toolbar) + var(--size-editor-body-min-fit) + var(--border-width));
}
}
</style>
@@ -0,0 +1,58 @@
import { afterEach, describe, expect, it } from 'vitest'
import { eventPathHas } from './event-path'
// 사건이 지나간 길 — 앞선 처리기가 target 을 문서에서 떼어 내도 dispatch 때의 길로 가른다 (2026-11-10 — 목록 보기의 고르기 상자)
describe('eventPathHas', () => {
afterEach(() => {
document.body.innerHTML = ''
})
/** <a-wrap> 안의 줄(tr 대신 div) — 줄 > 칸 > 단추 > 그림 */
function build() {
document.body.innerHTML =
'<a id="outer"><div id="row"><div id="cell" class="cell"><button id="button"><svg><rect id="picture" /></svg></button></div><span id="text">글</span></div></a>'
const get = (id: string) => document.getElementById(id) as Element
return { outer: get('outer'), row: get('row'), cell: get('cell'), button: get('button'), picture: get('picture'), text: get('text') }
}
/** 줄에 단 처리기에서 본 값 */
function seenByRow(row: Element, target: Element, selector: string, before?: () => void): { path: boolean; closest: boolean } {
let seen = { path: false, closest: false }
const listener = (event: Event) => {
seen = {
path: eventPathHas(event, selector),
closest: event.target instanceof Element && event.target.closest(selector) !== null,
}
}
row.addEventListener('click', listener)
if (before) target.parentElement?.closest('.cell')?.addEventListener('click', before)
target.dispatchEvent(new MouseEvent('click', { bubbles: true }))
row.removeEventListener('click', listener)
return seen
}
it('그림을 누른 click — 단추의 처리기 뒤 그림이 문서에서 떨어져도 길에는 단추가 있다 (closest 는 비었다)', () => {
const { row, picture } = build()
const seen = seenByRow(row, picture, 'button', () => picture.remove())
expect(picture.isConnected).toBe(false)
expect(seen).toEqual({ path: true, closest: false })
})
it('칸의 빈자리 · 칸 밖은 그 선택자가 길에 없다', () => {
const { row, cell, text } = build()
expect(seenByRow(row, cell, 'button').path).toBe(false)
expect(seenByRow(row, cell, '.cell').path).toBe(true)
expect(seenByRow(row, text, 'button, .cell').path).toBe(false)
})
it('사건을 받은 요소(currentTarget)까지만 본다 — 줄을 감싼 바깥 링크는 세지 않는다', () => {
const { row, text } = build()
expect(seenByRow(row, text, 'a').path).toBe(false)
})
})
+14
View File
@@ -0,0 +1,14 @@
// 사건이 지나간 길 — dispatch 할 때 정해져, 앞선 처리기가 화면을 다시 그려 target 이 문서에서 떨어져도 그대로다.
// 줄 전체에 click 을 걸고 "단추 · 링크를 눌렀는가"를 가르는 곳(목록 보기의 줄 — MessageTableRow · ConversationTableRow)은
// target.closest 대신 이것으로 본다 (2026-11-10 사용자 보고 — 목록 보기에서 고르기 상자 가운데를 누르면 target 은 상자 그림(<rect>)인데,
// 단추의 처리기가 고르기를 바꾸자 처리기 사이(microtask)에 Vue 가 다시 그려 그 그림을 떼어 냈다. 줄의 처리기에서 closest('button') 이
// 비어 줄을 누른 것으로 보고 메일까지 열었다)
/** 사건이 지나간 길에서 그 사건을 받은 요소(currentTarget)까지 — 선택자에 맞는 요소가 있었는가 */
export function eventPathHas(event: Event, selector: string): boolean {
for (const node of event.composedPath()) {
if (node instanceof Element && node.matches(selector)) return true
if (node === event.currentTarget) return false
}
return false
}
+12
View File
@@ -252,6 +252,18 @@
--size-editor-size-button: 3.5rem; /* 56px — 글자 크기 단추 */
--size-editor-toolbar: 3.25rem; /* 52px — 툴바 높이 (위아래 4) */
--size-editor-body-min: 18.75rem; /* 300px — 본문 편집 영역의 최소 높이 (편집기를 불러오는 동안의 자리도 같다) */
/*
* 200px — 넓은 화면(70rem 이상 — 쓰기 칸이 화면 높이에 묶여 따로 스크롤한다)에서 쓰기 칸이 화면보다 길어지려 할 때 본문 편집 영역이 줄어드는 한계.
* 자리가 넉넉하면 지금처럼 300 이상으로 늘어나고, 받는 사람이 많은 낮은 화면에서만 이 값까지 줄어 쓰기 칸 전체가 화면 안에 든다 —
* 그 밑으로는 쓰기 칸이 스크롤한다 (2026-11-10 사용자 보고 — 바깥 스크롤 + 본문 스크롤의 이중 스크롤)
*/
--size-editor-body-min-fit: 12.5rem;
/*
* 96px — 받는 사람 · 참조 · 숨은참조 칸(칩 + 입력)이 보이는 최대 높이: 칩 28 · 입력 34 · 줄 사이 4 로 맨 위에서는 칩 세 줄,
* 맨 아래(입력 자리)에서는 칩 두 줄 + 입력 줄이 줄 사이에서 끊긴다 — 반쯤 잘린 칩이 보이지 않게 (칸의 위 여백 4 는 RecipientField 가 더한다).
* 넘으면 그 칸 안에서 스크롤한다 (2026-11-10 사용자 보고 — 전 직원을 숨은참조에 넣자 칸이 늘어 [보내기]가 화면 밖으로 숨었다)
*/
--size-recipient-chips-max: 6rem;
--editor-toolbar-inline: 1.25rem; /* 20px — 툴바 좌우 여백. 버튼 안 12 를 더하면 첫 아이콘이 32px 선 */
--color-editor-pressed-hover: #d5ebed; /* 켜진 버튼에 마우스를 올렸을 때 */
--color-quote-bar: #d0d5dd; /* 원문 인용 막대 — 포커스 선(브랜드색)과 겹치지 않게 회색 */