From a79f661fe02c114b98c74baad175c086f7a7b82c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=9D=80=EC=9A=B0?= Date: Mon, 27 Jul 2026 22:19:59 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EA=B8=80=20=EC=9D=BD=EA=B8=B0=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- blog/ui/components/TableOfContents.test.tsx | 24 +++--- .../TableOfContents/TableOfContents.tsx | 69 +++++++--------- blog/ui/pages/BlogPostPage.tsx | 11 +-- styles/globals.css | 79 ++++++++++++++++++- styles/globals.test.ts | 20 +++++ styles/tokens.css | 8 +- styles/tokens.test.ts | 1 + styles/viewport/content.css | 8 ++ 8 files changed, 152 insertions(+), 68 deletions(-) diff --git a/blog/ui/components/TableOfContents.test.tsx b/blog/ui/components/TableOfContents.test.tsx index f1ed5675..32e062d8 100644 --- a/blog/ui/components/TableOfContents.test.tsx +++ b/blog/ui/components/TableOfContents.test.tsx @@ -4,9 +4,12 @@ import { TableOfContents } from './TableOfContents'; describe('TableOfContents', () => { it('renders native hash links and observes headings', () => { - const item = { id: 'section-1', text: '첫번째 섹션', level: 2 }; + const items = [ + { id: 'section-1', text: '첫번째 섹션', level: 2 }, + { id: 'section-2', text: '두번째 섹션', level: 2 }, + ]; const header = document.createElement('h2'); - header.id = item.id; + header.id = items[0].id; document.body.appendChild(header); const observeSpy = vi.fn(); @@ -24,22 +27,17 @@ describe('TableOfContents', () => { window.IntersectionObserver = MockIntersectionObserver as unknown as typeof window.IntersectionObserver; - const { container, getByRole } = render(); + const { container, getByRole } = render(); const link = getByRole('link', { name: '첫번째 섹션' }); fireEvent.click(link); - expect(container.querySelector('nav')).toBeNull(); - expect(document.body.querySelector('nav')).toHaveClass('bottom-8'); - expect(document.body.querySelector('nav > div')).toHaveClass( - 'h-full', - 'overflow-y-auto' + expect(container.querySelector('nav')).toHaveClass('ark-article-toc'); + expect(container.querySelector('nav > ol')).toHaveClass( + 'ark-article-toc-list' ); - expect(document.body.querySelector('ul')).toHaveClass('m-0', 'p-0'); - expect(link).toHaveAttribute('href', `#${item.id}`); - expect(link).toHaveStyle({ - fontFamily: 'var(--font-sans-emoji)', - }); + expect(link).toHaveAttribute('href', `#${items[0].id}`); + expect(link).toHaveClass('ark-article-toc-link'); expect(observeSpy).toHaveBeenCalledWith(header); }); }); diff --git a/blog/ui/components/TableOfContents/TableOfContents.tsx b/blog/ui/components/TableOfContents/TableOfContents.tsx index 6bfecb69..8549d148 100644 --- a/blog/ui/components/TableOfContents/TableOfContents.tsx +++ b/blog/ui/components/TableOfContents/TableOfContents.tsx @@ -1,8 +1,6 @@ 'use client'; import { useEffect, useState } from 'react'; -import { createPortal } from 'react-dom'; -import { clsx } from 'clsx'; interface TocItem { id: string; @@ -16,11 +14,6 @@ interface TableOfContentsProps { export default function TableOfContents({ items }: TableOfContentsProps) { const [activeId, setActiveId] = useState(''); - const [portalRoot, setPortalRoot] = useState(null); - - useEffect(() => { - setPortalRoot(document.body); - }, []); useEffect(() => { if (items.length === 0) { @@ -48,41 +41,35 @@ export default function TableOfContents({ items }: TableOfContentsProps) { return () => observer.disconnect(); }, [items]); - if (items.length === 0 || !portalRoot) return null; + if (items.length < 2) return null; - return createPortal( - , - portalRoot + return ( + ); } diff --git a/blog/ui/pages/BlogPostPage.tsx b/blog/ui/pages/BlogPostPage.tsx index b5095bef..8c99b4ae 100644 --- a/blog/ui/pages/BlogPostPage.tsx +++ b/blog/ui/pages/BlogPostPage.tsx @@ -1,9 +1,6 @@ import { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { - getFeedData, - getAllFeedSlugs, -} from '@/blog/services/post-repository'; +import { getFeedData, getAllFeedSlugs } from '@/blog/services/post-repository'; import { getMdxSource, parseHeadingsFromMdx, @@ -149,7 +146,7 @@ export default async function BlogPostPage({
- + {/* Header */}
@@ -167,6 +164,8 @@ export default async function BlogPostPage({
+ + {/* Content */}
@@ -206,8 +205,6 @@ export default async function BlogPostPage({ isAccessibleForFree: true, }} /> - - ); } diff --git a/styles/globals.css b/styles/globals.css index f4c58163..da59496b 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -184,11 +184,77 @@ .ark-site-external-link { color: var(--color-text-primary); - font-size: var(--text-base); + font-size: var(--text-sm); font-weight: var(--font-normal); line-height: 1.5rem; } +.ark-article-toc { + margin: var(--space-12) 0; + padding: var(--space-6) 0; + border-top: 1px solid var(--color-divider); + border-bottom: 1px solid var(--color-divider); +} + +.ark-article-toc-title { + margin: 0 0 var(--space-4); + color: var(--color-grey-500); + font-family: var(--font-mono); + font-size: var(--text-xs); + font-weight: var(--font-medium); + letter-spacing: var(--tracking-wide); +} + +.ark-article-toc-list { + display: flex; + flex-direction: column; + gap: var(--space-1); + margin: 0; + padding: 0; + list-style: none; +} + +.ark-article-toc-link { + display: flex; + gap: var(--space-3); + padding: var(--space-1) 0; + color: var(--color-grey-600); + font-family: var(--font-sans-emoji); + font-size: var(--text-sm); + line-height: 1.5rem; + transition: color var(--duration-150) var(--ease-default); +} + +.ark-article-toc-link:hover { + color: var(--color-grey-900); +} + +.ark-article-toc-link:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 0.25rem; +} + +.ark-article-toc-link-level-3 { + padding-left: var(--space-4); +} + +.ark-article-toc-link-level-4 { + padding-left: var(--space-8); +} + +.ark-article-toc-link-active { + color: var(--color-accent); + font-weight: var(--font-medium); +} + +.ark-article-toc-number { + flex-shrink: 0; + color: var(--color-grey-400); + font-family: var(--font-mono); + font-size: var(--text-xs); + line-height: 1.5rem; +} + /* ===== Article Entry ===== */ .ark-article { @@ -636,12 +702,17 @@ .prose code { font-family: var(--font-mono); font-size: 0.875em; - background-color: var(--color-bg-secondary); - color: var(--color-text-primary); - padding: 0.125rem 0.375rem; + background-color: var(--color-code-inline-bg); + color: var(--color-code-inline-fg); + padding: 0.1rem 0.35rem; border-radius: var(--radius-action); } +.prose :not(pre) > code { + background-color: var(--color-code-inline-bg); + color: var(--color-code-inline-fg); +} + /* Inline code should not have background if it's inside pre */ .prose pre code { background-color: transparent; diff --git a/styles/globals.test.ts b/styles/globals.test.ts index e323fe7c..bf953d15 100644 --- a/styles/globals.test.ts +++ b/styles/globals.test.ts @@ -84,6 +84,20 @@ describe('globals styles', () => { expect(globalsContent).toContain('max-width: 34rem;'); }); + it('keeps external links visually secondary to primary navigation', () => { + expect(globalsContent).toContain('.ark-site-external-link {'); + expect(globalsContent).toContain('font-size: var(--text-sm);'); + }); + + it('keeps inline code lighter than fenced code blocks', () => { + expect(tokensContent).toContain('--color-code-inline-bg: #d8d8dc;'); + expect(tokensContent).toContain('--color-code-inline-fg: #52525b;'); + expect(tokensContent).toContain('--color-code-bg: #3f3f46;'); + expect(globalsContent).toContain( + '.prose :not(pre) > code {\n background-color: var(--color-code-inline-bg);' + ); + }); + it('gives the mobile home page a split first-entry layout', () => { expect(mobileViewportContent).toContain( ".ark-site-grid[data-page-layout='home']" @@ -134,6 +148,12 @@ describe('globals styles', () => { expect(contentViewport).toContain( ".ark-site-grid[data-page-layout='content'] .ark-article {\n padding-top: 0;" ); + expect(contentViewport).toContain('position: sticky;'); + expect(contentViewport).toContain('top: 2.5rem;'); + expect(contentViewport).toContain( + ".ark-site-grid[data-page-layout='content'] .ark-article-container {" + ); + expect(contentViewport).toContain('margin-left: 0;'); }); it('matches hero and primary navigation sizes at intermediate widths', () => { diff --git a/styles/tokens.css b/styles/tokens.css index bc839e5c..39f1d674 100644 --- a/styles/tokens.css +++ b/styles/tokens.css @@ -53,9 +53,11 @@ --mobile-nav-focus-offset: var(--mobile-nav-bg); /* Code Block Colors */ - --color-code-bg: #252525; - --color-code-fg: #eaebea; - --color-code-title-bg: #3f3f46; + --color-code-bg: #3f3f46; + --color-code-fg: #e2e2e5; + --color-code-inline-bg: #d8d8dc; + --color-code-inline-fg: #52525b; + --color-code-title-bg: #52525b; --color-code-title-fg: #eaebea; --color-code-highlight: rgba(234, 235, 234, 0.12); --color-code-divider: rgba(234, 235, 234, 0.16); diff --git a/styles/tokens.test.ts b/styles/tokens.test.ts index d11c578c..55f2e14e 100644 --- a/styles/tokens.test.ts +++ b/styles/tokens.test.ts @@ -13,6 +13,7 @@ describe('Ark paper token definitions', () => { expect(tokensContent).toContain('--text-meta: 0.75rem;'); expect(tokensContent).toContain('--text-reading: 0.875rem;'); expect(tokensContent).toContain('--text-prose-h1: 1.375rem;'); + expect(tokensContent).toContain('--color-code-inline-bg: #d8d8dc;'); }); it('defines semantic radius roles for actions, content, and selections', () => { diff --git a/styles/viewport/content.css b/styles/viewport/content.css index 9e609847..73bf556c 100644 --- a/styles/viewport/content.css +++ b/styles/viewport/content.css @@ -7,6 +7,9 @@ .ark-site-grid[data-page-layout='content'] .ark-site-navigation { grid-column: 1; grid-row: 1; + position: sticky; + top: 2.5rem; + align-self: start; padding-top: 4.5rem; } @@ -18,4 +21,9 @@ .ark-site-grid[data-page-layout='content'] .ark-article { padding-top: 0; } + + .ark-site-grid[data-page-layout='content'] .ark-article-container { + margin-right: 0; + margin-left: 0; + } } From 6439af823e0cb4c747ced3b13bf83f2e4d9bb790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=9D=80=EC=9A=B0?= Date: Tue, 28 Jul 2026 00:22:02 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EB=A6=AC=EB=B7=B0=20=EC=BD=94?= =?UTF-8?q?=EB=A9=98=ED=8A=B8=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- blog/ui/pages/BlogPostPage.tsx | 2 +- docs/README.md | 3 +- ...e-table-of-contents-for-article-reading.md | 51 +++++++++++++++++++ docs/adr/README.md | 1 + 4 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0053-use-inline-table-of-contents-for-article-reading.md diff --git a/blog/ui/pages/BlogPostPage.tsx b/blog/ui/pages/BlogPostPage.tsx index 8c99b4ae..80986477 100644 --- a/blog/ui/pages/BlogPostPage.tsx +++ b/blog/ui/pages/BlogPostPage.tsx @@ -175,7 +175,7 @@ export default async function BlogPostPage({ {/* Comments */}
- +
diff --git a/docs/README.md b/docs/README.md index 79695e01..5c283d91 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # 문서 인덱스 -Last updated: 2026-07-24 +Last updated: 2026-07-28 이 인덱스는 현재 코드베이스와 함께 유지해야 하는 문서만 추적한다. 계속 업데이트할 문서가 아니라면 삭제하거나, 오래 남겨야 하는 결정만 ADR로 옮긴다. @@ -50,6 +50,7 @@ Last updated: 2026-07-24 - `docs/adr/0049-share-publication-policy-data-with-audit.md` - `docs/adr/0050-retire-series-navigation-and-enforce-listed-post-policy.md` - `docs/adr/0052-use-repo-local-public-writing-review-workflow.md` +- `docs/adr/0053-use-inline-table-of-contents-for-article-reading.md` - `docs/blog-quality-guide.md` - `docs/content-publication-candidates.md` - `docs/database/db-schema.md` diff --git a/docs/adr/0053-use-inline-table-of-contents-for-article-reading.md b/docs/adr/0053-use-inline-table-of-contents-for-article-reading.md new file mode 100644 index 00000000..d24868f7 --- /dev/null +++ b/docs/adr/0053-use-inline-table-of-contents-for-article-reading.md @@ -0,0 +1,51 @@ +# 0053. 글 읽기 흐름에 인라인 목차를 사용한다 + +Date: 2026-07-28 +Status: Accepted + +## 배경 + +기존 글 상세 화면은 본문 바깥의 Portal 목차를 사용했다. 이 방식은 목차를 +항상 화면에 노출할 수 있지만, 본문과 목차의 관계가 분리되어 글의 실제 +읽기 흐름과 반응형 레이아웃을 함께 이해하기 어렵다. 본문 안에 목차를 +배치하면 짧은 글에서 목차를 숨길 수 있고, 독자가 글을 시작하는 위치에서 +구조를 바로 확인할 수 있다. + +## 결정 + +- 목차는 글 본문 컨테이너 안에 인라인으로 렌더링한다. +- 목차 항목이 2개 미만이면 렌더링하지 않는다. +- 목차는 번호, 계층 들여쓰기, 현재 활성 heading 상태를 제공한다. +- 데스크톱 콘텐츠 레이아웃에서는 본문과 댓글에 같은 + `ark-article-container` 정렬 정책을 적용한다. +- 기존 viewport별 rail과 타이포그래피 규칙은 ADR 0051을 유지하고, 이 ADR은 + 글 내부 탐색 방식에 대한 결정을 추가한다. + +## 결과 + +- 목차가 본문 시작과 가까워져 글의 구조를 빠르게 파악할 수 있다. +- 짧은 글에는 불필요한 목차가 나타나지 않는다. +- 본문과 댓글이 같은 콘텐츠 열에 정렬되어 넓은 데스크톱 화면에서도 + 읽기 흐름과 대화 영역의 시작점이 일치한다. +- 목차가 본문 흐름에 포함되므로 Portal 기반의 고정 목차보다 화면에 + 항상 노출된다는 보장은 줄어든다. + +## 검토한 대안 + +- **기존 Portal 목차 유지**: 항상 접근할 수 있지만 본문과 구조적으로 + 분리되고 짧은 글에도 별도 탐색 UI가 남는다. +- **데스크톱에서만 Portal 목차 유지**: 데스크톱과 모바일의 탐색 모델이 + 달라지고, 본문과 목차의 위치 관계를 일관되게 설명하기 어렵다. +- **본문과 댓글의 서로 다른 정렬 유지**: 넓은 화면에서 댓글이 본문보다 + 오른쪽으로 밀려 콘텐츠 열의 시각적 기준선이 어긋난다. + +## 검증 + +- 목차 컴포넌트의 2개 미만 항목, 계층, 활성 상태 테스트 +- viewport stylesheet selector 및 정렬 정책 단위 테스트 +- 전체 lint, CSS syntax 검사, production build + +## Related History + +- `7f3f6ec`: 본문 Portal 목차를 인라인 목차로 변경한 초기 구현 +- `a79f661`: 커밋 메시지 및 PR 메타데이터 정리 diff --git a/docs/adr/README.md b/docs/adr/README.md index a41a146f..48a94020 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -70,6 +70,7 @@ ADR은 AI 협업 가이드와 별개의 문서다. 사람이 결정했든 AI가 | [0050](0050-retire-series-navigation-and-enforce-listed-post-policy.md) | Accepted | 시리즈 탐색을 제거하고 발행 정책을 repository에서 강제한다 | | [0051](0051-use-viewport-specific-reading-layout-and-type-scale.md) | Accepted | viewport별 읽기 레이아웃과 타이포그래피 스케일을 사용한다 | | [0052](0052-use-repo-local-public-writing-review-workflow.md) | Accepted | 공개 글 검토에 repo-local writing review workflow를 사용한다 | +| [0053](0053-use-inline-table-of-contents-for-article-reading.md) | Accepted | 글 읽기 흐름에 인라인 목차를 사용한다 | ## 작성 조건