Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 11 additions & 13 deletions blog/ui/components/TableOfContents.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -24,22 +27,17 @@ describe('TableOfContents', () => {
window.IntersectionObserver =
MockIntersectionObserver as unknown as typeof window.IntersectionObserver;

const { container, getByRole } = render(<TableOfContents items={[item]} />);
const { container, getByRole } = render(<TableOfContents items={items} />);

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);
});
});
69 changes: 28 additions & 41 deletions blog/ui/components/TableOfContents/TableOfContents.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
'use client';

import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { clsx } from 'clsx';

interface TocItem {
id: string;
Expand All @@ -16,11 +14,6 @@ interface TableOfContentsProps {

export default function TableOfContents({ items }: TableOfContentsProps) {
const [activeId, setActiveId] = useState<string>('');
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);

useEffect(() => {
setPortalRoot(document.body);
}, []);

useEffect(() => {
if (items.length === 0) {
Expand Down Expand Up @@ -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(
<nav
className="fixed right-8 top-20 bottom-8 hidden w-64 xl:block"
aria-label="이 글의 목차"
>
<div className="h-full overflow-y-auto rounded-[var(--radius-md)] bg-[var(--color-grey-50)] p-4">
<h2 className="text-sm font-semibold text-[var(--color-grey-900)] mb-4 sticky top-0 bg-[var(--color-grey-50)] pb-2">
이 글의 목차
</h2>
<ul className="m-0 flex list-none flex-col gap-1 p-0">
{items.map((item) => (
<li key={item.id}>
<a
href={`#${item.id}`}
aria-current={activeId === item.id ? 'location' : undefined}
className={clsx(
'block w-full text-left text-sm py-1.5 px-3 rounded-[6px]',
'transition-colors duration-[var(--duration-150)]',
item.level > 2 && 'pl-6',
activeId === item.id
? 'bg-[var(--color-accent)]/10 text-[var(--color-accent)] font-medium'
: 'text-[var(--color-grey-600)] hover:text-[var(--color-grey-900)] hover:bg-[var(--color-grey-100)]'
)}
style={{ fontFamily: 'var(--font-sans-emoji)' }}
>
{item.text}
</a>
</li>
))}
</ul>
</div>
</nav>,
portalRoot
return (
<nav className="ark-article-toc" aria-label="이 글의 목차">
Comment on lines +46 to +47

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Record the inline TOC decision in an ADR

Replacing the fixed body portal with an in-flow TOC chooses between two implementations with material visibility, scrolling, and responsive-layout tradeoffs, and it also revises the reading layout previously covered by ADR 0051. This commit changes the decision without adding a numbered successor ADR or updating the ADR index; document the alternatives and consequences before merging.

AGENTS.md reference: AGENTS.md:L26-L35

Useful? React with 👍 / 👎.

<p className="ark-article-toc-title">목차</p>
<ol className="ark-article-toc-list">
{items.map((item, index) => (
<li key={item.id}>
<a
href={`#${item.id}`}
aria-current={activeId === item.id ? 'location' : undefined}
className={[
'ark-article-toc-link',
item.level === 3 && 'ark-article-toc-link-level-3',
item.level > 3 && 'ark-article-toc-link-level-4',
activeId === item.id && 'ark-article-toc-link-active',
]
.filter(Boolean)
.join(' ')}
>
<span aria-hidden="true" className="ark-article-toc-number">
{String(index + 1).padStart(2, '0')}
</span>
<span>{item.text}</span>
</a>
</li>
))}
</ol>
</nav>
);
}

Expand Down
11 changes: 4 additions & 7 deletions blog/ui/pages/BlogPostPage.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -149,7 +146,7 @@ export default async function BlogPostPage({
<ScrollDepthTracker slug={post.slug} />

<article className="ark-article">
<Container size="md">
<Container size="md" className="ark-article-container">
{/* Header */}
<header className="ark-article-header">
<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)]">
Expand All @@ -167,6 +164,8 @@ export default async function BlogPostPage({
</div>
</header>

<TableOfContents items={tocItems} />

{/* Content */}
<div className="prose">
<Content components={mdxComponents} />
Expand Down Expand Up @@ -206,8 +205,6 @@ export default async function BlogPostPage({
isAccessibleForFree: true,
}}
/>

<TableOfContents items={tocItems} />
</>
);
}
79 changes: 75 additions & 4 deletions styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
20 changes: 20 additions & 0 deletions styles/globals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']"
Expand Down Expand Up @@ -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', () => {
Expand Down
8 changes: 5 additions & 3 deletions styles/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions styles/tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
8 changes: 8 additions & 0 deletions styles/viewport/content.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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;
Comment on lines +25 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the comments aligned with the article

On desktop widths where the content grid column exceeds the container's 800px maximum (including the configured 1440px desktop viewport), these overrides left-align only .ark-article-container, while the comments at BlogPostPage.tsx:178 remain in a centered Container size="md". The comments therefore begin roughly half the surplus column width to the right of the article; apply the same alignment policy to both sections or retain centering for both.

AGENTS.md reference: AGENTS.md:L93-L95

Useful? React with 👍 / 👎.

}
}
Loading