fix(frontend): 같은 대화 칸의 안 읽은 줄을 굵게 · 패널 [조건 지우기]는 칸만 비운다

2026-11-07 사용자가 사용자 관점 점검의 보드 바꾸기 제안을 추천대로 정했다.
- 같은 대화 칸: 안 읽은 줄을 목록 줄처럼 굵게 그리고 화면 낭독에 "안 읽음, "을
  붙인다. 이 탭이 바꾼 읽음(펼침 · 읽기 화면 · 목록 · 여러 통 처리 · 모두 읽음)은
  줄에 바로 적고, 받는 동안 바꾼 것은 받은 값보다 앞세운다. 읽음만 바뀐 소식으로는
  다시 받지 않는다(메일을 열 때마다 다시 받게 되므로).
- 여러 통 처리가 실패하면 바꾼 표시만 되돌린다(중요 표시 실패가 읽음을 덮지 않게).
- 조건 패널의 [조건 지우기]는 칸만 비우고 패널을 열어 둔다(찾지 않는다). 조건만
  걸린 검색에서 비운 채 [찾기]를 누르면 검색을 닫는다.
- 검색 결과 미리 보기 줄의 강조는 글자를 --color-text-secondary 로 진하게 한다
  (노랑 위 대비 4.05:1 → 6.3:1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 10:49:27 +09:00
parent cc4f550044
commit 438dac8e01
21 changed files with 668 additions and 53 deletions
@@ -4,11 +4,14 @@
// - 다른 메일: 단추(aria-expanded · 펼쳤을 때 aria-controls) — 누르면 그 자리에서 펼치고(ConversationMessagePanel) 다시 누르면 접는다.
// 펼친 안의 [다시 시도]가 사라지면 칸이 초점을 이 단추(data-conversation-toggle)로 돌려준다 (MessageConversation)
// - 붙잡힌 메일(보내기 취소 · 예약 — 9단계)은 시각 자리에 목록과 같은 "보내는 중" · "예약 · …"(청록 굵게)
// - 안 읽은 메일(row.unread — 2026-11-07)은 목록 줄(MessageListItem)과 같은 굵기 · 색: 이름 650, 미리 보기 · 시각 600(목록 줄의 제목 · 시각 자리).
// 화면 낭독은 이름 앞에 목록과 같은 "안 읽음, "(MAIL_LIST_ROW_TEXT). 지금 보는 메일의 줄도 같다
// 뱃지 · 알약 없이 글로만 보인다. 검색 강조는 넣지 않는다
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import ConversationMessagePanel from '@/components/mail/ConversationMessagePanel.vue'
import { MAIL_LIST_ROW_TEXT } from '@/shared/constants/mail'
import { MAIL_CONVERSATION_TEXT } from '@/shared/constants/mail-conversation'
import type { ConversationRow } from '@/shared/utils/mail-conversation'
import type { ConversationDetailState } from '@/stores/mail.store'
@@ -40,7 +43,7 @@ const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
<template>
<li
class="conversation-row"
:class="['conversation-row', { 'is-unread': row.unread }]"
:data-conversation-row="row.message.id"
>
<div
@@ -52,7 +55,12 @@ const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
:class="['conversation-row__avatar', { 'conversation-row__avatar--mine': row.sender.mine }]"
aria-hidden="true"
>{{ row.sender.initial }}</span>
<span class="conversation-row__who">{{ row.sender.name }}</span>
<span class="conversation-row__who">
<span
v-if="row.unread"
class="visually-hidden"
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ row.sender.name }}
</span>
<span class="conversation-row__current">{{ MAIL_CONVERSATION_TEXT.current }}</span>
<time
:class="['conversation-row__time', { 'is-pending': row.pending }]"
@@ -78,7 +86,12 @@ const panelId = computed(() => `conversation-panel-${props.row.message.id}`)
:class="['conversation-row__avatar', { 'conversation-row__avatar--mine': row.sender.mine }]"
aria-hidden="true"
>{{ row.sender.initial }}</span>
<span class="conversation-row__who">{{ row.sender.name }}</span>
<span class="conversation-row__who">
<span
v-if="row.unread"
class="visually-hidden"
>{{ MAIL_LIST_ROW_TEXT.unreadPrefix }}</span>{{ row.sender.name }}
</span>
<span
v-if="row.sender.mailboxName"
class="conversation-row__mailbox"
@@ -200,8 +213,21 @@ button.conversation-row__line:focus-visible {
font-variant-numeric: tabular-nums;
}
/* 붙잡힌 메일 — 목록의 "보내는 중" · "예약 · …"과 같다 (디자인 원본 MailSendLater) */
.conversation-row__time.is-pending {
/* 안 읽음 — 뱃지 대신 굵기 · 색으로만, 목록 줄(MessageListItem)과 같은 값 (미리 보기가 목록 줄의 제목 자리다) */
.conversation-row.is-unread .conversation-row__who {
font-weight: 650;
color: var(--color-text);
}
.conversation-row.is-unread .conversation-row__preview,
.conversation-row.is-unread .conversation-row__time {
font-weight: 600;
color: var(--color-text);
}
/* 붙잡힌 메일 — 목록의 "보내는 중" · "예약 · …"과 같다 (디자인 원본 MailSendLater) — 안 읽음이어도 */
.conversation-row__time.is-pending,
.conversation-row.is-unread .conversation-row__time.is-pending {
font-weight: 600;
color: var(--color-brand-strong);
}
@@ -37,7 +37,10 @@ const segments = computed(() => highlightSegments(props.text, props.ranges))
</template>
<style scoped>
/* 맞은 말 — 노랑 · 글자색 그대로 · 모서리 2 · 좌우 1 (디자인 원본 Search14) */
/*
* 맞은 말 — 노랑 · 글자색 그대로 · 모서리 2 · 좌우 1 (디자인 원본 Search14).
* 흐린 글자 위면 부르는 쪽이 글자색을 진하게 한다 — 검색 결과의 미리 보기 줄(MessageListItem — 대비 4.5:1, 2026-11-07)
*/
.search-mark {
padding: 0 var(--space-1);
border-radius: calc(var(--radius-xs) / 2);
@@ -465,22 +465,109 @@ describe('MailSearchBox — [조건] · 「조건으로 찾기」 (14단계 디
expect(push).not.toHaveBeenCalled()
})
it('패널의 [조건 지우기] — 걸린 조건을 빼고 다시 찾는다. 검색어도 없으면 검색을 닫는다', async () => {
useMailStore().rememberSearchOrigin({ mailboxId: 'inbox', location: '/mail/inbox' })
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox&subject=${encodeURIComponent('견적')}`)
await openPanel(wrapper)
describe('패널의 [조건 지우기] — 패널의 칸만 비운다 (2026-11-07 사용자 관점 점검)', () => {
const textValues = (wrapper: VueWrapper) =>
['from', 'to', 'subject', 'body'].map((name) => (field(wrapper, name).element as HTMLInputElement).value)
await wrapper.get('[data-search-conditions-clear]').trigger('click')
await flushPromises()
it('칸 · 기간 · 날짜 · 첨부 체크를 비우고 패널은 열어 둔다 — 찾지 않고 초점은 그 단추에, 걸린 조건(주소 · [조건])은 그대로', async () => {
const path = `/mail/search?q=${Q}&in=inbox&from=${encodeURIComponent('이도윤')}&subject=${encodeURIComponent('견적')}&since=2026-10-01&until=2026-11-05&attachment=1`
const { wrapper, router, input } = await mountAt(path)
const push = vi.spyOn(router, 'push')
const replace = vi.spyOn(router, 'replace')
await openPanel(wrapper)
expect(textValues(wrapper)).toEqual(['이도윤', '', '견적', ''])
expect((field(wrapper, 'period').element as HTMLSelectElement).value).toBe('custom')
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
const clear = wrapper.get<HTMLButtonElement>('[data-search-conditions-clear]')
clear.element.focus()
await clear.trigger('click')
await flushPromises()
await router.replace('/mail/search?in=inbox&attachment=1')
await flushPromises()
await openPanel(wrapper)
await wrapper.get('[data-search-conditions-clear]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(textValues(wrapper)).toEqual(['', '', '', ''])
expect((field(wrapper, 'period').element as HTMLSelectElement).value).toBe('all')
expect(wrapper.find('[data-search-condition="since"]').exists()).toBe(false)
expect((field(wrapper, 'attachment').element as HTMLInputElement).checked).toBe(false)
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
expect(document.activeElement).toBe(clear.element)
// 찾지 않는다 — 주소 · 검색 칸 · [조건]의 "조건이 걸려 있음"은 그대로
expect(router.currentRoute.value.fullPath).toBe(path)
expect(push).not.toHaveBeenCalled()
expect(replace).not.toHaveBeenCalled()
expect(input.element.value).toBe('회의')
expect(wrapper.get('[data-search-conditions-toggle]').text()).toContain('조건이 걸려 있음')
// 다시 눌러도 그대로다 (비운 칸)
await clear.trigger('click')
await flushPromises()
expect(textValues(wrapper)).toEqual(['', '', '', ''])
expect(document.activeElement).toBe(clear.element)
})
it('칸 아래 알림(날짜가 거꾸로)도 지운다', async () => {
const { wrapper } = await mountAt('/mail/inbox')
await openPanel(wrapper)
await field(wrapper, 'period').setValue('custom')
await field(wrapper, 'since').setValue('2026-11-05')
await field(wrapper, 'until').setValue('2026-10-01')
await wrapper.get('[data-search-conditions-apply]').trigger('click')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
await wrapper.get('[data-search-conditions-clear]').trigger('click')
await flushPromises()
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect((field(wrapper, 'period').element as HTMLSelectElement).value).toBe('all')
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
})
it('[찾기] 없이 닫으면 아무것도 바뀌지 않는다 — 다시 열면 걸린 조건으로 다시 채운다', async () => {
const path = `/mail/search?q=${Q}&in=inbox&from=${encodeURIComponent('이도윤')}&attachment=1`
const { wrapper, router } = await mountAt(path)
await openPanel(wrapper)
await wrapper.get('[data-search-conditions-clear]').trigger('click')
await flushPromises()
await field(wrapper, 'to').trigger('keydown', { key: 'Escape' })
await flushPromises()
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
expect(router.currentRoute.value.fullPath).toBe(path)
await openPanel(wrapper)
expect(textValues(wrapper)).toEqual(['이도윤', '', '', ''])
expect((field(wrapper, 'attachment').element as HTMLInputElement).checked).toBe(true)
})
it('비운 뒤 [찾기] — 검색어가 있으면 조건만 빼고 다시 찾고, 조건만 걸린 검색이면 검색을 닫는다(조건 줄의 [조건 지우기]와 같다)', async () => {
useMailStore().rememberSearchOrigin({ mailboxId: 'inbox', location: '/mail/inbox' })
const { wrapper, router } = await mountAt(`/mail/search?q=${Q}&in=inbox&subject=${encodeURIComponent('견적')}`)
await openPanel(wrapper)
await wrapper.get('[data-search-conditions-clear]').trigger('click')
await wrapper.get('[data-search-conditions-apply]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
await router.replace('/mail/search?in=inbox&attachment=1')
await flushPromises()
await openPanel(wrapper)
await wrapper.get('[data-search-conditions-clear]').trigger('click')
await wrapper.get('[data-search-conditions-apply]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
expect(wrapper.find('[data-search-conditions]').exists()).toBe(false)
})
it('검색어도 조건도 없는 [찾기]는 조건이 걸리지 않았으면 아무것도 하지 않는다 — 패널은 그대로', async () => {
const { wrapper, router, input } = await mountAt(`/mail/search?q=${Q}&in=inbox`)
await input.setValue('')
await openPanel(wrapper)
await wrapper.get('[data-search-conditions-apply]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(`/mail/search?q=${Q}&in=inbox`)
expect(wrapper.find('[data-search-conditions]').exists()).toBe(true)
})
})
})
+7 -10
View File
@@ -6,7 +6,8 @@
// - 검색 화면에서는 칸에 지금 검색어를 보인다 (뒤로 가기로 결과에 돌아와도)
// - 칸 안 오른쪽(✕ 앞)의 [조건](MailSearchConditionsToggle)이 칸 바로 아래에 「조건으로 찾기」 패널을 연다 (14단계 — 열고 닫기는 useSearchConditionsPopup).
// 패널이 열린 동안 검색 칸의 Enter 는 [찾기]와 같고, 검색 칸의 Esc 는 패널만 닫는다(초점은 검색 칸에 남는다).
// 검색 칸이 비어도 조건만으로 찾는다. 패널이 닫힌 채 누른 Enter 는 걸려 있는 조건을 그대로 둔다
// 검색 칸이 비어도 조건만으로 찾는다. 패널이 닫힌 채 누른 Enter 는 걸려 있는 조건을 그대로 둔다.
// 패널의 [조건 지우기]는 패널의 칸만 비운다(MailSearchConditions) — 걸린 조건은 [찾기]를 누르거나 결과의 조건 줄 [조건 지우기]로 뺀다
import { computed, nextTick, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
@@ -20,7 +21,7 @@ import { hasSearchConditions, type SearchConditions } from '@/shared/utils/mail-
const PANEL_ID = 'mail-search-conditions'
const { isSearching, routeSearch, startSearch, clearConditions, closeSearch } = useMailSearchNavigation()
const { isSearching, routeSearch, startSearch, closeSearch } = useMailSearchNavigation()
const text = ref<string>('')
const inputRef = ref<HTMLInputElement | null>(null)
@@ -68,7 +69,10 @@ function onKeydown(event: KeyboardEvent) {
}
}
/** [찾기] — 검색 칸의 말과 패널의 조건으로. 찾았으면 패널을 닫고 검색 칸으로 (낱말이 너무 많으면 알림만 — 패널은 그대로) */
/**
* [찾기] — 검색 칸의 말과 패널의 조건으로. 찾았으면(조건을 다 비워 검색을 닫았어도) 패널을 닫고 검색 칸으로
* (낱말이 너무 많으면 알림만 — 패널은 그대로)
*/
async function onApply(conditions: SearchConditions) {
if (!(await startSearch(text.value, conditions))) return
closeConditions({ restoreFocus: false })
@@ -76,12 +80,6 @@ async function onApply(conditions: SearchConditions) {
inputRef.value?.focus()
}
/** [조건 지우기] — 걸린 조건이 있으면 빼고 다시 찾는다(검색어도 없으면 검색을 닫는다). 패널을 닫고 [조건]으로 */
function onClear() {
closeConditions()
if (conditionsApplied.value) void clearConditions()
}
// 검색 화면에서는 주소의 검색어를 보이고, 검색을 닫으면(검색 화면을 떠나면) 비운다
watch(
() => (routeSearch.value ? routeSearch.value.q : null),
@@ -145,7 +143,6 @@ watch(
:panel-id="PANEL_ID"
:applied="appliedConditions"
@apply="onApply"
@clear="onClear"
@close="closeConditions()"
/>
</form>
@@ -2,7 +2,8 @@
// 「조건으로 찾기」 패널의 칸 묶음 (14단계 — 디자인 원본 Search14): 보낸 사람 · 받는 사람(참조도) · 제목 · 내용 · 기간(MailSearchPeriodField) · 첨부.
// 이름 76 · 칸, 칸 높이 40 — 필터 조건 칸의 밀도 예외(--size-filter-control)를 다시 쓴다.
// 칸 값은 useSearchConditionsDraft — 처음 그릴 때 걸린 조건(applied)으로 채우고, 부르는 쪽이 take() 로 넘길 조건을 받는다
// (기간이 잘못됐으면 null — 칸 아래에 알리고 그 날짜 칸으로 초점). 글 칸의 Enter 는 submit 으로 알린다 (조합 중 Enter 는 글자 확정 — isImeComposing)
// (기간이 잘못됐으면 null — 칸 아래에 알리고 그 날짜 칸으로 초점), clear() 로 칸을 모두 비운다([조건 지우기]).
// 글 칸의 Enter 는 submit 으로 알린다 (조합 중 Enter 는 글자 확정 — isImeComposing)
import { nextTick, onMounted, ref } from 'vue'
import MailSearchPeriodField from '@/components/mail/MailSearchPeriodField.vue'
@@ -27,7 +28,7 @@ interface Emits {
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { draft, periodProblem, fill, changePeriod, changeDay, take } = useSearchConditionsDraft(useClock())
const { draft, periodProblem, fill, clear, changePeriod, changeDay, take } = useSearchConditionsDraft(useClock())
const TEXT_FIELDS: readonly { field: SearchConditionTextField; label: string; placeholder: string }[] = [
{ field: 'from', label: SEARCH_CONDITIONS_TEXT.from, placeholder: SEARCH_CONDITIONS_TEXT.fromPlaceholder },
@@ -58,6 +59,8 @@ async function takeConditions(): Promise<SearchConditions | null> {
defineExpose({
take: takeConditions,
/** 칸을 모두 비운다 — 기간 · 날짜 · 첨부 체크 · 칸 아래 알림도 (찾지 않는다) */
clear,
/** 첫 칸(보낸 사람)으로 */
focusFirst: () => rootRef.value?.querySelector<HTMLInputElement>('input')?.focus(),
})
@@ -3,7 +3,10 @@
// 칸 묶음(칸 값 · 기간 검사)은 MailSearchConditionFields. 단추 높이 40 — 칸과 같은 밀도 예외(--size-filter-control)
// - 열 때 지금 걸린 조건(applied)으로 칸을 채운다
// - [찾기] · 글 칸의 Enter: 조건을 apply 로 넘긴다. 날짜 고르기가 잘못됐으면(거꾸로 · 고를 수 없는 날짜) 넘기지 않는다 — 칸 아래에 알리고 그 칸으로
// - [조건 지우기]: clear 로 알린다 (부르는 쪽이 걸린 조건을 빼고 패널을 닫는다). Esc: close (부르는 쪽이 [조건]으로 초점을 돌린다 — 조합 중 Esc 는 글자 확정)
// - [조건 지우기]: 패널의 칸만 비운다 — 기간 · 날짜 · 첨부 체크 · 칸 아래 알림까지. 패널은 열린 채이고 찾지 않으며 초점은 그 단추에 남는다.
// 걸린 조건 · 조건 줄 · 결과는 [찾기]를 누를 때까지 그대로이고, [찾기] 없이 닫았다 다시 열면 걸린 조건으로 다시 채운다.
// 걸린 조건을 빼는 일은 결과의 조건 줄 [조건 지우기](MailSearchConditionLine)가 한다 (2026-11-07 사용자 관점 점검)
// - Esc: close (부르는 쪽이 [조건]으로 초점을 돌린다 — 조합 중 Esc 는 글자 확정)
import { computed, ref } from 'vue'
import MailSearchConditionFields from '@/components/mail/MailSearchConditionFields.vue'
@@ -20,7 +23,6 @@ interface Props {
interface Emits {
(e: 'apply', conditions: SearchConditions): void
(e: 'clear'): void
(e: 'close'): void
}
@@ -36,6 +38,11 @@ async function apply() {
if (conditions) emit('apply', conditions)
}
/** [조건 지우기] — 칸만 비운다 (머리말 — 찾지 않고, 패널과 초점은 그대로) */
function clearFields() {
fieldsRef.value?.clear()
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape' || isImeComposing(event)) return
event.preventDefault()
@@ -79,7 +86,7 @@ defineExpose({
type="button"
class="search-conditions__button"
data-search-conditions-clear
@click="emit('clear')"
@click="clearFields"
>
{{ SEARCH_CONDITIONS_TEXT.clear }}
</button>
@@ -104,6 +104,8 @@ function deferred() {
interface Server {
conversation?: (request: MockRequest) => MockResult | Promise<MockResult>
detail?: (request: MockRequest) => MockResult | Promise<MockResult>
/** 읽음 · 중요 표시 — 한 통(PATCH /messages/{id}) · 여러 통(PATCH /messages) */
patch?: (request: MockRequest) => MockResult | Promise<MockResult>
}
describe('MessageConversation — 읽기 화면의 같은 대화', () => {
@@ -132,7 +134,7 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
function serve(server: Server = {}) {
requests = installMockApi((request) => {
if (request.url.endsWith('/conversation')) return server.conversation?.(request) ?? page(THREAD)
if (request.method === 'PATCH') return ok({ ...REPLY, isRead: true })
if (request.method === 'PATCH') return server.patch?.(request) ?? ok({ ...REPLY, isRead: true })
if (request.url === '/mailboxes') return ok({ items: MAILBOXES, pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
if (request.url.startsWith('/messages/')) {
if (server.detail) return server.detail(request)
@@ -218,8 +220,9 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
expect(mine.get('.conversation-row__preview').text()).toBe(ORIGINAL.preview)
expect(mine.get('time').text()).toBe('10월 27일 (화) 오후 2:10')
// 안 읽은 줄이다 — 화면 낭독용 "안 읽음, "이 이름 앞에 든다 (아래 "안 읽은 줄")
const other = rowOf(wrapper, 'm2')
expect(other.get('.conversation-row__who').text()).toBe('이도윤')
expect(other.get('.conversation-row__who').text()).toBe('안 읽음, 이도윤')
expect(other.find('.conversation-row__mailbox').exists()).toBe(false)
expect(other.get('.conversation-row__avatar').classes()).not.toContain('conversation-row__avatar--mine')
expect(other.get('time').text()).toBe('10월 29일 (목) 오전 9:30')
@@ -453,6 +456,102 @@ describe('MessageConversation — 읽기 화면의 같은 대화', () => {
})
})
describe('안 읽은 줄 (2026-11-07 사용자 관점 점검)', () => {
it('안 읽은 줄은 목록 줄처럼 굵게(is-unread) — 화면 낭독은 이름 앞에 목록과 같은 "안 읽음, ", 읽은 줄 · 임시 저장본은 아니다', async () => {
const draft = summary('d1', {
mailboxIds: ['drafts'],
from: { name: '김아라', email: USER.email },
isRead: false,
receivedAt: at(2026, 10, 6, 11, 0),
})
serve({ conversation: () => page([ORIGINAL, REPLY, CURRENT, draft]) })
const { wrapper } = await mountReader()
const unread = rowOf(wrapper, 'm2')
expect(unread.classes()).toContain('is-unread')
expect(unread.get('.conversation-row__who .visually-hidden').element.textContent).toBe('안 읽음, ')
// 펼침 단추의 이름에서 머리글자는 읽지 않으니(aria-hidden) "안 읽음, 이도윤 …"이 된다
expect(unread.get('.conversation-row__who').text()).toBe('안 읽음, 이도윤')
expect(unread.get('.conversation-row__avatar').attributes('aria-hidden')).toBe('true')
for (const id of ['m1', 'm3', 'd1']) {
expect(rowOf(wrapper, id).classes()).not.toContain('is-unread')
expect(rowOf(wrapper, id).find('.conversation-row__who .visually-hidden').exists()).toBe(false)
}
})
it('펼쳐 읽음이 되면 바로 굵기를 푼다 — 읽음으로 바꾸지 못하면 다시 굵게', async () => {
const reply = deferred()
serve({ patch: () => reply.promise })
const { wrapper } = await mountReader()
const row = () => rowOf(wrapper, 'm2')
await row().get('button').trigger('click')
await flushPromises()
// 본문을 받았고 읽음을 보내는 중 — 줄은 이미 읽은 모양
expect(requests.filter((request) => request.method === 'PATCH').map((request) => request.url)).toEqual([
'/messages/m2',
])
expect(row().classes()).not.toContain('is-unread')
expect(row().find('.conversation-row__who .visually-hidden').exists()).toBe(false)
reply.resolve(fail(503, 'MAIL_001'))
await flushPromises()
expect(row().classes()).toContain('is-unread')
expect(row().get('.conversation-row__who').text()).toBe('안 읽음, 이도윤')
})
it('목록 · 여러 통 처리에서 바꾼 읽음 · 안 읽음도 줄에 바로 — 바꾸지 못한 메일은 줄도 되돌린다, 지금 보는 메일도 같다', async () => {
const first = deferred()
let firstBulk: Promise<MockResult> | null = first.promise
serve({
patch: (request) => {
if (request.url !== '/messages') return ok({ ...REPLY, isRead: true })
const reply = firstBulk ?? ok({ updatedIds: (request.data as { ids: string[] }).ids, failedIds: [] })
firstBulk = null
return reply
},
})
const { wrapper, store } = await mountReader()
expect(rowOf(wrapper, 'm2').classes()).toContain('is-unread')
// 목록에서 골라 읽음 표시 — 보내는 동안 줄도 읽은 모양, 서버가 바꾸지 못했으면 다시 굵게
store.toggleCheck('m2')
const marking = store.patchChecked({ isRead: true })
await flushPromises()
expect(rowOf(wrapper, 'm2').classes()).not.toContain('is-unread')
first.resolve(ok({ updatedIds: [], failedIds: ['m2'] }))
await marking
await flushPromises()
expect(rowOf(wrapper, 'm2').classes()).toContain('is-unread')
// 지금 보는 메일을 안 읽음으로 — 그 줄도 굵게 ("지금 보고 있는 메일" 줄)
store.clearChecked()
store.toggleCheck('m3')
await store.patchChecked({ isRead: false })
await flushPromises()
const current = rowOf(wrapper, 'm3')
expect(current.classes()).toContain('is-unread')
expect(current.get('[aria-current="true"] .conversation-row__who').text()).toBe('안 읽음, 이도윤')
})
it('다른 탭 · 기기에서 바꾼 읽음은 다음에 칸을 다시 받을 때 보인다 — 목록을 맞춰도 줄은 그대로', async () => {
let thread = THREAD
serve({ conversation: () => page(thread) })
const { wrapper, store } = await mountReader()
// 다른 탭에서 m2 를 읽었다 — 목록 맞추기가 서버의 값을 가져왔다 (이 탭이 바꾼 것이 아니다)
thread = [ORIGINAL, { ...REPLY, isRead: true }, CURRENT]
store.messages = [{ ...REPLY, isRead: true }, CURRENT]
await flushPromises()
expect(rowOf(wrapper, 'm2').classes()).toContain('is-unread')
// 다시 받으면(새 메일 · 지움 · 옮김으로 통 수가 바뀜 · 쓰기 화면에서 돌아옴) 보인다
await store.refreshConversation()
await flushPromises()
expect(rowOf(wrapper, 'm2').classes()).not.toContain('is-unread')
})
})
describe('받지 못했을 때 — 읽기 화면은 그대로', () => {
it('칸 자리에 "같은 대화를 불러오지 못했습니다." + [다시 시도] · 문의 코드 — 알리지 않는다, 누르면 다시 받는다', async () => {
let failing = true
@@ -2,6 +2,7 @@
// 읽기 화면의 "같은 대화" 칸 (14단계 — 디자인 원본 Thread14 · README "읽기 화면 — 같은 대화", 계약 2절 "대화 — 14단계")
// - 본문 · 첨부 아래 "같은 대화 N통" — N 은 total, 모르면 다 받았을 때의 받은 수, 그것도 모르면 수 없이 "같은 대화". 한 통뿐이면 칸이 없다
// - 테두리 상자 안의 줄(ConversationMessageRow — 48), 오래된 차례. 지금 보는 메일의 줄은 "지금 보고 있는 메일"이고 다른 줄은 그 자리에서 펼친다
// - 안 읽은 줄은 목록 줄처럼 굵게(2026-11-07). 이 탭에서 바꾼 읽음은 바로 바뀌고, 다른 탭 · 기기에서 바꾼 것은 다음에 받을 때 보인다(stores/mail/conversation.ts)
// - 받을 쪽이 남았으면 상자 아래 [더 보기](44) — 받는 동안 "불러오는 중…"(aria-busy, 초점을 잃지 않게 꺼짐 없이)
// - 받지 못하면 칸 자리에 "같은 대화를 불러오지 못했습니다." + [다시 시도] — 읽기 화면은 그대로다. 없는 메일(RES_001)이면 칸 없이.
// 화면 낭독용 상태 영역은 늘 두고 글만 바꾼다(나중에 생긴 영역은 읽어 주지 않는다)
@@ -337,6 +337,14 @@ function onMouseDown(event: MouseEvent) {
color: var(--color-text-muted);
}
/*
* 미리 보기 줄의 맞은 말만 글자색을 진하게 — 흐린 글자(#667085)는 노랑(#FCE7A4) 위에서 4.05:1 이라 4.5:1 에 못 미친다(#4A5363 은 6.3:1, 2026-11-07).
* 제목 줄 · 읽기 화면 · 본문 틀의 강조는 진한 글자 위라 글자색 그대로다 (HighlightedText)
*/
.message-row__preview :deep(.search-mark) {
color: var(--color-text-secondary);
}
/* 비어 있는 값 — "(받는 사람 없음)" · "(제목 없음)" (디자인 원본 ComposeAssist #667085) */
.message-row__who.is-placeholder,
.message-row__subject.is-placeholder {