Skip to content

Commit a79f661

Browse files
박은우박은우
authored andcommitted
feat: 글 읽기 레이아웃 개선
1 parent 1956566 commit a79f661

8 files changed

Lines changed: 152 additions & 68 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: 4 additions & 7 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} />
@@ -206,8 +205,6 @@ export default async function BlogPostPage({
206205
isAccessibleForFree: true,
207206
}}
208207
/>
209-
210-
<TableOfContents items={tocItems} />
211208
</>
212209
);
213210
}

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', () => {

styles/tokens.css

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,11 @@
5353
--mobile-nav-focus-offset: var(--mobile-nav-bg);
5454

5555
/* Code Block Colors */
56-
--color-code-bg: #252525;
57-
--color-code-fg: #eaebea;
58-
--color-code-title-bg: #3f3f46;
56+
--color-code-bg: #3f3f46;
57+
--color-code-fg: #e2e2e5;
58+
--color-code-inline-bg: #d8d8dc;
59+
--color-code-inline-fg: #52525b;
60+
--color-code-title-bg: #52525b;
5961
--color-code-title-fg: #eaebea;
6062
--color-code-highlight: rgba(234, 235, 234, 0.12);
6163
--color-code-divider: rgba(234, 235, 234, 0.16);

styles/tokens.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ describe('Ark paper token definitions', () => {
1313
expect(tokensContent).toContain('--text-meta: 0.75rem;');
1414
expect(tokensContent).toContain('--text-reading: 0.875rem;');
1515
expect(tokensContent).toContain('--text-prose-h1: 1.375rem;');
16+
expect(tokensContent).toContain('--color-code-inline-bg: #d8d8dc;');
1617
});
1718

1819
it('defines semantic radius roles for actions, content, and selections', () => {

styles/viewport/content.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
.ark-site-grid[data-page-layout='content'] .ark-site-navigation {
88
grid-column: 1;
99
grid-row: 1;
10+
position: sticky;
11+
top: 2.5rem;
12+
align-self: start;
1013
padding-top: 4.5rem;
1114
}
1215

@@ -18,4 +21,9 @@
1821
.ark-site-grid[data-page-layout='content'] .ark-article {
1922
padding-top: 0;
2023
}
24+
25+
.ark-site-grid[data-page-layout='content'] .ark-article-container {
26+
margin-right: 0;
27+
margin-left: 0;
28+
}
2129
}

0 commit comments

Comments
 (0)