diff --git a/frontend/src/components/mail/ComposeFooter.vue b/frontend/src/components/mail/ComposeFooter.vue
index c037a3e..37f16af 100644
--- a/frontend/src/components/mail/ComposeFooter.vue
+++ b/frontend/src/components/mail/ComposeFooter.vue
@@ -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) => {
'))
+ 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)
+ })
+})
diff --git a/frontend/src/pages/mail/ComposePage.vue b/frontend/src/pages/mail/ComposePage.vue
index 7145f9f..f04dc17 100644
--- a/frontend/src/pages/mail/ComposePage.vue
+++ b/frontend/src/pages/mail/ComposePage.vue
@@ -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(null)
const fileInputRef = ref(null)
const attachButtonRef = ref(null)
const attachmentListRef = ref | null>(null)
+const footerRef = ref | 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"
>
diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css
index 5bca981..2c0744b 100644
--- a/frontend/src/styles/tokens.css
+++ b/frontend/src/styles/tokens.css
@@ -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; /* 원문 인용 막대 — 포커스 선(브랜드색)과 겹치지 않게 회색 */