Skip to content

Commit 4e0efb8

Browse files
authored
feat(blog): 글 읽기 레이아웃 개선 (#124)
1 parent 1956566 commit 4e0efb8

11 files changed

Lines changed: 207 additions & 70 deletions

File tree

blog/ui/components/TableOfContents.test.tsx

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ import { TableOfContents } from './TableOfContents';
44

55
describe('TableOfContents', () => {
66
it('renders native hash links and observes headings', () => {
7-
const item = { id: 'section-1', text: '첫번째 섹션', level: 2 };
7+
const items = [
8+
{ id: 'section-1', text: '첫번째 섹션', level: 2 },
9+
{ id: 'section-2', text: '두번째 섹션', level: 2 },
10+
];
811
const header = document.createElement('h2');
9-
header.id = item.id;
12+
header.id = items[0].id;
1013
document.body.appendChild(header);
1114

1215
const observeSpy = vi.fn();
@@ -24,22 +27,17 @@ describe('TableOfContents', () => {
2427
window.IntersectionObserver =
2528
MockIntersectionObserver as unknown as typeof window.IntersectionObserver;
2629

27-
const { container, getByRole } = render(<TableOfContents items={[item]} />);
30+
const { container, getByRole } = render(<TableOfContents items={items} />);
2831

2932
const link = getByRole('link', { name: '첫번째 섹션' });
3033
fireEvent.click(link);
3134

32-
expect(container.querySelector('nav')).toBeNull();
33-
expect(document.body.querySelector('nav')).toHaveClass('bottom-8');
34-
expect(document.body.querySelector('nav > div')).toHaveClass(
35-
'h-full',
36-
'overflow-y-auto'
35+
expect(container.querySelector('nav')).toHaveClass('ark-article-toc');
36+
expect(container.querySelector('nav > ol')).toHaveClass(
37+
'ark-article-toc-list'
3738
);
38-
expect(document.body.querySelector('ul')).toHaveClass('m-0', 'p-0');
39-
expect(link).toHaveAttribute('href', `#${item.id}`);
40-
expect(link).toHaveStyle({
41-
fontFamily: 'var(--font-sans-emoji)',
42-
});
39+
expect(link).toHaveAttribute('href', `#${items[0].id}`);
40+
expect(link).toHaveClass('ark-article-toc-link');
4341
expect(observeSpy).toHaveBeenCalledWith(header);
4442
});
4543
});

blog/ui/components/TableOfContents/TableOfContents.tsx

Lines changed: 28 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
'use client';
22

33
import { useEffect, useState } from 'react';
4-
import { createPortal } from 'react-dom';
5-
import { clsx } from 'clsx';
64

75
interface TocItem {
86
id: string;
@@ -16,11 +14,6 @@ interface TableOfContentsProps {
1614

1715
export default function TableOfContents({ items }: TableOfContentsProps) {
1816
const [activeId, setActiveId] = useState<string>('');
19-
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
20-
21-
useEffect(() => {
22-
setPortalRoot(document.body);
23-
}, []);
2417

2518
useEffect(() => {
2619
if (items.length === 0) {
@@ -48,41 +41,35 @@ export default function TableOfContents({ items }: TableOfContentsProps) {
4841
return () => observer.disconnect();
4942
}, [items]);
5043

51-
if (items.length === 0 || !portalRoot) return null;
44+
if (items.length < 2) return null;
5245

53-
return createPortal(
54-
<nav
55-
className="fixed right-8 top-20 bottom-8 hidden w-64 xl:block"
56-
aria-label="이 글의 목차"
57-
>
58-
<div className="h-full overflow-y-auto rounded-[var(--radius-md)] bg-[var(--color-grey-50)] p-4">
59-
<h2 className="text-sm font-semibold text-[var(--color-grey-900)] mb-4 sticky top-0 bg-[var(--color-grey-50)] pb-2">
60-
이 글의 목차
61-
</h2>
62-
<ul className="m-0 flex list-none flex-col gap-1 p-0">
63-
{items.map((item) => (
64-
<li key={item.id}>
65-
<a
66-
href={`#${item.id}`}
67-
aria-current={activeId === item.id ? 'location' : undefined}
68-
className={clsx(
69-
'block w-full text-left text-sm py-1.5 px-3 rounded-[6px]',
70-
'transition-colors duration-[var(--duration-150)]',
71-
item.level > 2 && 'pl-6',
72-
activeId === item.id
73-
? 'bg-[var(--color-accent)]/10 text-[var(--color-accent)] font-medium'
74-
: 'text-[var(--color-grey-600)] hover:text-[var(--color-grey-900)] hover:bg-[var(--color-grey-100)]'
75-
)}
76-
style={{ fontFamily: 'var(--font-sans-emoji)' }}
77-
>
78-
{item.text}
79-
</a>
80-
</li>
81-
))}
82-
</ul>
83-
</div>
84-
</nav>,
85-
portalRoot
46+
return (
47+
<nav className="ark-article-toc" aria-label="이 글의 목차">
48+
<p className="ark-article-toc-title">목차</p>
49+
<ol className="ark-article-toc-list">
50+
{items.map((item, index) => (
51+
<li key={item.id}>
52+
<a
53+
href={`#${item.id}`}
54+
aria-current={activeId === item.id ? 'location' : undefined}
55+
className={[
56+
'ark-article-toc-link',
57+
item.level === 3 && 'ark-article-toc-link-level-3',
58+
item.level > 3 && 'ark-article-toc-link-level-4',
59+
activeId === item.id && 'ark-article-toc-link-active',
60+
]
61+
.filter(Boolean)
62+
.join(' ')}
63+
>
64+
<span aria-hidden="true" className="ark-article-toc-number">
65+
{String(index + 1).padStart(2, '0')}
66+
</span>
67+
<span>{item.text}</span>
68+
</a>
69+
</li>
70+
))}
71+
</ol>
72+
</nav>
8673
);
8774
}
8875

blog/ui/pages/BlogPostPage.tsx

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import { Metadata } from 'next';
22
import { notFound } from 'next/navigation';
3-
import {
4-
getFeedData,
5-
getAllFeedSlugs,
6-
} from '@/blog/services/post-repository';
3+
import { getFeedData, getAllFeedSlugs } from '@/blog/services/post-repository';
74
import {
85
getMdxSource,
96
parseHeadingsFromMdx,
@@ -149,7 +146,7 @@ export default async function BlogPostPage({
149146
<ScrollDepthTracker slug={post.slug} />
150147

151148
<article className="ark-article">
152-
<Container size="md">
149+
<Container size="md" className="ark-article-container">
153150
{/* Header */}
154151
<header className="ark-article-header">
155152
<span className="mb-4 inline-block rounded-[var(--radius-selection)] bg-[var(--color-bg-secondary)] px-3 py-1 text-xs font-medium text-[var(--color-text-secondary)]">
@@ -167,6 +164,8 @@ export default async function BlogPostPage({
167164
</div>
168165
</header>
169166

167+
<TableOfContents items={tocItems} />
168+
170169
{/* Content */}
171170
<div className="prose">
172171
<Content components={mdxComponents} />
@@ -176,7 +175,7 @@ export default async function BlogPostPage({
176175

177176
{/* Comments */}
178177
<section className="py-12">
179-
<Container size="md">
178+
<Container size="md" className="ark-article-container">
180179
<GiscusComments slug={slug} />
181180
</Container>
182181
</section>
@@ -206,8 +205,6 @@ export default async function BlogPostPage({
206205
isAccessibleForFree: true,
207206
}}
208207
/>
209-
210-
<TableOfContents items={tocItems} />
211208
</>
212209
);
213210
}

docs/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# 문서 인덱스
22

3-
Last updated: 2026-07-24
3+
Last updated: 2026-07-28
44

55
이 인덱스는 현재 코드베이스와 함께 유지해야 하는 문서만 추적한다. 계속
66
업데이트할 문서가 아니라면 삭제하거나, 오래 남겨야 하는 결정만 ADR로 옮긴다.
@@ -50,6 +50,7 @@ Last updated: 2026-07-24
5050
- `docs/adr/0049-share-publication-policy-data-with-audit.md`
5151
- `docs/adr/0050-retire-series-navigation-and-enforce-listed-post-policy.md`
5252
- `docs/adr/0052-use-repo-local-public-writing-review-workflow.md`
53+
- `docs/adr/0053-use-inline-table-of-contents-for-article-reading.md`
5354
- `docs/blog-quality-guide.md`
5455
- `docs/content-publication-candidates.md`
5556
- `docs/database/db-schema.md`
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# 0053. 글 읽기 흐름에 인라인 목차를 사용한다
2+
3+
Date: 2026-07-28
4+
Status: Accepted
5+
6+
## 배경
7+
8+
기존 글 상세 화면은 본문 바깥의 Portal 목차를 사용했다. 이 방식은 목차를
9+
항상 화면에 노출할 수 있지만, 본문과 목차의 관계가 분리되어 글의 실제
10+
읽기 흐름과 반응형 레이아웃을 함께 이해하기 어렵다. 본문 안에 목차를
11+
배치하면 짧은 글에서 목차를 숨길 수 있고, 독자가 글을 시작하는 위치에서
12+
구조를 바로 확인할 수 있다.
13+
14+
## 결정
15+
16+
- 목차는 글 본문 컨테이너 안에 인라인으로 렌더링한다.
17+
- 목차 항목이 2개 미만이면 렌더링하지 않는다.
18+
- 목차는 번호, 계층 들여쓰기, 현재 활성 heading 상태를 제공한다.
19+
- 데스크톱 콘텐츠 레이아웃에서는 본문과 댓글에 같은
20+
`ark-article-container` 정렬 정책을 적용한다.
21+
- 기존 viewport별 rail과 타이포그래피 규칙은 ADR 0051을 유지하고, 이 ADR은
22+
글 내부 탐색 방식에 대한 결정을 추가한다.
23+
24+
## 결과
25+
26+
- 목차가 본문 시작과 가까워져 글의 구조를 빠르게 파악할 수 있다.
27+
- 짧은 글에는 불필요한 목차가 나타나지 않는다.
28+
- 본문과 댓글이 같은 콘텐츠 열에 정렬되어 넓은 데스크톱 화면에서도
29+
읽기 흐름과 대화 영역의 시작점이 일치한다.
30+
- 목차가 본문 흐름에 포함되므로 Portal 기반의 고정 목차보다 화면에
31+
항상 노출된다는 보장은 줄어든다.
32+
33+
## 검토한 대안
34+
35+
- **기존 Portal 목차 유지**: 항상 접근할 수 있지만 본문과 구조적으로
36+
분리되고 짧은 글에도 별도 탐색 UI가 남는다.
37+
- **데스크톱에서만 Portal 목차 유지**: 데스크톱과 모바일의 탐색 모델이
38+
달라지고, 본문과 목차의 위치 관계를 일관되게 설명하기 어렵다.
39+
- **본문과 댓글의 서로 다른 정렬 유지**: 넓은 화면에서 댓글이 본문보다
40+
오른쪽으로 밀려 콘텐츠 열의 시각적 기준선이 어긋난다.
41+
42+
## 검증
43+
44+
- 목차 컴포넌트의 2개 미만 항목, 계층, 활성 상태 테스트
45+
- viewport stylesheet selector 및 정렬 정책 단위 테스트
46+
- 전체 lint, CSS syntax 검사, production build
47+
48+
## Related History
49+
50+
- `7f3f6ec`: 본문 Portal 목차를 인라인 목차로 변경한 초기 구현
51+
- `a79f661`: 커밋 메시지 및 PR 메타데이터 정리

docs/adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ ADR은 AI 협업 가이드와 별개의 문서다. 사람이 결정했든 AI가
7070
| [0050](0050-retire-series-navigation-and-enforce-listed-post-policy.md) | Accepted | 시리즈 탐색을 제거하고 발행 정책을 repository에서 강제한다 |
7171
| [0051](0051-use-viewport-specific-reading-layout-and-type-scale.md) | Accepted | viewport별 읽기 레이아웃과 타이포그래피 스케일을 사용한다 |
7272
| [0052](0052-use-repo-local-public-writing-review-workflow.md) | Accepted | 공개 글 검토에 repo-local writing review workflow를 사용한다 |
73+
| [0053](0053-use-inline-table-of-contents-for-article-reading.md) | Accepted | 글 읽기 흐름에 인라인 목차를 사용한다 |
7374

7475
## 작성 조건
7576

styles/globals.css

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -184,11 +184,77 @@
184184

185185
.ark-site-external-link {
186186
color: var(--color-text-primary);
187-
font-size: var(--text-base);
187+
font-size: var(--text-sm);
188188
font-weight: var(--font-normal);
189189
line-height: 1.5rem;
190190
}
191191

192+
.ark-article-toc {
193+
margin: var(--space-12) 0;
194+
padding: var(--space-6) 0;
195+
border-top: 1px solid var(--color-divider);
196+
border-bottom: 1px solid var(--color-divider);
197+
}
198+
199+
.ark-article-toc-title {
200+
margin: 0 0 var(--space-4);
201+
color: var(--color-grey-500);
202+
font-family: var(--font-mono);
203+
font-size: var(--text-xs);
204+
font-weight: var(--font-medium);
205+
letter-spacing: var(--tracking-wide);
206+
}
207+
208+
.ark-article-toc-list {
209+
display: flex;
210+
flex-direction: column;
211+
gap: var(--space-1);
212+
margin: 0;
213+
padding: 0;
214+
list-style: none;
215+
}
216+
217+
.ark-article-toc-link {
218+
display: flex;
219+
gap: var(--space-3);
220+
padding: var(--space-1) 0;
221+
color: var(--color-grey-600);
222+
font-family: var(--font-sans-emoji);
223+
font-size: var(--text-sm);
224+
line-height: 1.5rem;
225+
transition: color var(--duration-150) var(--ease-default);
226+
}
227+
228+
.ark-article-toc-link:hover {
229+
color: var(--color-grey-900);
230+
}
231+
232+
.ark-article-toc-link:focus-visible {
233+
outline: 2px solid var(--color-accent);
234+
outline-offset: 0.25rem;
235+
}
236+
237+
.ark-article-toc-link-level-3 {
238+
padding-left: var(--space-4);
239+
}
240+
241+
.ark-article-toc-link-level-4 {
242+
padding-left: var(--space-8);
243+
}
244+
245+
.ark-article-toc-link-active {
246+
color: var(--color-accent);
247+
font-weight: var(--font-medium);
248+
}
249+
250+
.ark-article-toc-number {
251+
flex-shrink: 0;
252+
color: var(--color-grey-400);
253+
font-family: var(--font-mono);
254+
font-size: var(--text-xs);
255+
line-height: 1.5rem;
256+
}
257+
192258
/* ===== Article Entry ===== */
193259

194260
.ark-article {
@@ -636,12 +702,17 @@
636702
.prose code {
637703
font-family: var(--font-mono);
638704
font-size: 0.875em;
639-
background-color: var(--color-bg-secondary);
640-
color: var(--color-text-primary);
641-
padding: 0.125rem 0.375rem;
705+
background-color: var(--color-code-inline-bg);
706+
color: var(--color-code-inline-fg);
707+
padding: 0.1rem 0.35rem;
642708
border-radius: var(--radius-action);
643709
}
644710

711+
.prose :not(pre) > code {
712+
background-color: var(--color-code-inline-bg);
713+
color: var(--color-code-inline-fg);
714+
}
715+
645716
/* Inline code should not have background if it's inside pre */
646717
.prose pre code {
647718
background-color: transparent;

styles/globals.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,20 @@ describe('globals styles', () => {
8484
expect(globalsContent).toContain('max-width: 34rem;');
8585
});
8686

87+
it('keeps external links visually secondary to primary navigation', () => {
88+
expect(globalsContent).toContain('.ark-site-external-link {');
89+
expect(globalsContent).toContain('font-size: var(--text-sm);');
90+
});
91+
92+
it('keeps inline code lighter than fenced code blocks', () => {
93+
expect(tokensContent).toContain('--color-code-inline-bg: #d8d8dc;');
94+
expect(tokensContent).toContain('--color-code-inline-fg: #52525b;');
95+
expect(tokensContent).toContain('--color-code-bg: #3f3f46;');
96+
expect(globalsContent).toContain(
97+
'.prose :not(pre) > code {\n background-color: var(--color-code-inline-bg);'
98+
);
99+
});
100+
87101
it('gives the mobile home page a split first-entry layout', () => {
88102
expect(mobileViewportContent).toContain(
89103
".ark-site-grid[data-page-layout='home']"
@@ -134,6 +148,12 @@ describe('globals styles', () => {
134148
expect(contentViewport).toContain(
135149
".ark-site-grid[data-page-layout='content'] .ark-article {\n padding-top: 0;"
136150
);
151+
expect(contentViewport).toContain('position: sticky;');
152+
expect(contentViewport).toContain('top: 2.5rem;');
153+
expect(contentViewport).toContain(
154+
".ark-site-grid[data-page-layout='content'] .ark-article-container {"
155+
);
156+
expect(contentViewport).toContain('margin-left: 0;');
137157
});
138158

139159
it('matches hero and primary navigation sizes at intermediate widths', () => {

0 commit comments

Comments
 (0)