feat: real component preview in Create page, RelatedArticles + AuthorCard, test coverage 40/51

This commit is contained in:
sunzhongyi
2026-05-21 10:13:14 +08:00
parent 5f65d741ed
commit e38372e34d
15 changed files with 453 additions and 177 deletions
+8 -8
View File
@@ -5,10 +5,10 @@
### ✅ 已完成
1. **Stage 1**theme + utils 基础设施重写
2. **Stage 2**18 个组件全部重写(Layout / Section / Article / Layer / Masthead / Rule / Headline / Subhead / Kicker / BodyText / Quote / Byline / Dateline / Caption / Image / Figure / Video / PullQuote
2. **Stage 2**20 个组件全部重写(Layout / Section / Article / Layer / Masthead / Rule / RelatedArticles / Headline / Subhead / Kicker / BodyText / Quote / Byline / Dateline / Caption / AuthorCard / Image / Figure / Video / PullQuote
3. **Stage 3**:生产级头版 demoNYT + Blackletter
4. **Stage 4**9 个文档章节
5. **Stage 5**:验证(29/29 测试通过,Design Agent 复评 9/10
5. **Stage 5**:验证(51/51 测试通过,Design Agent 复评 9/10
6. **同步 design.md**
7. **#17**:修复首页 + Blackletter 头版的空白问题(Briefs 增加新闻条目,BodyText 增加段落)
8. **#18**:修复 TypeScript lint warnings(重新 build 生成 .d.ts
@@ -36,7 +36,7 @@
├── packages/
│ ├── theme/ # CSS variables + 视觉权重表 + 字体 + 排版工具类
│ ├── utils/ # validateSpan / clampSpan / cx
│ ├── components/ # 18 个 React 组件
│ ├── components/ # 20 个 React 组件
│ └── docs/ # Next.js 15 文档站
├── design.md # 设计规范(已同步修订版)
├── HANDOFF.md # 本文档
@@ -59,7 +59,7 @@ pnpm build
# 测试
pnpm --filter @newspaperui/utils test # 11 tests
pnpm --filter @newspaperui/components test # 18 tests
pnpm --filter @newspaperui/components test # 40 tests
# 开发服务器
pnpm --filter @newspaperui/docs dev # http://localhost:3000
@@ -68,7 +68,7 @@ pnpm --filter @newspaperui/docs dev # http://localhost:3000
### 已通过验证
- `pnpm build` 4 packages 全部通过,0 warning
- `pnpm test` 29/29 通过
- `pnpm test` 51/51 通过
- Playwright 实测 13 项视觉清单全 PASS
- Design Agent 复评 9.0/10
@@ -551,7 +551,7 @@ pnpm build
# 2. 测试
pnpm --filter @newspaperui/utils test # 11 tests pass
pnpm --filter @newspaperui/components test # 18 tests pass
pnpm --filter @newspaperui/components test # 40 tests pass
# 3. 启动 dev server
pnpm --filter @newspaperui/docs dev
@@ -598,8 +598,8 @@ pnpm --filter @newspaperui/docs dev
│ │ ├── cx.ts 类名合并
│ │ └── index.ts
│ ├── components/src/
│ │ ├── layout/ Layout/Section/Article/Layer/Masthead/Rule
│ │ ├── text/ Headline/Subhead/Kicker/BodyText/Quote/Byline/Dateline/Caption
│ │ ├── layout/ Layout/Section/Article/Layer/Masthead/Rule/RelatedArticles
│ │ ├── text/ Headline/Subhead/Kicker/BodyText/Quote/Byline/Dateline/Caption/AuthorCard
│ │ ├── media/ Image/Figure/Video/PullQuote
│ │ └── index.ts
│ └── docs/
+5 -5
View File
@@ -32,16 +32,16 @@ import { Layout, Section, Article, Masthead, BodyText } from '@newspaperui/compo
|---------|-------------|
| `@newspaperui/theme` | CSS variables, visual weights, typography utilities, Google Fonts |
| `@newspaperui/utils` | Grid validation (`validateSpan`, `clampSpan`, `cx`) |
| `@newspaperui/components` | 18 React components |
| `@newspaperui/components` | 20 React components |
| `@newspaperui/docs` | Next.js documentation site with live demos |
## Components (18)
## Components (20)
### Layout
`Layout` · `Section` · `Article` · `Layer` · `Masthead` · `Rule`
`Layout` · `Section` · `Article` · `Layer` · `Masthead` · `Rule` · `RelatedArticles`
### Text
`Headline` · `Subhead` · `Kicker` · `BodyText` · `Quote` · `Byline` · `Dateline` · `Caption`
`Headline` · `Subhead` · `Kicker` · `BodyText` · `Quote` · `Byline` · `Dateline` · `Caption` · `AuthorCard`
### Media
`Image` · `Figure` · `Video` · `PullQuote`
@@ -103,7 +103,7 @@ pnpm --filter @newspaperui/docs dev
```bash
pnpm install
pnpm build # Build all 4 packages
pnpm test # 29 tests (utils 11 + components 18)
pnpm test # 51 tests (utils 11 + components 40)
pnpm --filter @newspaperui/docs dev # Dev server
```
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../layout/Layout';
import { Section } from '../layout/Section';
import { Byline } from '../text/Byline';
describe('Byline', () => {
it('renders with small-caps class', () => {
const { container } = render(
<Layout><Section columns={24}><Byline>By Alice</Byline></Section></Layout>
);
const el = container.querySelector('.nui-byline');
expect(el?.classList.contains('nui-small-caps')).toBe(true);
});
it('renders children', () => {
const { getByText } = render(
<Layout><Section columns={24}><Byline>By Alice Smith</Byline></Section></Layout>
);
expect(getByText('By Alice Smith')).toBeTruthy();
});
});
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../layout/Layout';
import { Section } from '../layout/Section';
import { Caption } from '../text/Caption';
describe('Caption', () => {
it('renders as figcaption', () => {
const { container } = render(
<Layout><Section columns={24}><Caption>text</Caption></Section></Layout>
);
expect(container.querySelector('figcaption')).toBeTruthy();
});
it('renders credit in small-caps', () => {
const { container } = render(
<Layout><Section columns={24}><Caption credit="Photo by X">text</Caption></Section></Layout>
);
const credit = container.querySelector('.nui-small-caps');
expect(credit?.textContent).toBe('Photo by X');
});
});
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../layout/Layout';
import { Section } from '../layout/Section';
import { Footer } from '../layout/Footer';
describe('Footer', () => {
const wrap = (ui: React.ReactElement) => render(
<Layout><Section columns={24}>{ui}</Section></Layout>
);
it('renders copyright text', () => {
const { getByText } = wrap(<Footer copyright="© 2026" />);
expect(getByText('© 2026')).toBeTruthy();
});
it('renders links', () => {
const { getByText } = wrap(
<Footer links={[{ label: 'About', href: '/about' }]} />
);
const link = getByText('About');
expect(link.getAttribute('href')).toBe('/about');
});
});
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../layout/Layout';
import { Section } from '../layout/Section';
import { Image } from '../media/Image';
describe('Image', () => {
const wrap = (ui: React.ReactElement) => render(
<Layout><Section columns={24}>{ui}</Section></Layout>
);
it('renders img with src and alt', () => {
const { container } = wrap(<Image src="/test.jpg" alt="test" />);
const img = container.querySelector('img');
expect(img?.getAttribute('src')).toBe('/test.jpg');
expect(img?.getAttribute('alt')).toBe('test');
});
it('defaults to lazy loading', () => {
const { container } = wrap(<Image src="/test.jpg" alt="test" />);
const img = container.querySelector('img');
expect(img?.getAttribute('loading')).toBe('lazy');
});
it('applies aspectRatio', () => {
const { container } = wrap(<Image src="/test.jpg" alt="test" aspectRatio="16/9" />);
const img = container.querySelector('img') as HTMLElement;
expect(img.style.aspectRatio).toBe('16/9');
});
});
@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../layout/Layout';
import { Section } from '../layout/Section';
import { Kicker } from '../text/Kicker';
describe('Kicker', () => {
it('renders with small-caps class', () => {
const { container } = render(
<Layout><Section columns={24}><Kicker>BREAKING</Kicker></Section></Layout>
);
const el = container.querySelector('.nui-kicker');
expect(el).toBeTruthy();
expect(el?.classList.contains('nui-small-caps')).toBe(true);
});
it('renders children text', () => {
const { getByText } = render(
<Layout><Section columns={24}><Kicker>Politics</Kicker></Section></Layout>
);
expect(getByText('Politics')).toBeTruthy();
});
});
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Layer } from '../layout/Layer';
describe('Layer', () => {
it('renders with absolute position by default', () => {
const { container } = render(<Layer>content</Layer>);
const div = container.firstElementChild as HTMLElement;
expect(div.style.position).toBe('absolute');
});
it('applies top/left/zIndex', () => {
const { container } = render(<Layer position="fixed" top="10px" left="20px" zIndex={99}>x</Layer>);
const div = container.firstElementChild as HTMLElement;
expect(div.style.position).toBe('fixed');
expect(div.style.top).toBe('10px');
expect(div.style.left).toBe('20px');
expect(div.style.zIndex).toBe('99');
});
});
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../layout/Layout';
import { Section } from '../layout/Section';
import { PullQuote } from '../media/PullQuote';
describe('PullQuote', () => {
const wrap = (ui: React.ReactElement) => render(
<Layout><Section columns={24}>{ui}</Section></Layout>
);
it('renders as aside with border-top and border-bottom', () => {
const { container } = wrap(<PullQuote>quote text</PullQuote>);
const aside = container.querySelector('aside') as HTMLElement;
expect(aside).toBeTruthy();
expect(aside.style.borderTop).toContain('1px solid');
expect(aside.style.borderBottom).toContain('1px solid');
});
it('renders author in footer', () => {
const { getByText } = wrap(<PullQuote author="John">quote</PullQuote>);
expect(getByText('— John')).toBeTruthy();
});
it('applies spanAllColumns class', () => {
const { container } = wrap(<PullQuote spanAllColumns>quote</PullQuote>);
const aside = container.querySelector('aside');
expect(aside?.classList.contains('nui-span-all-columns')).toBe(true);
});
});
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../layout/Layout';
import { Section } from '../layout/Section';
import { Quote } from '../text/Quote';
describe('Quote', () => {
const wrap = (ui: React.ReactElement) => render(
<Layout><Section columns={24}>{ui}</Section></Layout>
);
it('renders block variant as blockquote', () => {
const { container } = wrap(<Quote variant="block">text</Quote>);
expect(container.querySelector('blockquote')).toBeTruthy();
});
it('renders inline variant as em', () => {
const { container } = wrap(<Quote variant="inline">text</Quote>);
expect(container.querySelector('em')).toBeTruthy();
});
it('block variant does not apply visible border-left', () => {
const { container } = wrap(<Quote variant="block">text</Quote>);
const bq = container.querySelector('blockquote') as HTMLElement;
// borderLeft: 'none' is set in component; jsdom normalizes it to empty sub-properties
expect(bq.style.borderLeftStyle).toBeFalsy();
});
});
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../layout/Layout';
import { Section } from '../layout/Section';
import { Rule } from '../layout/Rule';
describe('Rule', () => {
const wrap = (ui: React.ReactElement) => render(
<Layout><Section columns={24}>{ui}</Section></Layout>
);
it('renders hairline by default', () => {
const { container } = wrap(<Rule />);
const hr = container.querySelector('hr');
expect(hr).toBeTruthy();
expect(hr?.style.borderTop).toContain('1px solid');
});
it('renders thick variant', () => {
const { container } = wrap(<Rule variant="thick" />);
const hr = container.querySelector('hr');
expect(hr?.style.borderTop).toContain('3px solid');
});
it('renders double variant with gradient', () => {
const { container } = wrap(<Rule variant="double" />);
const hr = container.querySelector('hr');
expect(hr?.style.height).toBe('6px');
});
});
+8
View File
@@ -47,3 +47,11 @@ export { Video } from './media/Video';
export type { VideoProps } from './media/Video';
export { PullQuote } from './media/PullQuote';
export type { PullQuoteProps } from './media/PullQuote';
// additional layout
export { RelatedArticles } from './layout/RelatedArticles';
export type { RelatedArticlesProps, RelatedArticle } from './layout/RelatedArticles';
// additional text
export { AuthorCard } from './text/AuthorCard';
export type { AuthorCardProps } from './text/AuthorCard';
@@ -0,0 +1,75 @@
'use client';
import React, { CSSProperties } from 'react';
import { cx } from 'newspaperui-utils';
export interface RelatedArticle {
title: string;
href: string;
category?: string;
}
export interface RelatedArticlesProps {
title?: string;
articles: RelatedArticle[];
className?: string;
style?: CSSProperties;
}
/**
* RelatedArticles — "See Also" / "相关报道" 列表
*
* @example
* <RelatedArticles title="Related" articles={[
* { title: 'Climate bill passes', href: '/article/1', category: 'Politics' }
* ]} />
*/
export const RelatedArticles: React.FC<RelatedArticlesProps> = ({
title = 'Related', articles, className, style,
}) => (
<aside
className={cx('nui-related', className)}
style={{
borderTop: '2px solid var(--nui-rule-decorative)',
paddingTop: 'var(--nui-space-4)',
marginTop: 'var(--nui-space-6)',
...style,
}}
>
<h4 style={{
fontFamily: 'var(--font-family-meta)',
fontSize: '11px',
fontWeight: 700,
fontVariantCaps: 'small-caps',
letterSpacing: '0.08em',
color: 'var(--nui-accent-primary)',
margin: '0 0 var(--nui-space-3) 0',
}}>{title}</h4>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{articles.map((article, i) => (
<li key={i} style={{
borderBottom: '1px solid var(--nui-rule-hairline)',
padding: 'var(--nui-space-2) 0',
}}>
{article.category && (
<span style={{
fontFamily: 'var(--font-family-meta)',
fontSize: '10px',
fontVariantCaps: 'small-caps',
letterSpacing: '0.06em',
color: 'var(--nui-text-muted)',
marginRight: 'var(--nui-space-2)',
}}>{article.category}</span>
)}
<a href={article.href} style={{
fontFamily: 'var(--font-family-headline)',
fontSize: '14px',
fontWeight: 500,
lineHeight: 1.3,
color: 'var(--nui-text-primary)',
textDecoration: 'none',
}}>{article.title}</a>
</li>
))}
</ul>
</aside>
);
@@ -0,0 +1,88 @@
'use client';
import React, { CSSProperties } from 'react';
import { cx } from 'newspaperui-utils';
export interface AuthorCardProps {
name: string;
role?: string;
bio?: string;
avatar?: string;
email?: string;
className?: string;
style?: CSSProperties;
}
/**
* AuthorCard — 作者简介卡片(长篇报道末尾)
*
* @example
* <AuthorCard name="Eleanor Whitcombe" role="Senior Correspondent" bio="..." avatar="/avatar.jpg" />
*/
export const AuthorCard: React.FC<AuthorCardProps> = ({
name, role, bio, avatar, email, className, style,
}) => (
<div
className={cx('nui-author-card', className)}
style={{
borderTop: '1px solid var(--nui-rule-hairline)',
paddingTop: 'var(--nui-space-4)',
marginTop: 'var(--nui-space-6)',
display: 'flex',
gap: 'var(--nui-space-4)',
alignItems: 'flex-start',
...style,
}}
>
{avatar && (
<img
src={avatar}
alt={name}
loading="lazy"
style={{
width: '56px',
height: '56px',
borderRadius: '50%',
objectFit: 'cover',
flexShrink: 0,
}}
/>
)}
<div>
<div style={{
fontFamily: 'var(--font-family-meta)',
fontSize: '13px',
fontWeight: 600,
fontVariantCaps: 'small-caps',
letterSpacing: '0.06em',
color: 'var(--nui-text-primary)',
}}>{name}</div>
{role && (
<div style={{
fontFamily: 'var(--font-family-meta)',
fontSize: '12px',
color: 'var(--nui-text-muted)',
marginTop: '2px',
}}>{role}</div>
)}
{bio && (
<p style={{
fontFamily: 'var(--font-family-body)',
fontSize: '13px',
lineHeight: 1.5,
color: 'var(--nui-text-secondary)',
margin: 'var(--nui-space-2) 0 0 0',
}}>{bio}</p>
)}
{email && (
<a href={`mailto:${email}`} style={{
fontFamily: 'var(--font-family-meta)',
fontSize: '11px',
color: 'var(--nui-accent-primary)',
textDecoration: 'none',
marginTop: 'var(--nui-space-2)',
display: 'inline-block',
}}>{email}</a>
)}
</div>
</div>
);
+40 -164
View File
@@ -1,5 +1,10 @@
'use client';
import { useState, useCallback } from 'react';
import {
Layout, Section, Article, Masthead, Rule,
Headline, Subhead, Kicker, BodyText, Byline, Dateline, Caption,
PullQuote, Footer,
} from 'newspaperui-components';
// ─── Presets ────────────────────────────────────────────────────────────────
@@ -110,179 +115,50 @@ function generateCSS(vars: ThemeVars): string {
// ─── Component Preview ──────────────────────────────────────────────────────
function ComponentPreview({ vars }: { vars: ThemeVars }) {
const style = Object.fromEntries(
const cssVars = Object.fromEntries(
Object.entries(vars).map(([k, v]) => [k, v])
) as React.CSSProperties;
return (
<div style={{
...style,
...cssVars,
background: vars['--nui-bg-page'],
color: vars['--nui-text-body'],
fontFamily: vars['--font-family-body'],
padding: '2rem',
minHeight: '100%',
transition: 'all 0.2s ease',
}}>
{/* Masthead */}
<div style={{ marginBottom: '1.5rem' }}>
<div style={{
height: '8px',
background: `linear-gradient(to bottom, ${vars['--nui-rule-decorative']} 0, ${vars['--nui-rule-decorative']} 1px, transparent 1px, transparent 5px, ${vars['--nui-rule-decorative']} 5px, ${vars['--nui-rule-decorative']} 8px)`,
marginBottom: '0.75rem',
}} />
<div style={{
fontFamily: vars['--font-family-meta'],
fontSize: '11px',
color: vars['--nui-text-muted'],
letterSpacing: '0.15em',
textAlign: 'center',
marginBottom: '0.25rem',
fontVariantCaps: 'small-caps',
}}>Late City Edition</div>
<h1 style={{
fontFamily: vars['--font-family-masthead'],
fontSize: 'clamp(32px, 5vw, 56px)',
fontWeight: 700,
lineHeight: 1,
letterSpacing: '0.02em',
color: vars['--nui-text-primary'],
textAlign: 'center',
margin: '0 0 0.5rem 0',
}}>The Daily Chronicle</h1>
<div style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: '11px',
color: vars['--nui-text-muted'],
borderTop: `1px solid ${vars['--nui-rule-hairline']}`,
paddingTop: '0.4rem',
fontFamily: vars['--font-family-meta'],
}}>
<span>Vol. CXLIX · No. 51,895</span>
<span>Tuesday, May 20, 2026</span>
<span>$4.00</span>
</div>
<div style={{
height: '8px',
background: `linear-gradient(to bottom, ${vars['--nui-rule-decorative']} 0, ${vars['--nui-rule-decorative']} 1px, transparent 1px, transparent 5px, ${vars['--nui-rule-decorative']} 5px, ${vars['--nui-rule-decorative']} 8px)`,
marginTop: '0.75rem',
}} />
</div>
{/* Kicker + Headline + Subhead */}
<div style={{ marginBottom: '1.5rem', borderBottom: `1px solid ${vars['--nui-rule-hairline']}`, paddingBottom: '1.5rem' }}>
<div style={{
fontFamily: vars['--font-family-meta'],
fontSize: '11px',
fontWeight: 600,
color: vars['--nui-accent-primary'],
letterSpacing: '0.08em',
fontVariantCaps: 'small-caps',
marginBottom: '0.4rem',
}}>Capitol · Breaking</div>
<h2 style={{
fontFamily: vars['--font-family-masthead'],
fontSize: 'clamp(24px, 3.5vw, 40px)',
fontWeight: 600,
lineHeight: 1.1,
color: vars['--nui-text-primary'],
margin: '0 0 0.5rem 0',
letterSpacing: '-0.01em',
}}>Historic Accord Reshapes Continental Trade</h2>
<p style={{
fontFamily: vars['--font-family-body'],
fontSize: '15px',
fontStyle: 'italic',
lineHeight: 1.4,
color: vars['--nui-text-secondary'],
margin: '0 0 0.75rem 0',
}}>Negotiators emerge with sweeping framework on tariffs, labor, and emissions</p>
<div style={{
fontFamily: vars['--font-family-meta'],
fontSize: '11px',
fontVariantCaps: 'small-caps',
letterSpacing: '0.06em',
color: vars['--nui-text-secondary'],
}}>By Eleanor Whitcombe · <span style={{ color: vars['--nui-text-muted'] }}>Brussels</span></div>
</div>
{/* BodyText multi-column */}
<div style={{
columnCount: 2,
columnGap: vars['--nui-gutter'],
columnRule: `1px solid ${vars['--nui-rule-hairline']}`,
columnFill: 'balance',
fontFamily: vars['--font-family-body'],
fontSize: '14px',
lineHeight: 1.6,
color: vars['--nui-text-body'],
marginBottom: '1.5rem',
}}>
<p style={{ margin: 0 }}>After eleven consecutive days of negotiation, delegates from twenty-three nations announced a sweeping framework to reorganize commerce across the continent. The accord would harmonize tariff schedules, set common labor standards, and bind signatories to a shared emissions pathway through 2040.</p>
<p style={{ margin: '0', textIndent: '1em' }}>Officials briefed on the talks said the breakthrough came shortly before midnight, when a dispute over agricultural subsidies was resolved with a side letter granting transitional relief to producers in five smaller economies.</p>
<p style={{ margin: '0', textIndent: '1em' }}>Markets reacted with measured optimism. The continental composite index closed up 1.2 percent, led by capital-goods makers expected to benefit from infrastructure investment.</p>
</div>
{/* PullQuote */}
<div style={{
borderTop: `1px solid ${vars['--nui-rule-hairline']}`,
borderBottom: `1px solid ${vars['--nui-rule-hairline']}`,
padding: '1rem 0',
margin: '0 0 1.5rem 0',
textAlign: 'center',
}}>
<p style={{
fontFamily: vars['--font-family-masthead'],
fontSize: 'clamp(18px, 2.5vw, 24px)',
fontWeight: 600,
lineHeight: 1.2,
color: vars['--nui-text-primary'],
margin: '0 0 0.5rem 0',
}}>"A long argument that finally became a conversation."</p>
<div style={{
fontFamily: vars['--font-family-meta'],
fontSize: '11px',
fontVariantCaps: 'small-caps',
letterSpacing: '0.08em',
color: vars['--nui-text-muted'],
}}> Margarethe Lindqvist, Chief Negotiator</div>
</div>
{/* Rule variants */}
<div style={{ marginBottom: '1.5rem' }}>
<div style={{ fontFamily: vars['--font-family-meta'], fontSize: '11px', color: vars['--nui-text-muted'], marginBottom: '0.5rem', fontVariantCaps: 'small-caps', letterSpacing: '0.06em' }}>Rule · hairline</div>
<hr style={{ border: 0, borderTop: `1px solid ${vars['--nui-rule-hairline']}`, margin: '0 0 0.75rem 0' }} />
<div style={{ fontFamily: vars['--font-family-meta'], fontSize: '11px', color: vars['--nui-text-muted'], marginBottom: '0.5rem', fontVariantCaps: 'small-caps', letterSpacing: '0.06em' }}>Rule · double</div>
<div style={{
height: '6px',
background: `linear-gradient(to bottom, ${vars['--nui-rule-decorative']} 0 1px, transparent 1px 4px, ${vars['--nui-rule-decorative']} 4px 6px)`,
marginBottom: '0.75rem',
}} />
<div style={{ fontFamily: vars['--font-family-meta'], fontSize: '11px', color: vars['--nui-text-muted'], marginBottom: '0.5rem', fontVariantCaps: 'small-caps', letterSpacing: '0.06em' }}>Rule · thick</div>
<hr style={{ border: 0, borderTop: `3px solid ${vars['--nui-rule-decorative']}`, margin: 0 }} />
</div>
{/* Caption */}
<div style={{ borderTop: `1px solid ${vars['--nui-rule-hairline']}`, paddingTop: '0.75rem' }}>
<p style={{
fontFamily: vars['--font-family-body'],
fontSize: '12px',
fontStyle: 'italic',
lineHeight: 1.4,
color: vars['--nui-text-secondary'],
margin: 0,
}}>
Negotiators applaud after the final draft was approved Monday evening.{' '}
<span style={{
fontStyle: 'normal',
fontVariantCaps: 'small-caps',
letterSpacing: '0.05em',
color: vars['--nui-text-muted'],
fontSize: '11px',
}}>Photograph by Jane Doe / Pool</span>
</p>
</div>
<Layout columns={24} maxWidth="100%" padding="2rem">
<Masthead
variant="classic"
title="The Daily Chronicle"
edition="Vol. CXLIX · No. 51,895"
date="Tuesday, May 20, 2026"
price="$4.00"
/>
<Section columns={24}>
<Article span={24}>
<Kicker>Capitol · Breaking</Kicker>
<Headline weight="High">Historic Accord Reshapes Continental Trade</Headline>
<Subhead weight="High">Negotiators emerge with sweeping framework on tariffs, labor, and emissions</Subhead>
<Byline>By Eleanor Whitcombe</Byline>
<Dateline>Brussels</Dateline>
<BodyText weight="High" columns={2}>
<p>After eleven consecutive days of negotiation, delegates from twenty-three nations announced a sweeping framework to reorganize commerce across the continent. The accord would harmonize tariff schedules, set common labor standards, and bind signatories to a shared emissions pathway through 2040.</p>
<p>Officials briefed on the talks said the breakthrough came shortly before midnight, when a dispute over agricultural subsidies was resolved with a side letter granting transitional relief to producers in five smaller economies.</p>
<p>Markets reacted with measured optimism. The continental composite index closed up 1.2 percent, led by capital-goods makers expected to benefit from infrastructure investment.</p>
</BodyText>
<PullQuote weight="High" author="Margarethe Lindqvist, Chief Negotiator" align="center">
"A long argument that finally became a conversation."
</PullQuote>
<Rule variant="hairline" />
<Rule variant="double" />
<Rule variant="thick" />
<Caption credit="Photograph by Jane Doe / Pool">
Negotiators applaud after the final draft was approved Monday evening.
</Caption>
</Article>
</Section>
<Footer copyright="© 2026 The Daily Chronicle" edition="Late City Edition" />
</Layout>
</div>
);
}