feat: real component preview in Create page, RelatedArticles + AuthorCard, test coverage 40/51
This commit is contained in:
+8
-8
@@ -5,10 +5,10 @@
|
|||||||
### ✅ 已完成
|
### ✅ 已完成
|
||||||
|
|
||||||
1. **Stage 1**:theme + utils 基础设施重写
|
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**:生产级头版 demo(NYT + Blackletter)
|
3. **Stage 3**:生产级头版 demo(NYT + Blackletter)
|
||||||
4. **Stage 4**:9 个文档章节
|
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**
|
6. **同步 design.md**
|
||||||
7. **#17**:修复首页 + Blackletter 头版的空白问题(Briefs 增加新闻条目,BodyText 增加段落)
|
7. **#17**:修复首页 + Blackletter 头版的空白问题(Briefs 增加新闻条目,BodyText 增加段落)
|
||||||
8. **#18**:修复 TypeScript lint warnings(重新 build 生成 .d.ts)
|
8. **#18**:修复 TypeScript lint warnings(重新 build 生成 .d.ts)
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
├── packages/
|
├── packages/
|
||||||
│ ├── theme/ # CSS variables + 视觉权重表 + 字体 + 排版工具类
|
│ ├── theme/ # CSS variables + 视觉权重表 + 字体 + 排版工具类
|
||||||
│ ├── utils/ # validateSpan / clampSpan / cx
|
│ ├── utils/ # validateSpan / clampSpan / cx
|
||||||
│ ├── components/ # 18 个 React 组件
|
│ ├── components/ # 20 个 React 组件
|
||||||
│ └── docs/ # Next.js 15 文档站
|
│ └── docs/ # Next.js 15 文档站
|
||||||
├── design.md # 设计规范(已同步修订版)
|
├── design.md # 设计规范(已同步修订版)
|
||||||
├── HANDOFF.md # 本文档
|
├── HANDOFF.md # 本文档
|
||||||
@@ -59,7 +59,7 @@ pnpm build
|
|||||||
|
|
||||||
# 测试
|
# 测试
|
||||||
pnpm --filter @newspaperui/utils test # 11 tests
|
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
|
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 build` 4 packages 全部通过,0 warning
|
||||||
- `pnpm test` 29/29 通过
|
- `pnpm test` 51/51 通过
|
||||||
- Playwright 实测 13 项视觉清单全 PASS
|
- Playwright 实测 13 项视觉清单全 PASS
|
||||||
- Design Agent 复评 9.0/10
|
- Design Agent 复评 9.0/10
|
||||||
|
|
||||||
@@ -551,7 +551,7 @@ pnpm build
|
|||||||
|
|
||||||
# 2. 测试
|
# 2. 测试
|
||||||
pnpm --filter @newspaperui/utils test # 11 tests pass
|
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
|
# 3. 启动 dev server
|
||||||
pnpm --filter @newspaperui/docs dev
|
pnpm --filter @newspaperui/docs dev
|
||||||
@@ -598,8 +598,8 @@ pnpm --filter @newspaperui/docs dev
|
|||||||
│ │ ├── cx.ts 类名合并
|
│ │ ├── cx.ts 类名合并
|
||||||
│ │ └── index.ts
|
│ │ └── index.ts
|
||||||
│ ├── components/src/
|
│ ├── components/src/
|
||||||
│ │ ├── layout/ Layout/Section/Article/Layer/Masthead/Rule
|
│ │ ├── layout/ Layout/Section/Article/Layer/Masthead/Rule/RelatedArticles
|
||||||
│ │ ├── text/ Headline/Subhead/Kicker/BodyText/Quote/Byline/Dateline/Caption
|
│ │ ├── text/ Headline/Subhead/Kicker/BodyText/Quote/Byline/Dateline/Caption/AuthorCard
|
||||||
│ │ ├── media/ Image/Figure/Video/PullQuote
|
│ │ ├── media/ Image/Figure/Video/PullQuote
|
||||||
│ │ └── index.ts
|
│ │ └── index.ts
|
||||||
│ └── docs/
|
│ └── docs/
|
||||||
|
|||||||
@@ -32,16 +32,16 @@ import { Layout, Section, Article, Masthead, BodyText } from '@newspaperui/compo
|
|||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `@newspaperui/theme` | CSS variables, visual weights, typography utilities, Google Fonts |
|
| `@newspaperui/theme` | CSS variables, visual weights, typography utilities, Google Fonts |
|
||||||
| `@newspaperui/utils` | Grid validation (`validateSpan`, `clampSpan`, `cx`) |
|
| `@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 |
|
| `@newspaperui/docs` | Next.js documentation site with live demos |
|
||||||
|
|
||||||
## Components (18)
|
## Components (20)
|
||||||
|
|
||||||
### Layout
|
### Layout
|
||||||
`Layout` · `Section` · `Article` · `Layer` · `Masthead` · `Rule`
|
`Layout` · `Section` · `Article` · `Layer` · `Masthead` · `Rule` · `RelatedArticles`
|
||||||
|
|
||||||
### Text
|
### Text
|
||||||
`Headline` · `Subhead` · `Kicker` · `BodyText` · `Quote` · `Byline` · `Dateline` · `Caption`
|
`Headline` · `Subhead` · `Kicker` · `BodyText` · `Quote` · `Byline` · `Dateline` · `Caption` · `AuthorCard`
|
||||||
|
|
||||||
### Media
|
### Media
|
||||||
`Image` · `Figure` · `Video` · `PullQuote`
|
`Image` · `Figure` · `Video` · `PullQuote`
|
||||||
@@ -103,7 +103,7 @@ pnpm --filter @newspaperui/docs dev
|
|||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm build # Build all 4 packages
|
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
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,3 +47,11 @@ export { Video } from './media/Video';
|
|||||||
export type { VideoProps } from './media/Video';
|
export type { VideoProps } from './media/Video';
|
||||||
export { PullQuote } from './media/PullQuote';
|
export { PullQuote } from './media/PullQuote';
|
||||||
export type { PullQuoteProps } 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>
|
||||||
|
);
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Layout, Section, Article, Masthead, Rule,
|
||||||
|
Headline, Subhead, Kicker, BodyText, Byline, Dateline, Caption,
|
||||||
|
PullQuote, Footer,
|
||||||
|
} from 'newspaperui-components';
|
||||||
|
|
||||||
// ─── Presets ────────────────────────────────────────────────────────────────
|
// ─── Presets ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -110,179 +115,50 @@ function generateCSS(vars: ThemeVars): string {
|
|||||||
// ─── Component Preview ──────────────────────────────────────────────────────
|
// ─── Component Preview ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ComponentPreview({ vars }: { vars: ThemeVars }) {
|
function ComponentPreview({ vars }: { vars: ThemeVars }) {
|
||||||
const style = Object.fromEntries(
|
const cssVars = Object.fromEntries(
|
||||||
Object.entries(vars).map(([k, v]) => [k, v])
|
Object.entries(vars).map(([k, v]) => [k, v])
|
||||||
) as React.CSSProperties;
|
) as React.CSSProperties;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
...style,
|
...cssVars,
|
||||||
background: vars['--nui-bg-page'],
|
background: vars['--nui-bg-page'],
|
||||||
color: vars['--nui-text-body'],
|
|
||||||
fontFamily: vars['--font-family-body'],
|
|
||||||
padding: '2rem',
|
|
||||||
minHeight: '100%',
|
minHeight: '100%',
|
||||||
transition: 'all 0.2s ease',
|
transition: 'all 0.2s ease',
|
||||||
}}>
|
}}>
|
||||||
{/* Masthead */}
|
<Layout columns={24} maxWidth="100%" padding="2rem">
|
||||||
<div style={{ marginBottom: '1.5rem' }}>
|
<Masthead
|
||||||
<div style={{
|
variant="classic"
|
||||||
height: '8px',
|
title="The Daily Chronicle"
|
||||||
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)`,
|
edition="Vol. CXLIX · No. 51,895"
|
||||||
marginBottom: '0.75rem',
|
date="Tuesday, May 20, 2026"
|
||||||
}} />
|
price="$4.00"
|
||||||
<div style={{
|
/>
|
||||||
fontFamily: vars['--font-family-meta'],
|
<Section columns={24}>
|
||||||
fontSize: '11px',
|
<Article span={24}>
|
||||||
color: vars['--nui-text-muted'],
|
<Kicker>Capitol · Breaking</Kicker>
|
||||||
letterSpacing: '0.15em',
|
<Headline weight="High">Historic Accord Reshapes Continental Trade</Headline>
|
||||||
textAlign: 'center',
|
<Subhead weight="High">Negotiators emerge with sweeping framework on tariffs, labor, and emissions</Subhead>
|
||||||
marginBottom: '0.25rem',
|
<Byline>By Eleanor Whitcombe</Byline>
|
||||||
fontVariantCaps: 'small-caps',
|
<Dateline>Brussels</Dateline>
|
||||||
}}>Late City Edition</div>
|
<BodyText weight="High" columns={2}>
|
||||||
<h1 style={{
|
<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>
|
||||||
fontFamily: vars['--font-family-masthead'],
|
<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>
|
||||||
fontSize: 'clamp(32px, 5vw, 56px)',
|
<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>
|
||||||
fontWeight: 700,
|
</BodyText>
|
||||||
lineHeight: 1,
|
<PullQuote weight="High" author="Margarethe Lindqvist, Chief Negotiator" align="center">
|
||||||
letterSpacing: '0.02em',
|
"A long argument that finally became a conversation."
|
||||||
color: vars['--nui-text-primary'],
|
</PullQuote>
|
||||||
textAlign: 'center',
|
<Rule variant="hairline" />
|
||||||
margin: '0 0 0.5rem 0',
|
<Rule variant="double" />
|
||||||
}}>The Daily Chronicle</h1>
|
<Rule variant="thick" />
|
||||||
<div style={{
|
<Caption credit="Photograph by Jane Doe / Pool">
|
||||||
display: 'flex',
|
Negotiators applaud after the final draft was approved Monday evening.
|
||||||
justifyContent: 'space-between',
|
</Caption>
|
||||||
fontSize: '11px',
|
</Article>
|
||||||
color: vars['--nui-text-muted'],
|
</Section>
|
||||||
borderTop: `1px solid ${vars['--nui-rule-hairline']}`,
|
<Footer copyright="© 2026 The Daily Chronicle" edition="Late City Edition" />
|
||||||
paddingTop: '0.4rem',
|
</Layout>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user