This commit is contained in:
sunzhongyi
2026-05-20 01:30:41 +08:00
parent f3e6b95be9
commit 7dded89537
44 changed files with 1166 additions and 3699 deletions
+159 -81
View File
@@ -2,142 +2,220 @@
# AI Agent Prompt: 生产级报纸组件库开发
## 任务目标
开发一个浏览器端生产级报纸布局组件库,参考 InDesign,组件少而精、职责明确、无冗余、无歧义,支持全局 24 列栅格、跨栏、视觉权重和主题系统,提供文档网站(内嵌 demo,无需 storybook)。
开发一个浏览器端生产级报纸布局组件库,参考 InDesign 与经典严肃风(NYT / The Times)排版传统,组件少而精、职责明确、无冗余、无歧义,支持全局 24 列栅格、跨栏、视觉权重和主题系统,提供文档网站(内嵌 demo,无需 storybook)。
视觉基调:经典严肃风(衬线为主、暖灰系、warm off-white 背景、双线分隔、首字下沉、栏间细线、真实多栏文字流)。
---
## 1. 项目 setup 与架构
- **Monorepo 管理**pnpm workspace / Turborepo
- **Monorepo 管理**pnpm workspace + Turborepo
- Packages:
1. `components` → Layout / Section / Article / Layer / Text / Media
2. `theme`全局主题变量、字体、颜色、间距、视觉权重映射
3. `docs` → 文档网站(shadcn stack + demo
4. `utils` → 栅格计算、跨栏逻辑、响应式辅助函数
1. `components` → Layout / Section / Article / Layer / Masthead / Rule / Headline / Subhead / Kicker / BodyText / Quote / Byline / Dateline / Caption / Image / Figure / Video / PullQuote
2. `theme`字体导入、CSS variables、视觉权重映射、Tailwind tokens、排版工具类
3. `docs` → 文档网站(Next.js + Tailwind + MDX
4. `utils` → 栅格校验(`validateSpan` / `clampSpan`)、`cx` 类名合并
- **目录结构示例**:
- **目录结构**:
```
/packages
/components
/Layout
/Section
/Article
/Layer
/Text
/Media
/theme
/docs
/utils
/components
/src
/layout (Layout / Section / Article / Layer / Masthead / Rule)
/text (Headline / Subhead / Kicker / BodyText / Quote / Byline / Dateline / Caption)
/media (Image / Figure / Video / PullQuote)
/theme
/src
fonts.css (Google Fonts: Cormorant Garamond / Source Serif 4 / Inter / UnifrakturMaguntia)
variables.css (CSS variables: 字体 / 暖灰系颜色 / 间距 / 分隔线 / 强调色)
typography.css (drop-cap / small-caps / paragraph-flow / OSF / column-rule 等工具类)
visual-weights.ts
tailwind.config.js
/docs
/utils
```
- **技术栈**:
- React 18+, TypeScript
- TailwindCSS + shadcn 文档 stack
- React 18+, TypeScript 5+
- TailwindCSS 3+ + 文档站基础 stack
- Vitest + React Testing Library
- 构建工具Vite + Rollup
- 构建:Vite + Rollupcomponents/theme/utils);Next.js 14+docs
---
## 2. 全局栅格系统
- `<Layout>` 顶层容器:
- maxWidth, padding, gutter, theme
- 栅格总列数 24
- snap-to-grid 支持
- Section 内对象 span ≤ Section.columns
- 响应式调整:小屏 1216 列
- `columns`(默认 24)、`maxWidth``padding``theme`'light' | 'dark'
- 通过 React Context 把 columns 下传给 `<Section>`
- `<Section>` 内部使用 `display: grid; grid-template-columns: repeat(N, 1fr); gap: var(--nui-gutter)`
- Section 内对象通过 `grid-column: span N` 跨栏,统一布局机制
- 响应式:通过 CSS media query / container query 调整(不再使用 JS 路径)
---
## 3. 核心布局组件
1. `<Section>`
- columns, breakable, padding/margin, priority
- 内部对象 span ≤ Section.columns
- `columns`(必填)、`gap``breakable``divider`'none' | 'top' | 'bottom' | 'both'
- 内部对象 span ≤ Section.columns,超过自动 clamp 并 console.warn
2. `<Article>`
- span, priority/weight, breakable
- 内含 Headline / Subhead / BodyText / Image / PullQuote
- `span``breakable`
- 跨栏统一通过 `grid-column: span N`
3. `<Layer>`
- position, top/left/right/bottom, zIndex
- 用于浮动广告、拉引、浮动图片
- `position`'absolute' | 'fixed' | 'sticky')、`top/left/right/bottom``zIndex`
4. `<Masthead>` 报头组件:
- `title``kicker``edition``date``price`
- `variant`'classic'(双线居中 + Cormorant Garamond)、'blackletter'UnifrakturMaguntia 哥特体)、'modern'(左对齐 + accent 色)
5. `<Rule>` 分隔线组件:
- `variant`'hairline' | 'double' | 'thick'
- `orientation`'horizontal' | 'vertical'
- `span`(横向时占多少列,默认 `1 / -1`
---
## 4. 内容组件
- 文本类:Headline / Subhead / BodyText / Quote / Byline / Caption
- 媒体类:Image / Figure / Video / PullQuote
- 属性:span(跨栏)、weightHigh/Medium/Low)、margin/padding
- 所有 span 基于 Section.columns
- 文本类:`<Headline>` / `<Subhead>` / `<Kicker>` / `<BodyText>` / `<Quote>` / `<Byline>` / `<Dateline>` / `<Caption>`
- 媒体类:`<Image>` / `<Figure>` / `<Video>` / `<PullQuote>`
- 通用属性:`weight``span`(跨栏,由 Section 的 grid-column 提供)、`className``style`
- `<BodyText>` 核心扩展:
- `columns?: 1 | 2 | 3 | 4` 启用 CSS multi-column 真实多栏文字流
- `dropCap?: boolean` 首字下沉
- 默认开启段间无空行 + 第二段起首行缩进 1em + old-style figures + hanging punctuation
- `<Quote>``variant: 'block' | 'inline'`block 用左缩进 1.5em(不使用左 border
- `<PullQuote>`:上下双 hairline + Display 字体;`spanAllColumns` 在多栏 BodyText 内自动跨所有栏
- `<Byline>` / `<Dateline>` / `<Kicker>`:使用真小帽(OpenType `font-variant-caps: small-caps`,非 `text-transform: uppercase`
---
## 5. 视觉权重映射表(24 列
## 5. 视觉权重映射表(修订版
| 组件 | 权重 | font-size | font-weight | line-height | color | span | margin/padding |
|---|---|---|---|---|---|---|---|
| Headline | High | 3648px | 700 | 1.1 | #111 | 68 | 0 0 1rem 0 |
| Headline | Medium | 2834px | 600 | 1.2 | #111 | 46 | 0 0 0.75rem 0 |
| Headline | Low | 2226px | 500 | 1.3 | #222 | 24 | 0 0 0.5rem 0 |
| Subhead | High | 2024px | 600 | 1.25 | #222 | 23 | 0 0 0.5rem 0 |
| Subhead | Medium | 1618px | 500 | 1.3 | #333 | 12 | 0 0 0.25rem 0 |
| BodyText | High | 16px | 400 | 1.5 | #333 | 1 | 0 0 1rem 0 |
| BodyText | Medium | 1415px | 400 | 1.5 | #444 | 1 | 0 0 0.75rem 0 |
| BodyText | Low | 1214px | 400 | 1.4 | #555 | 1 | 0 0 0.5rem 0 |
| Quote | High | 2024px | 500 | 1.4 | #222 | 2 | 0 0 0.75rem 0 |
| Quote | Medium | 1618px | 400 | 1.4 | #333 | 1 | 0 0 0.5rem 0 |
| Image Caption | Standard | 1214px | 400 | 1.3 | #555 | 1 | 0.25rem 0 |
| PullQuote | High | 2428px | 600 | 1.2 | #111 | 23 | 0 0 0.5rem 0 |
| PullQuote | Medium | 1820px | 500 | 1.25 | #222 | 12 | 0 0 0.25rem 0 |
| Byline | Standard | 1214px | 400 | 1.3 | #555 | 1 | 0 0 0.25rem 0 |
经典严肃风修订要点:
- 字号上限放开(Headline High 4872px,原 3648px 太小压不住头版)
- font-weight 普遍下调(High 700 → 600Medium 600 → 500),避免数码黑
- line-height 衬线上调(BodyText 1.551.6
- 颜色使用暖灰系 token`--nui-text-primary` `#1A1A1A` 而非 `#111`
- 颜色 / 字体均通过 CSS variable token,不硬编码 hex
- 新增 4 档:Masthead / Kicker / Dateline / Caption credit
| 组件 | 权重 | font-family | font-size | font-weight | line-height | letter-spacing | font-variant | color | span | margin |
|---|---|---|---|---|---|---|---|---|---|---|
| Masthead | Standard | `--font-family-masthead` | 5696px | 700 | 1.0 | 0.02em | lining-nums | `--nui-text-primary` | 24 | 0 |
| Headline | High | `--font-family-display` | 4872px | 600 | 1.05 | -0.01em | lining-nums + balance | `--nui-text-primary` | 816 | `0 0 1rem 0` |
| Headline | Medium | `--font-family-headline` | 3240px | 600 | 1.1 | -0.005em | lining-nums | `--nui-text-primary` | 610 | `0 0 0.75rem 0` |
| Headline | Low | `--font-family-headline` | 2226px | 500 | 1.2 | 0 | lining-nums | `--nui-text-body` | 46 | `0 0 0.5rem 0` |
| Subhead | High | `--font-family-headline` italic | 1822px | 500 | 1.3 | 0 | oldstyle-nums | `--nui-text-secondary` | 48 | `0 0 0.75rem 0` |
| Subhead | Medium | `--font-family-headline` italic | 1618px | 400 | 1.35 | 0 | oldstyle-nums | `--nui-text-secondary` | 24 | `0 0 0.5rem 0` |
| Kicker | Standard | `--font-family-meta` small-caps | 1213px | 600 | 1.2 | 0.08em | small-caps + lining | `--nui-accent-primary` | 14 | `0 0 0.25rem 0` |
| BodyText | High | `--font-family-body` | 1617px | 400 | 1.6 | 0 | oldstyle-nums | `--nui-text-body` | 1 | `0 0 0.75rem 0` |
| BodyText | Medium | `--font-family-body` | 15px | 400 | 1.55 | 0 | oldstyle-nums | `--nui-text-body` | 1 | `0 0 0.5rem 0` |
| BodyText | Low | `--font-family-body` | 1314px | 400 | 1.5 | 0 | oldstyle-nums | `--nui-text-secondary` | 1 | `0 0 0.5rem 0` |
| Quote (block) | High | `--font-family-body` | 1719px | 400 | 1.55 | 0 | oldstyle-nums | `--nui-text-quote` | 2 | `1rem 0 1rem 1.5em` |
| Quote (inline) | — | `--font-family-body` italic | inherit | 400 | inherit | 0 | inherit | inherit | — | 0 |
| PullQuote | High | `--font-family-display` | 2632px | 600 | 1.2 | -0.005em | lining-nums + balance | `--nui-text-primary` | 23 | `1.5rem 0` |
| PullQuote | Medium | `--font-family-display` | 2024px | 500 | 1.25 | 0 | lining-nums | `--nui-text-body` | 12 | `1rem 0` |
| Caption | Standard | `--font-family-body` italic | 13px | 400 | 1.4 | 0 | oldstyle-nums | `--nui-text-secondary` | 1 | `0.5rem 0 0 0` |
| Caption credit | — | `--font-family-meta` small-caps | 1112px | 500 | 1.4 | 0.06em | small-caps | `--nui-text-muted` | 1 | inline |
| Byline | Standard | `--font-family-meta` small-caps | 1213px | 500 | 1.3 | 0.06em | small-caps + lining | `--nui-text-secondary` | 1 | `0 0 0.25rem 0` |
| Dateline | Standard | `--font-family-meta` small-caps | 1213px | 600 | 1.3 | 0.08em | small-caps + lining | `--nui-text-primary` | 1 | inline |
---
## 6. 主题系统
- 全局新闻字体预设
- 颜色变量(黑/灰/强调色
- 间距变量(gutter, margin, padding
- 权重映射 Token
- Tailwind + CSS Variables 支持主题切换
### 6.1 字体(Google Fonts,免费可商用)
- `--font-family-masthead`Cormorant Garamond(报头主字
- `--font-family-blackletter`UnifrakturMaguntia(哥特报头 preset
- `--font-family-display` / `--font-family-headline` / `--font-family-body`Source Serif 4Optical Size 可变字体,覆盖头条到正文)
- `--font-family-meta`InterByline / Dateline / Kicker / Caption credit 用真小帽)
### 6.2 颜色(暖灰系,避开纯黑/纯白)
Light(默认):
- 页面背景:`--nui-bg-page #F7F4ED``--nui-bg-surface #FBF9F4`
- 文字:`--nui-text-primary #1A1A1A``--nui-text-body #22201C``--nui-text-secondary #4A4742``--nui-text-muted #6E6A63``--nui-text-quote #2E2A24`
- 分隔线:`--nui-rule-hairline #C9C2B2``--nui-rule-decorative #1A1A1A`
- 强调色:`--nui-accent-primary #7A1F1F`NYT 朱红)、`--nui-accent-ink-blue #1B2A4A`The Times 蓝)、`--nui-highlight #F2E9C8`(旧报纸黄)
Dark(暖深棕黑,非冷黑):
- 反向映射保持暖意,accent 色提亮 + 降饱和
### 6.3 间距 / 排版工具类
- 间距 token`--nui-gutter``--nui-space-{1,2,3,4,6,8}`
- 排版工具类(typography.css):
- `.nui-drop-cap` 首字下沉(`::first-letter` + `float: left`
- `.nui-small-caps` 真小帽(OpenType `smcp/c2sc`
- `.nui-paragraph-flow` 段间无空行 + `p + p` 首行缩进 1em
- `.nui-osf` old-style figures、`.nui-tnum` tabular figures
- `.nui-hanging-punctuation` 悬挂引号
- `.nui-column-rule` 栏间 hairline
- `.nui-avoid-break` `break-inside: avoid`
- `.nui-span-all-columns` `column-span: all`(多栏内跨栏)
- `.nui-masthead-rule-top` / `.nui-masthead-rule-bottom` 报头双线
### 6.4 主题切换
通过 `[data-theme="dark"]` 覆盖 CSS variables,组件代码无需感知主题;提供手动切换按钮。
---
## 7. 布局规则
- Section 内对象 span ≤ Section.columns
- 高权重对象可跨多栏
- Layer 浮动/绝对定位
- breakable 控制分页断开
- 响应式:小屏 1216 列
- Section 内对象 span ≤ Section.columns,超过自动 clamp + console.warn(不抛异常崩溃)
- 高权重对象可跨多栏Headline High 跨 816 列)
- Layer 浮动/绝对定位,脱离栅格流
- `breakable` 控制打印分页断开`break-inside: auto | avoid`
- BodyText 多栏:`columns: 1 | 2 | 3 | 4` 启用 CSS multi-column;通过 `column-rule: 1px solid var(--nui-rule-hairline)` 渲染栏间细线;标题与 PullQuote 自动 `break-inside: avoid` 避免被切断
- 响应式:通过 CSS media query / container query 在小屏调整列数与栏宽(不使用 JS 路径)
---
## 8. 文档网站
- 技术栈:shadcn stack + Next.js + Tailwind + MDX
- Demo 内嵌组件,展示属性、span、视觉权重、跨栏效果
- 文档章节
1. 概览与目标
2. 栅格系统与 Layout/Section 说明
3. 核心组件(Article/Image/Layer)属性说明
4. 文本组件属性与权重映射
5. 主题与变量使用指南
6. 跨栏和浮动 Layer 示例
7. 响应式布局展示
- 技术栈:Next.js + Tailwind + MDX
- 页面:所有 demo 用真实组件渲染,非 mockup
- 路由结构
1. `/` 首页 = NYT 派完整报纸头版(Masthead + Briefs + Lead Story 含 hero image + 3 栏 BodyText + drop cap + PullQuote + Secondary + Third Story 4 栏)
2. `/grid-system` 栅格系统与 Layout/Section/Article/Layer 说明
3. `/components/masthead` Masthead 三种 variant 对照
4. `/components/article` Article + Layer 跨栏与浮动 demo
5. `/components/rule` Rule 三种 variant 对照
6. `/components/media` Image / Figure / Video / PullQuote
7. `/text` 文本组件全集 + 视觉权重映射表(实时渲染)+ 多栏文字流 demo + drop cap demo
8. `/theme` CSS variables 列表 + color swatch + 暗色主题切换按钮
9. `/examples/spanning` 跨栏布局示例
10. `/examples/responsive` 响应式布局展示
11. `/examples/blackletter-frontpage` The Times / FAZ 派哥特体头版 preset
---
## 9. 实施顺序
1. 初始化 monorepo + Layout / Section 基础组件
2. 配置主题系统、视觉权重映射 CSS / Tailwind Token
3. 开发 Article / Image / Layer / PullQuote / 文本组件
4. 实现 Section 网格跨栏逻辑
5. 测试生产级报纸布局:跨栏、浮动 Layer、breakable、响应式
6. 构建文档网站,内嵌 demo 展示组件属性与效果
7. 确认生产级排版效果符合视觉权重、跨栏、主题、响应式要求
1. 初始化 monorepopnpm workspace + Turborepo
2. 实现 theme(字体导入 + 暖灰系 CSS variables + 视觉权重表 + 排版工具类 + Tailwind tokens
3. 实现 utils`validateSpan` / `clampSpan` / `cx`
4. 实现 components 三层(layout / text / media);统一 grid-column span 跨栏机制;BodyText 接入 CSS multi-column
5. 测试生产级头版:跨栏、多栏文字流、首字下沉、栏间 hairline、真小帽、Pull Quote 跨栏、breakable、响应式
6. 构建文档网站,首页直接展示 NYT 派头版作为生产级标杆,章节页用真实组件 demo
7. 验收:视觉与传统报纸排版(NYT / The Times / FAZ)一致;自动化构建/测试通过;Design Agent 复评 ≥ 9/10
---
## 10. 验收标准
打开首页应满足:
- Masthead 是 Cormorant Garamond 衬线 + 双线分隔
- 头条字号 ≥ 48px、line-height ≤ 1.15、字重 600
- 头条下方 BodyText 真分 3 栏,栏间有 1px 暖灰 hairline
- 第一段开头有真实 drop cap(占 2–3 行)
- Byline / Dateline / Kicker 是 Inter 真小帽(不是 uppercase 伪小帽)
- PullQuote 上下双 hairline、字号 2632px
- 配色是 warm off-white + 暖深灰,不是 `#FFFFFF` + `#000000`
- 数字是 old-style figures3 / 5 / 7 有下伸笔)
- 段间无空行,第二段起首行缩进 1em
- 切换 `[data-theme="dark"]` 后是暖深棕黑(不是冷黑)
@@ -1,35 +0,0 @@
import React from 'react';
import { useSection } from '../Section/Section';
export interface ArticleProps {
span: number;
priority?: 'High' | 'Medium' | 'Low';
breakable?: boolean;
children: React.ReactNode;
}
export const Article: React.FC<ArticleProps> = ({
span,
priority = 'Medium',
breakable = true,
children,
}) => {
const section = useSection();
const effectiveSpan = Math.min(Math.max(1, span), section.columns);
if (span !== effectiveSpan) {
console.warn(`[Article] span=${span} exceeds section.columns=${section.columns}, clamped to ${effectiveSpan}`);
}
return (
<article
className={`newspaper-article priority-${priority.toLowerCase()}`}
style={{
gridColumn: `span ${effectiveSpan}`,
breakInside: breakable ? 'auto' : 'avoid',
}}
data-span={span}
>
{children}
</article>
);
};
-37
View File
@@ -1,37 +0,0 @@
import React from 'react';
export interface LayerProps {
position: 'absolute' | 'fixed' | 'sticky';
top?: string;
left?: string;
right?: string;
bottom?: string;
zIndex?: number;
children: React.ReactNode;
}
export const Layer: React.FC<LayerProps> = ({
position,
top,
left,
right,
bottom,
zIndex = 10,
children,
}) => {
return (
<div
className="newspaper-layer"
style={{
position,
top,
left,
right,
bottom,
zIndex,
}}
>
{children}
</div>
);
};
+31 -39
View File
@@ -1,47 +1,39 @@
import React, { createContext, useContext } from 'react';
'use client';
import React, { createContext, useContext, ReactNode, CSSProperties } from 'react';
export interface LayoutProps {
maxWidth?: string;
padding?: string;
gutter?: number;
columns?: number; // 默认 24
maxWidth?: string; // 默认 '1280px'
padding?: string; // 默认 'var(--nui-space-6)'
theme?: 'light' | 'dark';
columns?: number;
children: React.ReactNode;
className?: string;
style?: CSSProperties;
children: ReactNode;
}
interface LayoutContextValue {
columns: number;
gutter: number;
}
const LayoutContext = createContext<LayoutContextValue>({
columns: 24,
gutter: 16,
});
interface LayoutContextValue { columns: number; }
const LayoutContext = createContext<LayoutContextValue>({ columns: 24 });
export const useLayout = () => useContext(LayoutContext);
export const Layout: React.FC<LayoutProps> = ({
maxWidth = '1440px',
padding = '1rem',
gutter = 16,
theme = 'light',
columns = 24,
children,
}) => {
return (
<LayoutContext.Provider value={{ columns, gutter }}>
<div
className={`newspaper-layout ${theme === 'dark' ? 'dark' : ''}`}
style={{
maxWidth,
padding,
margin: '0 auto',
'--gutter': `${gutter}px`,
} as React.CSSProperties}
>
{children}
</div>
</LayoutContext.Provider>
);
};
columns = 24, maxWidth = '1280px', padding = 'var(--nui-space-6)',
theme, className, style, children,
}) => (
<LayoutContext.Provider value={{ columns }}>
<div
data-theme={theme}
className={className}
style={{
maxWidth,
margin: '0 auto',
padding,
background: 'var(--nui-bg-page)',
color: 'var(--nui-text-body)',
fontFamily: 'var(--font-family-body)',
...style,
}}
>
{children}
</div>
</LayoutContext.Provider>
);
+18 -26
View File
@@ -1,43 +1,35 @@
import React from 'react';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
import { Caption } from '../Text/Caption';
'use client';
import React, { CSSProperties } from 'react';
import { clampSpan, cx } from '@newspaperui/utils';
import { useSection } from '../layout/Section';
import { Caption } from '../text/Caption';
export interface FigureProps {
src: string;
alt: string;
caption?: string;
credit?: string;
span?: number;
children?: React.ReactNode;
className?: string;
style?: CSSProperties;
}
export const Figure: React.FC<FigureProps> = ({
src,
alt,
caption,
span = 1,
children,
src, alt, caption, credit, span, className, style,
}) => {
const section = useSection();
const width = calculateSpanWidth(span, section.columns);
const cols = span ? clampSpan(span, section.columns) : undefined;
return (
<figure
className="newspaper-figure"
style={{ width, margin: 0 }}
data-span={span}
className={cx('nui-figure nui-avoid-break', className)}
style={{
margin: 0,
gridColumn: cols ? `span ${cols}` : undefined,
...style,
}}
>
<img
src={src}
alt={alt}
style={{
width: '100%',
height: 'auto',
display: 'block',
}}
/>
{caption && <Caption>{caption}</Caption>}
{children}
<img src={src} alt={alt} style={{ display: 'block', width: '100%', height: 'auto' }} />
{(caption || credit) && <Caption credit={credit}>{caption}</Caption>}
</figure>
);
};
+20 -31
View File
@@ -1,42 +1,31 @@
import React from 'react';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
import { Caption } from '../Text/Caption';
'use client';
import React, { CSSProperties } from 'react';
import { clampSpan, cx } from '@newspaperui/utils';
import { useSection } from '../layout/Section';
export interface ImageProps {
src: string;
alt: string;
span?: number;
caption?: string;
priority?: 'High' | 'Medium' | 'Low';
className?: string;
style?: CSSProperties;
}
export const Image: React.FC<ImageProps> = ({
src,
alt,
span = 1,
caption,
priority = 'Medium',
}) => {
export const Image: React.FC<ImageProps> = ({ src, alt, span, className, style }) => {
const section = useSection();
const width = calculateSpanWidth(span, section.columns);
const cols = span ? clampSpan(span, section.columns) : undefined;
return (
<div
className={`newspaper-image priority-${priority.toLowerCase()}`}
style={{ width }}
data-span={span}
>
<img
src={src}
alt={alt}
style={{
width: '100%',
height: 'auto',
display: 'block',
}}
/>
{caption && <Caption>{caption}</Caption>}
</div>
<img
src={src}
alt={alt}
className={cx('nui-image', className)}
style={{
display: 'block',
width: '100%',
height: 'auto',
gridColumn: cols ? `span ${cols}` : undefined,
...style,
}}
/>
);
};
+44 -35
View File
@@ -1,61 +1,70 @@
import React from 'react';
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
import { clampSpan, cx } from '@newspaperui/utils';
import { useSection } from '../layout/Section';
export interface PullQuoteProps {
weight?: 'High' | 'Medium';
span?: number;
spanAllColumns?: boolean; // 在多栏 BodyText 内跨所有栏
author?: string;
children: React.ReactNode;
align?: 'left' | 'center';
className?: string;
style?: CSSProperties;
children: ReactNode;
}
export const PullQuote: React.FC<PullQuoteProps> = ({
weight = 'High',
span,
author,
children,
weight = 'High', span, spanAllColumns = false, author, align = 'left',
className, style, children,
}) => {
const section = useSection();
const config = visualWeights.PullQuote[weight];
if (!config) {
throw new Error(`Invalid weight: ${weight} for PullQuote`);
}
const finalSpan = span || (Array.isArray(config.span) ? config.span[0] : config.span);
const width = calculateSpanWidth(finalSpan, section.columns);
const config = visualWeights.PullQuote[weight]!;
const cols = span ? clampSpan(span, section.columns) : undefined;
return (
<aside
className="newspaper-pull-quote"
className={cx(
'nui-pullquote nui-avoid-break nui-tnum',
spanAllColumns && 'nui-span-all-columns',
className,
)}
style={{
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
color: config.color,
margin: config.margin,
width,
padding: 'var(--nui-padding-lg, 1rem)',
borderLeft: '4px solid var(--nui-color-accent-primary, currentColor)',
padding: 'var(--nui-space-4) 0',
borderTop: '1px solid var(--nui-rule-hairline)',
borderBottom: '1px solid var(--nui-rule-hairline)',
textAlign: align,
gridColumn: cols ? `span ${cols}` : undefined,
...style,
}}
data-weight={weight}
data-span={finalSpan}
>
<blockquote style={{ margin: 0 }}>
<p
style={{
fontFamily: `var(${config.fontFamily})`,
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
letterSpacing: config.letterSpacing,
color: `var(${config.color})`,
margin: 0,
textWrap: 'balance' as CSSProperties['textWrap'],
}}
>
{children}
</blockquote>
</p>
{author && (
<cite
<footer
className="nui-small-caps"
style={{
display: 'block',
marginTop: '0.5rem',
fontSize: '0.875em',
fontStyle: 'normal',
marginTop: 'var(--nui-space-2)',
fontSize: '11px',
color: 'var(--nui-text-muted)',
letterSpacing: '0.08em',
}}
>
{author}
</cite>
</footer>
)}
</aside>
);
+18 -27
View File
@@ -1,41 +1,32 @@
import React from 'react';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
import { Caption } from '../Text/Caption';
'use client';
import React, { CSSProperties } from 'react';
import { clampSpan, cx } from '@newspaperui/utils';
import { useSection } from '../layout/Section';
import { Caption } from '../text/Caption';
export interface VideoProps {
src: string;
poster?: string;
span?: number;
caption?: string;
credit?: string;
span?: number;
controls?: boolean;
className?: string;
style?: CSSProperties;
}
export const Video: React.FC<VideoProps> = ({
src,
poster,
span = 1,
caption,
src, poster, caption, credit, span, controls = true, className, style,
}) => {
const section = useSection();
const width = calculateSpanWidth(span, section.columns);
const cols = span ? clampSpan(span, section.columns) : undefined;
return (
<div
className="newspaper-video"
style={{ width }}
data-span={span}
<figure
className={cx('nui-video nui-avoid-break', className)}
style={{ margin: 0, gridColumn: cols ? `span ${cols}` : undefined, ...style }}
>
<video
src={src}
poster={poster}
controls
style={{
width: '100%',
height: 'auto',
display: 'block',
}}
/>
{caption && <Caption>{caption}</Caption>}
</div>
<video src={src} poster={poster} controls={controls} style={{ width: '100%', height: 'auto' }} />
{(caption || credit) && <Caption credit={credit}>{caption}</Caption>}
</figure>
);
};
@@ -1,54 +0,0 @@
import React, { createContext, useContext } from 'react';
import { useLayout } from '../Layout/Layout';
export interface SectionProps {
columns: number;
breakable?: boolean;
padding?: string;
margin?: string;
priority?: 'High' | 'Medium' | 'Low';
children: React.ReactNode;
}
interface SectionContextValue {
columns: number;
}
const SectionContext = createContext<SectionContextValue>({ columns: 24 });
export const useSection = () => useContext(SectionContext);
export const Section: React.FC<SectionProps> = ({
columns,
breakable = true,
padding,
margin,
priority = 'Medium',
children,
}) => {
const layout = useLayout();
const effectiveColumns = Math.min(Math.max(1, columns), layout.columns);
if (columns !== effectiveColumns) {
console.warn(`[Section] columns=${columns} exceeds layout.columns=${layout.columns}, clamped to ${effectiveColumns}`);
}
return (
<SectionContext.Provider value={{ columns: effectiveColumns }}>
<section
className={`newspaper-section priority-${priority.toLowerCase()}`}
style={{
display: 'grid',
gridTemplateColumns: `repeat(${effectiveColumns}, 1fr)`,
gap: 'var(--nui-gutter, 1rem)',
padding,
margin,
breakInside: breakable ? 'auto' : 'avoid',
}}
data-columns={columns}
>
{children}
</section>
</SectionContext.Provider>
);
};
+37 -21
View File
@@ -1,44 +1,60 @@
import React from 'react';
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
import { clampSpan, cx } from '@newspaperui/utils';
import { useSection } from '../layout/Section';
export interface BodyTextProps {
weight?: 'High' | 'Medium' | 'Low';
span?: number;
children: React.ReactNode;
columns?: 1 | 2 | 3 | 4; // 多栏文字流
columnWidth?: string; // 默认 '18em'
columnFill?: 'auto' | 'balance';
dropCap?: boolean;
className?: string;
style?: CSSProperties;
children: ReactNode;
}
export const BodyText: React.FC<BodyTextProps> = ({
weight = 'Medium',
span,
children,
weight = 'Medium', span, columns = 1, columnWidth = '18em',
columnFill = 'auto', dropCap = false,
className, style, children,
}) => {
const section = useSection();
const config = visualWeights.BodyText[weight];
if (!config) {
throw new Error(`Invalid weight: ${weight} for BodyText`);
}
const finalSpan = span || (Array.isArray(config.span) ? config.span[0] : config.span);
const width = calculateSpanWidth(finalSpan, section.columns);
const config = visualWeights.BodyText[weight]!;
const cols = span ? clampSpan(span, section.columns) : undefined;
const useColumns = columns >= 2;
return (
<p
className="newspaper-body-text"
<div
className={cx(
'nui-bodytext',
'nui-paragraph-flow',
'nui-osf',
'nui-hanging-punctuation',
useColumns && 'nui-column-rule',
dropCap && 'nui-drop-cap',
className,
)}
style={{
fontFamily: `var(${config.fontFamily})`,
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
color: config.color,
color: `var(${config.color})`,
margin: config.margin,
width,
gridColumn: cols ? `span ${cols}` : undefined,
columnCount: useColumns ? columns : undefined,
columnWidth: useColumns ? columnWidth : undefined,
columnGap: useColumns ? 'var(--nui-gutter)' : undefined,
columnFill: useColumns ? columnFill : undefined,
...style,
}}
data-weight={weight}
data-span={finalSpan}
data-columns={columns}
>
{children}
</p>
</div>
);
};
+13 -11
View File
@@ -1,26 +1,28 @@
import React from 'react';
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { cx } from '@newspaperui/utils';
export interface BylineProps {
children: React.ReactNode;
className?: string;
style?: CSSProperties;
children: ReactNode; // e.g. "BY ALICE SMITH"
}
export const Byline: React.FC<BylineProps> = ({ children }) => {
const config = visualWeights.Byline.Standard;
if (!config) {
throw new Error('Byline configuration not found');
}
export const Byline: React.FC<BylineProps> = ({ className, style, children }) => {
const config = visualWeights.Byline.Standard!;
return (
<div
className="newspaper-byline"
className={cx('nui-byline nui-small-caps', className)}
style={{
fontFamily: `var(${config.fontFamily})`,
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
color: config.color,
letterSpacing: config.letterSpacing,
color: `var(${config.color})`,
margin: config.margin,
...style,
}}
>
{children}
+29 -11
View File
@@ -1,29 +1,47 @@
import React from 'react';
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { cx } from '@newspaperui/utils';
export interface CaptionProps {
children: React.ReactNode;
credit?: string; // e.g. "Photograph by Jane Doe"
className?: string;
style?: CSSProperties;
children: ReactNode;
}
export const Caption: React.FC<CaptionProps> = ({ children }) => {
const config = visualWeights.Caption.Standard;
if (!config) {
throw new Error('Caption configuration not found');
}
export const Caption: React.FC<CaptionProps> = ({ credit, className, style, children }) => {
const config = visualWeights.Caption.Standard!;
return (
<figcaption
className="newspaper-caption"
className={cx('nui-caption nui-osf', className)}
style={{
fontFamily: `var(${config.fontFamily})`,
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
fontStyle: config.fontStyle,
lineHeight: config.lineHeight,
color: config.color,
color: `var(${config.color})`,
margin: config.margin,
...style,
}}
>
{children}
{credit && (
<span
className="nui-small-caps"
style={{
display: 'inline-block',
marginLeft: 'var(--nui-space-2)',
fontSize: '11px',
color: 'var(--nui-text-muted)',
fontStyle: 'normal',
letterSpacing: '0.06em',
}}
>
{credit}
</span>
)}
</figcaption>
);
};
+23 -25
View File
@@ -1,51 +1,49 @@
import React from 'react';
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
import { clampSpan, cx } from '@newspaperui/utils';
import { useSection } from '../layout/Section';
const weightToTag: Record<'High' | 'Medium' | 'Low', 'h1' | 'h2' | 'h3'> = {
High: 'h1',
Medium: 'h2',
Low: 'h3',
High: 'h1', Medium: 'h2', Low: 'h3',
};
export interface HeadlineProps {
weight?: 'High' | 'Medium' | 'Low';
span?: number;
as?: 'h1' | 'h2' | 'h3' | 'h4';
children: React.ReactNode;
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
align?: 'left' | 'center' | 'right';
className?: string;
style?: CSSProperties;
children: ReactNode;
}
export const Headline: React.FC<HeadlineProps> = ({
weight = 'High',
span,
as,
children,
weight = 'High', span, as, align, className, style, children,
}) => {
const section = useSection();
const config = visualWeights.Headline[weight];
if (!config) {
throw new Error(`Invalid weight: ${weight} for Headline`);
}
const finalSpan = span || (Array.isArray(config.span) ? config.span[0] : config.span);
const width = calculateSpanWidth(finalSpan, section.columns);
const Tag = as ?? weightToTag[weight];
const config = visualWeights.Headline[weight]!;
const Tag = (as ?? weightToTag[weight]) as keyof JSX.IntrinsicElements;
const cols = span ? clampSpan(span, section.columns) : undefined;
const variantClass = config.fontVariant?.includes('lining') ? 'nui-tnum' : 'nui-osf';
return (
<Tag
className="newspaper-headline"
className={cx('nui-headline', variantClass, className)}
style={{
fontFamily: `var(${config.fontFamily})`,
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
color: config.color,
letterSpacing: config.letterSpacing,
color: `var(${config.color})`,
margin: config.margin,
width,
textAlign: align,
textWrap: 'balance' as CSSProperties['textWrap'],
gridColumn: cols ? `span ${cols}` : undefined,
...style,
}}
data-weight={weight}
data-span={finalSpan}
>
{children}
</Tag>
+25 -17
View File
@@ -1,42 +1,50 @@
import React from 'react';
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
import { clampSpan, cx } from '@newspaperui/utils';
import { useSection } from '../layout/Section';
export interface QuoteProps {
variant?: 'block' | 'inline';
weight?: 'High' | 'Medium';
span?: number;
children: React.ReactNode;
cite?: string;
className?: string;
style?: CSSProperties;
children: ReactNode;
}
export const Quote: React.FC<QuoteProps> = ({
weight = 'Medium',
span,
children,
variant = 'block', weight = 'Medium', span, cite, className, style, children,
}) => {
const section = useSection();
const config = visualWeights.Quote[weight];
const config = visualWeights.Quote[weight]!;
const cols = span ? clampSpan(span, section.columns) : undefined;
if (!config) {
throw new Error(`Invalid weight: ${weight} for Quote`);
if (variant === 'inline') {
return (
<em className={cx('nui-quote nui-quote--inline', className)} style={style}>
{children}
</em>
);
}
const finalSpan = span || (Array.isArray(config.span) ? config.span[0] : config.span);
const width = calculateSpanWidth(finalSpan, section.columns);
return (
<blockquote
className="newspaper-quote"
cite={cite}
className={cx('nui-quote nui-quote--block nui-osf', className)}
style={{
fontFamily: `var(${config.fontFamily})`,
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
lineHeight: config.lineHeight,
color: config.color,
color: `var(${config.color})`,
margin: config.margin,
width,
gridColumn: cols ? `span ${cols}` : undefined,
borderLeft: 'none',
...style,
}}
data-weight={weight}
data-span={finalSpan}
>
{children}
</blockquote>
+20 -22
View File
@@ -1,44 +1,42 @@
import React from 'react';
'use client';
import React, { ReactNode, CSSProperties } from 'react';
import { visualWeights, resolveFontSize } from '@newspaperui/theme';
import { calculateSpanWidth } from '@newspaperui/utils';
import { useSection } from '../Section/Section';
import { clampSpan, cx } from '@newspaperui/utils';
import { useSection } from '../layout/Section';
export interface SubheadProps {
weight?: 'High' | 'Medium';
span?: number;
children: React.ReactNode;
as?: 'h2' | 'h3' | 'h4' | 'p';
className?: string;
style?: CSSProperties;
children: ReactNode;
}
export const Subhead: React.FC<SubheadProps> = ({
weight = 'Medium',
span,
children,
weight = 'Medium', span, as = 'p', className, style, children,
}) => {
const section = useSection();
const config = visualWeights.Subhead[weight];
if (!config) {
throw new Error(`Invalid weight: ${weight} for Subhead`);
}
const finalSpan = span || (Array.isArray(config.span) ? config.span[0] : config.span);
const width = calculateSpanWidth(finalSpan, section.columns);
const config = visualWeights.Subhead[weight]!;
const Tag = as as keyof JSX.IntrinsicElements;
const cols = span ? clampSpan(span, section.columns) : undefined;
return (
<h2
className="newspaper-subhead"
<Tag
className={cx('nui-subhead nui-osf', className)}
style={{
fontFamily: `var(${config.fontFamily})`,
fontSize: resolveFontSize(config.fontSize),
fontWeight: config.fontWeight,
fontStyle: config.fontStyle,
lineHeight: config.lineHeight,
color: config.color,
color: `var(${config.color})`,
margin: config.margin,
width,
gridColumn: cols ? `span ${cols}` : undefined,
...style,
}}
data-weight={weight}
data-span={finalSpan}
>
{children}
</h2>
</Tag>
);
};
@@ -1,51 +1,46 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../Layout/Layout';
import { Section } from '../Section/Section';
import { Article } from '../Article/Article';
import { Layout } from '../layout/Layout';
import { Section } from '../layout/Section';
import { Article } from '../layout/Article';
describe('Article Component', () => {
it('renders children correctly', () => {
describe('Article', () => {
it('applies grid-column span N when span is provided', () => {
const { container } = render(
<Layout>
<Section columns={12}>
<Article span={6}>
<div>Article Content</div>
</Article>
<Article span={6}>x</Article>
</Section>
</Layout>
</Layout>,
);
expect(container.textContent).toContain('Article Content');
const article = container.querySelector('article') as HTMLElement;
expect(article.style.gridColumn).toBe('span 6');
expect(article.getAttribute('data-span')).toBe('6');
});
it('clamps span exceeding section columns and warns', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
render(
<Layout>
<Section columns={12}>
<Article span={15}>
<div>Clamped</div>
</Article>
</Section>
</Layout>
);
expect(warnSpy).toHaveBeenCalledWith(
'[Article] span=15 exceeds section.columns=12, clamped to 12'
);
warnSpy.mockRestore();
});
it('applies correct grid-column span based on span', () => {
it('defaults to filling section.columns when span is omitted', () => {
const { container } = render(
<Layout>
<Section columns={12}>
<Article span={6}>
<div>Half Width</div>
</Article>
<Section columns={8}>
<Article>x</Article>
</Section>
</Layout>
</Layout>,
);
const article = container.querySelector('.newspaper-article');
expect(article).toHaveStyle({ gridColumn: 'span 6' });
const article = container.querySelector('article') as HTMLElement;
expect(article.style.gridColumn).toBe('span 8');
expect(article.getAttribute('data-span')).toBe('8');
});
it('clamps span when exceeding section.columns', () => {
const { container } = render(
<Layout>
<Section columns={6}>
<Article span={20}>x</Article>
</Section>
</Layout>,
);
const article = container.querySelector('article') as HTMLElement;
expect(article.style.gridColumn).toBe('span 6');
expect(article.getAttribute('data-span')).toBe('6');
});
});
@@ -1,36 +1,39 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Layout } from '../Layout/Layout';
import { Section } from '../Section/Section';
import { Layout, useLayout } from '../layout/Layout';
describe('Layout Component', () => {
it('renders children correctly', () => {
const ColumnsProbe = () => {
const { columns } = useLayout();
return <span data-testid="cols">{columns}</span>;
};
describe('Layout', () => {
it('renders children', () => {
render(
<Layout>
<div>Test Content</div>
</Layout>
<p>hello</p>
</Layout>,
);
expect(screen.getByText('Test Content')).toBeInTheDocument();
expect(screen.getByText('hello')).toBeInTheDocument();
});
it('applies custom maxWidth and padding', () => {
it('provides default columns context of 24', () => {
render(
<Layout>
<ColumnsProbe />
</Layout>,
);
expect(screen.getByTestId('cols').textContent).toBe('24');
});
it('applies maxWidth and padding', () => {
const { container } = render(
<Layout maxWidth="1200px" padding="2rem">
<div>Content</div>
</Layout>
<Layout maxWidth="900px" padding="16px">
<p>x</p>
</Layout>,
);
const layout = container.querySelector('.newspaper-layout');
expect(layout).toHaveStyle({ maxWidth: '1200px', padding: '2rem' });
});
it('provides default columns value of 24', () => {
render(
<Layout>
<Section columns={12}>
<div>Test</div>
</Section>
</Layout>
);
expect(screen.getByText('Test')).toBeInTheDocument();
const root = container.firstChild as HTMLElement;
expect(root.style.maxWidth).toBe('900px');
expect(root.style.padding).toBe('16px');
});
});
@@ -1,46 +1,49 @@
import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import { Layout } from '../Layout/Layout';
import { Section } from '../Section/Section';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Layout } from '../layout/Layout';
import { Section, useSection } from '../layout/Section';
describe('Section Component', () => {
it('renders children correctly', () => {
const { container } = render(
<Layout>
<Section columns={12}>
<div>Section Content</div>
</Section>
</Layout>
);
expect(container.textContent).toContain('Section Content');
});
const ColsProbe = () => {
const { columns } = useSection();
return <span data-testid="section-cols">{columns}</span>;
};
it('clamps columns exceeding layout columns and warns', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
describe('Section', () => {
it('applies grid-template-columns repeat', () => {
const { container } = render(
<Layout columns={24}>
<Section columns={30}>
<div>Clamped</div>
<Section columns={12}>
<p>x</p>
</Section>
</Layout>
</Layout>,
);
expect(warnSpy).toHaveBeenCalledWith(
'[Section] columns=30 exceeds layout.columns=24, clamped to 24'
);
const section = container.querySelector('.newspaper-section');
expect(section).toHaveStyle({ gridTemplateColumns: 'repeat(24, 1fr)' });
warnSpy.mockRestore();
const section = container.querySelector('section') as HTMLElement;
expect(section.style.display).toBe('grid');
expect(section.style.gridTemplateColumns).toBe('repeat(12, 1fr)');
expect(section.getAttribute('data-columns')).toBe('12');
});
it('applies custom padding and margin', () => {
it('clamps columns when exceeding layout.columns', () => {
render(
<Layout columns={12}>
<Section columns={24}>
<ColsProbe />
</Section>
</Layout>,
);
expect(screen.getByTestId('section-cols').textContent).toBe('12');
});
it('applies top and bottom borders when divider="both"', () => {
const { container } = render(
<Layout>
<Section columns={12} padding="1rem" margin="2rem">
<div>Content</div>
<Section columns={12} divider="both">
<p>x</p>
</Section>
</Layout>
</Layout>,
);
const section = container.querySelector('.newspaper-section');
expect(section).toHaveStyle({ padding: '1rem', margin: '2rem' });
const section = container.querySelector('section') as HTMLElement;
expect(section.style.borderTop).toContain('1px');
expect(section.style.borderBottom).toContain('1px');
});
});
+40 -48
View File
@@ -1,51 +1,43 @@
/**
* @newspaperui/components
* React components for newspaperui
*/
import '@newspaperui/theme';
// Layout components
export { Layout, useLayout } from './Layout/Layout';
export type { LayoutProps } from './Layout/Layout';
// layout
export { Layout, useLayout } from './layout/Layout';
export type { LayoutProps } from './layout/Layout';
export { Section, useSection } from './layout/Section';
export type { SectionProps } from './layout/Section';
export { Article } from './layout/Article';
export type { ArticleProps } from './layout/Article';
export { Layer } from './layout/Layer';
export type { LayerProps } from './layout/Layer';
export { Masthead } from './layout/Masthead';
export type { MastheadProps } from './layout/Masthead';
export { Rule } from './layout/Rule';
export type { RuleProps } from './layout/Rule';
export { Section, useSection } from './Section/Section';
export type { SectionProps } from './Section/Section';
// text
export { Headline } from './text/Headline';
export type { HeadlineProps } from './text/Headline';
export { Subhead } from './text/Subhead';
export type { SubheadProps } from './text/Subhead';
export { Kicker } from './text/Kicker';
export type { KickerProps } from './text/Kicker';
export { BodyText } from './text/BodyText';
export type { BodyTextProps } from './text/BodyText';
export { Quote } from './text/Quote';
export type { QuoteProps } from './text/Quote';
export { Byline } from './text/Byline';
export type { BylineProps } from './text/Byline';
export { Dateline } from './text/Dateline';
export type { DatelineProps } from './text/Dateline';
export { Caption } from './text/Caption';
export type { CaptionProps } from './text/Caption';
export { Article } from './Article/Article';
export type { ArticleProps } from './Article/Article';
export { Layer } from './Layer/Layer';
export type { LayerProps } from './Layer/Layer';
// Text components
export { Headline } from './Text/Headline';
export type { HeadlineProps } from './Text/Headline';
export { Subhead } from './Text/Subhead';
export type { SubheadProps } from './Text/Subhead';
export { BodyText } from './Text/BodyText';
export type { BodyTextProps } from './Text/BodyText';
export { Quote } from './Text/Quote';
export type { QuoteProps } from './Text/Quote';
export { Byline } from './Text/Byline';
export type { BylineProps } from './Text/Byline';
export { Caption } from './Text/Caption';
export type { CaptionProps } from './Text/Caption';
// Media components
export { Image } from './Media/Image';
export type { ImageProps } from './Media/Image';
export { Figure } from './Media/Figure';
export type { FigureProps } from './Media/Figure';
export { Video } from './Media/Video';
export type { VideoProps } from './Media/Video';
export { PullQuote } from './Media/PullQuote';
export type { PullQuoteProps } from './Media/PullQuote';
export const version = '0.0.0';
// media
export { Image } from './media/Image';
export type { ImageProps } from './media/Image';
export { Figure } from './media/Figure';
export type { FigureProps } from './media/Figure';
export { Video } from './media/Video';
export type { VideoProps } from './media/Video';
export { PullQuote } from './media/PullQuote';
export type { PullQuoteProps } from './media/PullQuote';
@@ -1,192 +0,0 @@
'use client';
import { Demo } from '@/components/Demo';
import { PropsTable } from '@/components/PropsTable';
import { Layout, Section, Article, Headline, BodyText, Subhead } from '@newspaperui/components';
export default function ArticlePage() {
return (
<div className="prose prose-lg max-w-none">
<h1>Article </h1>
<p className="text-xl text-gray-600">
Article
</p>
<h2></h2>
<p>
<code>Article</code>
span
</p>
<h2>API </h2>
<PropsTable
data={[
{
name: 'span',
type: 'number',
required: true,
description: '文章占据的列数,必须 ≤ 所在 Section 的 columns',
},
{
name: 'priority',
type: 'number',
default: '0',
description: '优先级,数值越大优先级越高,影响排序',
},
{
name: 'weight',
type: '"High" | "Medium" | "Low"',
default: '"Medium"',
description: '视觉权重,影响内部文本组件的默认样式',
},
{
name: 'breakable',
type: 'boolean',
default: 'true',
description: '是否允许在此处分页断开',
},
{
name: 'className',
type: 'string',
description: '自定义 CSS 类名',
},
{
name: 'children',
type: 'React.ReactNode',
required: true,
description: '文章内容',
},
]}
/>
<h2></h2>
<Demo
title="单列文章"
description="创建一个占据 8 列的文章"
code={`<Layout columns={24}>
<Section columns={24}>
<Article span={8}>
<Headline weight="High">文章标题</Headline>
<Subhead>副标题说明</Subhead>
<BodyText>
这是文章的正文内容。Article 组件提供了内容容器,
可以包含多种文本和媒体组件。
</BodyText>
</Article>
</Section>
</Layout>`}
>
<Layout columns={24}>
<Section columns={24}>
<Article span={8}>
<Headline weight="High"></Headline>
<Subhead></Subhead>
<BodyText>
Article
</BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2></h2>
<p>
span span
span
</p>
<Demo
title="多列文章并排"
description="在 24 列布局中创建三个 8 列的文章"
code={`<Layout columns={24}>
<Section columns={24}>
<Article span={8}>
<Headline weight="High">主要文章</Headline>
<BodyText>占据 8 列的主要内容</BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium">次要文章</Headline>
<BodyText>占据 8 列的次要内容</BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium">更多内容</Headline>
<BodyText>占据 8 列的更多内容</BodyText>
</Article>
</Section>
</Layout>`}
>
<Layout columns={24}>
<Section columns={24}>
<Article span={8}>
<Headline weight="High"></Headline>
<BodyText> 8 使 High </BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium"></Headline>
<BodyText> 8 使 Medium </BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium"></Headline>
<BodyText> 8 </BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2></h2>
<Demo
title="混合宽度布局"
description="组合不同宽度的文章实现复杂布局"
code={`<Layout columns={24}>
<Section columns={24}>
<Article span={16}>
<Headline weight="High">宽文章 (16 列)</Headline>
<BodyText>
这是一篇占据 16 列的宽文章,适合放置重要内容或长篇文章。
</BodyText>
</Article>
<Article span={8}>
<Headline weight="Low">窄文章 (8 列)</Headline>
<BodyText>这是占据 8 列的窄文章,适合侧边栏或简短内容。</BodyText>
</Article>
</Section>
</Layout>`}
>
<Layout columns={24}>
<Section columns={24}>
<Article span={16}>
<Headline weight="High"> (16 )</Headline>
<BodyText>
16
</BodyText>
</Article>
<Article span={8}>
<Headline weight="Low"> (8 )</Headline>
<BodyText> 8 </BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2>使</h2>
<ul>
<li>使 12-16 </li>
<li>使 6-8 </li>
<li> Section Article span Section.columns</li>
<li>使 priority </li>
<li> weight </li>
<li>使 span </li>
</ul>
<h2></h2>
<ul>
<li>Article span Section columns </li>
<li> Section Article span Section.columns</li>
<li>Article </li>
<li>breakable </li>
</ul>
</div>
);
}
-199
View File
@@ -1,199 +0,0 @@
'use client';
import { Demo } from '@/components/Demo';
import { PropsTable } from '@/components/PropsTable';
import { Layout, Section, Article, Layer, Headline, BodyText } from '@newspaperui/components';
export default function LayerPage() {
return (
<div className="prose prose-lg max-w-none">
<h1>Layer </h1>
<p className="text-xl text-gray-600">
Layer 广
</p>
<h2></h2>
<p>
<code>Layer</code> 使
广
</p>
<h2>API </h2>
<PropsTable
data={[
{
name: 'position',
type: '"absolute" | "fixed" | "sticky"',
default: '"absolute"',
description: 'CSS position 属性',
},
{
name: 'top',
type: 'string | number',
description: '距离顶部的距离',
},
{
name: 'right',
type: 'string | number',
description: '距离右侧的距离',
},
{
name: 'bottom',
type: 'string | number',
description: '距离底部的距离',
},
{
name: 'left',
type: 'string | number',
description: '距离左侧的距离',
},
{
name: 'zIndex',
type: 'number',
default: '10',
description: 'z-index 层级',
},
{
name: 'className',
type: 'string',
description: '自定义 CSS 类名',
},
{
name: 'children',
type: 'React.ReactNode',
required: true,
description: '浮动层内容',
},
]}
/>
<h2></h2>
<Demo
title="浮动拉引"
description="在文章旁边添加浮动的拉引文字"
code={`<Layout columns={24}>
<Section columns={24}>
<div style={{ position: 'relative', minHeight: '300px' }}>
<Article span={16}>
<Headline weight="High">主要文章</Headline>
<BodyText>
这是一篇主要文章的内容。旁边有一个浮动的拉引,
用于突出显示文章中的重要引用或观点。
</BodyText>
</Article>
<Layer position="absolute" top="20px" right="20px" zIndex={10}>
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 max-w-xs">
<p className="text-lg font-semibold text-gray-800">
"这是一段重要的引用文字"
</p>
</div>
</Layer>
</div>
</Section>
</Layout>`}
>
<Layout columns={24}>
<Section columns={24}>
<div style={{ position: 'relative', minHeight: '300px' }}>
<Article span={16}>
<Headline weight="High"></Headline>
<BodyText>
Layer 使
</BodyText>
</Article>
<Layer position="absolute" top="20px" right="20px" zIndex={10}>
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 max-w-xs">
<p className="text-lg font-semibold text-gray-800">
"这是一段重要的引用文字"
</p>
</div>
</Layer>
</div>
</Section>
</Layout>
</Demo>
<h2>广</h2>
<Demo
title="侧边浮动广告"
description="在页面侧边添加固定位置的广告"
code={`<Layout columns={24}>
<Section columns={24}>
<div style={{ position: 'relative', minHeight: '400px' }}>
<Article span={18}>
<Headline weight="High">文章内容</Headline>
<BodyText>
这是文章的主要内容区域。右侧有一个浮动的广告位,
使用 Layer 组件实现。
</BodyText>
</Article>
<Layer position="absolute" top="0" right="0" zIndex={5}>
<div className="bg-blue-100 border border-blue-300 p-6 w-48">
<p className="text-center font-bold text-blue-900">广告位</p>
<p className="text-sm text-blue-700 mt-2">300x250</p>
</div>
</Layer>
</div>
</Section>
</Layout>`}
>
<Layout columns={24}>
<Section columns={24}>
<div style={{ position: 'relative', minHeight: '400px' }}>
<Article span={18}>
<Headline weight="High"></Headline>
<BodyText>
广
使 Layer 广
</BodyText>
<BodyText>
Layer
</BodyText>
</Article>
<Layer position="absolute" top="0" right="0" zIndex={5}>
<div className="bg-blue-100 border border-blue-300 p-6 w-48 text-center">
<p className="font-bold text-blue-900">广</p>
<p className="text-sm text-blue-700 mt-2">300x250</p>
</div>
</Layer>
</div>
</Section>
</Layout>
</Demo>
<h2>使</h2>
<ul>
<li>Layer <code>position: relative</code></li>
<li>使 zIndex Layer </li>
<li></li>
<li> Layer</li>
<li>使 <code>position="fixed"</code> </li>
<li>使 <code>position="sticky"</code> </li>
</ul>
<h2></h2>
<ul>
<li>Layer Section Article </li>
<li> Layer </li>
<li> Layer </li>
<li> Layer 访</li>
<li> Layer </li>
</ul>
<h2></h2>
<ul>
<li> - </li>
<li>广 - 广</li>
<li> - </li>
<li> - 使 fixed </li>
<li> - 使 sticky </li>
</ul>
</div>
);
}
-271
View File
@@ -1,271 +0,0 @@
'use client';
import { Demo } from '@/components/Demo';
import { PropsTable } from '@/components/PropsTable';
import { Layout, Section, Article, Headline, BodyText } from '@newspaperui/components';
import { Image, Figure } from '@newspaperui/components';
export default function MediaPage() {
return (
<div className="prose prose-lg max-w-none">
<h1></h1>
<p className="text-xl text-gray-600">
NewspaperUI ImageFigure Video
</p>
<h2>Image </h2>
<p>
<code>Image</code>
</p>
<PropsTable
data={[
{
name: 'src',
type: 'string',
required: true,
description: '图片 URL',
},
{
name: 'alt',
type: 'string',
required: true,
description: '图片替代文本(无障碍)',
},
{
name: 'span',
type: 'number',
description: '图片占据的列数',
},
{
name: 'aspectRatio',
type: 'string',
default: '"16/9"',
description: '图片宽高比',
},
{
name: 'objectFit',
type: '"cover" | "contain" | "fill"',
default: '"cover"',
description: '图片填充方式',
},
{
name: 'className',
type: 'string',
description: '自定义 CSS 类名',
},
]}
/>
<Demo
title="基础图片"
description="在文章中插入图片"
code={`<Article span={12}>
<Headline weight="High">带图片的文章</Headline>
<Image
src="https://via.placeholder.com/800x600"
alt="示例图片"
span={12}
/>
<BodyText>图片下方的文字说明</BodyText>
</Article>`}
>
<Layout columns={24}>
<Section columns={24}>
<Article span={12}>
<Headline weight="High"></Headline>
<Image
src="https://via.placeholder.com/800x600"
alt="示例图片"
span={12}
/>
<BodyText></BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2>Figure </h2>
<p>
<code>Figure</code>
</p>
<PropsTable
data={[
{
name: 'src',
type: 'string',
required: true,
description: '图片 URL',
},
{
name: 'alt',
type: 'string',
required: true,
description: '图片替代文本',
},
{
name: 'caption',
type: 'string',
required: true,
description: '图注文字',
},
{
name: 'span',
type: 'number',
description: 'Figure 占据的列数',
},
{
name: 'aspectRatio',
type: 'string',
default: '"16/9"',
description: '图片宽高比',
},
{
name: 'className',
type: 'string',
description: '自定义 CSS 类名',
},
]}
/>
<Demo
title="带图注的图片"
description="使用 Figure 组件添加图注"
code={`<Article span={12}>
<Headline weight="High">新闻标题</Headline>
<Figure
src="https://via.placeholder.com/800x600"
alt="新闻配图"
caption="这是图片的说明文字,描述图片内容"
span={12}
/>
<BodyText>新闻正文内容...</BodyText>
</Article>`}
>
<Layout columns={24}>
<Section columns={24}>
<Article span={12}>
<Headline weight="High"></Headline>
<Figure
src="https://via.placeholder.com/800x600"
alt="新闻配图"
caption="这是图片的说明文字,描述图片内容"
span={12}
/>
<BodyText>...</BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2>Video </h2>
<p>
<code>Video</code>
</p>
<PropsTable
data={[
{
name: 'src',
type: 'string',
required: true,
description: '视频 URL',
},
{
name: 'poster',
type: 'string',
description: '视频封面图片 URL',
},
{
name: 'span',
type: 'number',
description: '视频占据的列数',
},
{
name: 'controls',
type: 'boolean',
default: 'true',
description: '是否显示控制条',
},
{
name: 'autoPlay',
type: 'boolean',
default: 'false',
description: '是否自动播放',
},
{
name: 'loop',
type: 'boolean',
default: 'false',
description: '是否循环播放',
},
{
name: 'muted',
type: 'boolean',
default: 'false',
description: '是否静音',
},
{
name: 'className',
type: 'string',
description: '自定义 CSS 类名',
},
]}
/>
<Demo
title="嵌入视频"
description="在文章中嵌入视频内容"
code={`<Article span={16}>
<Headline weight="High">视频新闻</Headline>
<Video
src="https://example.com/video.mp4"
poster="https://via.placeholder.com/800x450"
span={16}
controls
/>
<BodyText>视频相关的文字说明...</BodyText>
</Article>`}
>
<Layout columns={24}>
<Section columns={24}>
<Article span={16}>
<Headline weight="High"></Headline>
<div className="bg-gray-200 aspect-video flex items-center justify-center">
<p className="text-gray-600"></p>
</div>
<BodyText>...</BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2>使</h2>
<ul>
<li>使 Image </li>
<li>使 Figure </li>
<li>使 Video </li>
<li> span </li>
<li> aspectRatio </li>
<li> alt </li>
<li></li>
</ul>
<h2></h2>
<p>
</p>
<h2></h2>
<ul>
<li>使WebPAVIF</li>
<li></li>
<li>使lazy loading</li>
<li></li>
<li>使 CDN </li>
</ul>
</div>
);
}
@@ -1,251 +0,0 @@
'use client';
import { Demo } from '@/components/Demo';
import { Layout, Section, Article } from '@newspaperui/components';
import { Headline, BodyText } from '@newspaperui/components';
export default function ResponsivePage() {
return (
<div className="prose prose-lg max-w-none">
<h1></h1>
<p className="text-xl text-gray-600">
NewspaperUI
</p>
<h2></h2>
<p>
NewspaperUI
</p>
<div className="bg-blue-50 border-l-4 border-blue-400 p-4 my-6">
<h3 className="text-lg font-semibold text-blue-900 mt-0"></h3>
<ul className="mb-0 text-blue-800">
<li><strong>1024px</strong>: 24 </li>
<li><strong>768px-1023px</strong>: 16 </li>
<li><strong>&lt;768px</strong>: 12 </li>
</ul>
</div>
<h2></h2>
<p>
</p>
<Demo
title="响应式三栏布局"
description="大屏显示三栏,中屏显示两栏,小屏显示单栏"
code={`<Layout columns={24}>
<Section columns={24}>
{/* 大屏: 8+8+8, 中屏: 12+12, 小屏: 24 */}
<Article span={8} className="lg:span-8 md:span-12 sm:span-24">
<Headline weight="Medium">第一栏</Headline>
<BodyText>内容自动适应屏幕宽度</BodyText>
</Article>
<Article span={8} className="lg:span-8 md:span-12 sm:span-24">
<Headline weight="Medium">第二栏</Headline>
<BodyText>响应式布局</BodyText>
</Article>
<Article span={8} className="lg:span-8 md:span-12 sm:span-24">
<Headline weight="Medium">第三栏</Headline>
<BodyText>自动换行</BodyText>
</Article>
</Section>
</Layout>`}
>
<Layout columns={24}>
<Section columns={24}>
<Article span={8}>
<Headline weight="Medium"></Headline>
<BodyText>
</BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium"></Headline>
<BodyText>
</BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium"></Headline>
<BodyText>
NewspaperUI
</BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2></h2>
<p>
</p>
<Demo
title="主次内容自适应"
description="大屏 16+8,中屏 12+12,小屏全宽堆叠"
code={`<Layout columns={24}>
<Section columns={24}>
<Article span={16}>
<Headline weight="High">主要内容</Headline>
<BodyText weight="High">
主要内容在大屏占据 16 列,在中屏占据 12 列,
在小屏占据全宽。
</BodyText>
</Article>
<Article span={8}>
<Headline weight="Low">侧边栏</Headline>
<BodyText weight="Low">
侧边栏在大屏占据 8 列,在中屏占据 12 列,
在小屏移到主内容下方。
</BodyText>
</Article>
</Section>
</Layout>`}
>
<Layout columns={24}>
<Section columns={24}>
<Article span={16}>
<Headline weight="High"></Headline>
<BodyText weight="High">
</BodyText>
<BodyText>
</BodyText>
</Article>
<Article span={8}>
<Headline weight="Low"></Headline>
<BodyText weight="Low">
1<br />
2<br />
3<br />
...
</BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2></h2>
<p>
NewspaperUI
</p>
<Demo
title="移动优先布局"
description="小屏优化的单栏布局"
>
<Layout columns={24}>
<Section columns={24}>
<Article span={24}>
<Headline weight="High"></Headline>
<BodyText>
</BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2></h2>
<p>
</p>
<Demo
title="响应式媒体"
description="图片自动适应容器宽度"
>
<Layout columns={24}>
<Section columns={24}>
<Article span={16}>
<Headline weight="High"></Headline>
<div className="bg-gray-200 aspect-video flex items-center justify-center my-4">
<span className="text-gray-600"></span>
</div>
<BodyText>
</BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2></h2>
<ul>
<li>使remempx</li>
<li></li>
<li> 16px</li>
<li> 44x44px</li>
<li></li>
<li></li>
<li></li>
</ul>
<h2></h2>
<p>
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 my-6">
<div className="border border-gray-200 rounded-lg p-4">
<h3 className="text-lg font-semibold mb-2"></h3>
<ul className="text-sm space-y-1 mb-0">
<li>iPhone SE (375px)</li>
<li>iPhone 12/13 (390px)</li>
<li>iPhone 14 Pro Max (430px)</li>
<li>Android (360px)</li>
</ul>
</div>
<div className="border border-gray-200 rounded-lg p-4">
<h3 className="text-lg font-semibold mb-2"></h3>
<ul className="text-sm space-y-1 mb-0">
<li>iPad Mini (768px)</li>
<li>iPad (810px)</li>
<li>iPad Pro (1024px)</li>
<li>Android (800px)</li>
</ul>
</div>
<div className="border border-gray-200 rounded-lg p-4">
<h3 className="text-lg font-semibold mb-2"></h3>
<ul className="text-sm space-y-1 mb-0">
<li> (1366px)</li>
<li> (1920px)</li>
<li> (2560px)</li>
<li> (3440px)</li>
</ul>
</div>
</div>
<h2></h2>
<p>
使
</p>
<ul>
<li><strong>Chrome DevTools</strong>: Cmd/Ctrl + Shift + M</li>
<li><strong>Firefox</strong>: Cmd/Ctrl + Shift + M</li>
<li><strong>Safari</strong>: Develop Enter Responsive Design Mode</li>
</ul>
<h2></h2>
<ul>
<li>使lazy loading</li>
<li></li>
<li>使WebPAVIF</li>
<li></li>
<li>使 CDN </li>
<li> JavaScript CSS</li>
</ul>
</div>
);
}
@@ -1,267 +0,0 @@
'use client';
import { Demo } from '@/components/Demo';
import { Layout, Section, Article, Layer } from '@newspaperui/components';
import { Headline, Subhead, BodyText, Quote } from '@newspaperui/components';
export default function SpanningPage() {
return (
<div className="prose prose-lg max-w-none">
<h1></h1>
<p className="text-xl text-gray-600">
使 NewspaperUI
</p>
<h2></h2>
<p>
</p>
<Demo
title="报纸头版"
description="主新闻占据 16 列,侧边栏占据 8 列,带浮动拉引"
code={`<Layout columns={24}>
<Section columns={24}>
<div style={{ position: 'relative', minHeight: '600px' }}>
{/* 主新闻 - 16 列 */}
<Article span={16}>
<Headline weight="High">重大新闻标题占据主要版面</Headline>
<Subhead weight="High">
副标题提供更多背景信息和上下文
</Subhead>
<BodyText weight="High">
这是主要新闻的正文内容。在报纸布局中,最重要的新闻通常占据最大的版面,
使用最大的字号和最粗的字重。这样的布局能够立即吸引读者的注意力。
</BodyText>
<BodyText>
新闻的后续段落继续详细描述事件的发展。通过合理的跨栏布局,
我们可以在有限的版面中呈现丰富的内容。
</BodyText>
</Article>
{/* 侧边栏 - 8 列 */}
<Article span={8}>
<Headline weight="Medium">次要新闻</Headline>
<BodyText weight="Medium">
侧边栏的新闻使用较小的字号,适合放置次要但仍然重要的内容。
</BodyText>
</Article>
{/* 浮动拉引 */}
<Layer position="absolute" top="100px" right="20px" zIndex={10}>
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 max-w-xs">
<Quote weight="High">
"这是一段重要的引用,用于突出文章中的关键观点"
</Quote>
</div>
</Layer>
</div>
</Section>
</Layout>`}
>
<Layout columns={24}>
<Section columns={24}>
<div style={{ position: 'relative', minHeight: '600px' }}>
<Article span={16}>
<Headline weight="High"></Headline>
<Subhead weight="High">
</Subhead>
<BodyText weight="High">
使
</BodyText>
<BodyText>
</BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium"></Headline>
<BodyText weight="Medium">
使
</BodyText>
</Article>
<Layer position="absolute" top="100px" right="20px" zIndex={10}>
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 max-w-xs">
<Quote weight="High">
"这是一段重要的引用,用于突出文章中的关键观点"
</Quote>
</div>
</Layer>
</div>
</Section>
</Layout>
</Demo>
<h2></h2>
<p>
24 8
</p>
<Demo
title="三栏布局"
description="三个 8 列的文章并排显示"
code={`<Layout columns={24}>
<Section columns={24}>
<Article span={8}>
<Headline weight="Medium">第一栏标题</Headline>
<BodyText>第一栏的内容...</BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium">第二栏标题</Headline>
<BodyText>第二栏的内容...</BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium">第三栏标题</Headline>
<BodyText>第三栏的内容...</BodyText>
</Article>
</Section>
</Layout>`}
>
<Layout columns={24}>
<Section columns={24}>
<Article span={8}>
<Headline weight="Medium"></Headline>
<BodyText>
</BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium"></Headline>
<BodyText>
</BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium"></Headline>
<BodyText>
</BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2></h2>
<p>
使
</p>
<Demo
title="12 + 12 列布局"
description="两个等宽的 12 列区域"
code={`<Layout columns={24}>
<Section columns={24}>
<Article span={12}>
<Headline weight="High">左侧主要内容</Headline>
<BodyText weight="High">
占据 12 列的主要内容区域...
</BodyText>
</Article>
<Article span={12}>
<Headline weight="High">右侧主要内容</Headline>
<BodyText weight="High">
同样占据 12 列的内容区域...
</BodyText>
</Article>
</Section>
</Layout>`}
>
<Layout columns={24}>
<Section columns={24}>
<Article span={12}>
<Headline weight="High"></Headline>
<BodyText weight="High">
12
</BodyText>
</Article>
<Article span={12}>
<Headline weight="High"></Headline>
<BodyText weight="High">
</BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2></h2>
<p>
</p>
<Demo
title="混合列宽布局"
description="6 + 12 + 6 列的组合布局"
code={`<Layout columns={24}>
<Section columns={24}>
<Article span={6}>
<Headline weight="Low">侧边栏</Headline>
<BodyText weight="Low">简短内容</BodyText>
</Article>
<Article span={12}>
<Headline weight="High">主要内容</Headline>
<BodyText>详细的主要内容...</BodyText>
</Article>
<Article span={6}>
<Headline weight="Low">另一侧边栏</Headline>
<BodyText weight="Low">补充信息</BodyText>
</Article>
</Section>
</Layout>`}
>
<Layout columns={24}>
<Section columns={24}>
<Article span={6}>
<Headline weight="Low"></Headline>
<BodyText weight="Low">
1<br />
2<br />
3
</BodyText>
</Article>
<Article span={12}>
<Headline weight="High"></Headline>
<BodyText>
12
6
</BodyText>
</Article>
<Article span={6}>
<Headline weight="Low"></Headline>
<BodyText weight="Low">
1<br />
2<br />
3
</BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2></h2>
<ul>
<li>使 span 12-16 </li>
<li>使 span 4-8 </li>
<li></li>
<li>使 Layer </li>
<li></li>
<li></li>
</ul>
<h2></h2>
<ul>
<li> Article span Section columns</li>
<li></li>
<li></li>
<li>使 8:166:12:6 </li>
<li></li>
</ul>
</div>
);
}
+11 -29
View File
@@ -1,35 +1,17 @@
@import '@newspaperui/theme/variables.css';
@import "@newspaperui/theme/dist/style.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-white text-gray-900;
}
h1 {
@apply text-4xl font-bold mb-4;
}
h2 {
@apply text-3xl font-semibold mt-8 mb-4;
}
h3 {
@apply text-2xl font-semibold mt-6 mb-3;
}
p {
@apply mb-4 leading-relaxed;
}
code {
@apply text-sm bg-gray-100 px-1.5 py-0.5 rounded;
}
pre code {
@apply bg-transparent p-0;
}
html, body {
background: var(--nui-bg-page);
color: var(--nui-text-body);
font-family: var(--font-family-body);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
margin: 0;
padding: 0;
}
* { box-sizing: border-box; }
-209
View File
@@ -1,209 +0,0 @@
'use client';
import { Demo } from '@/components/Demo';
import { PropsTable } from '@/components/PropsTable';
import { Layout, Section, Article, Headline, BodyText } from '@newspaperui/components';
export default function GridSystemPage() {
return (
<div className="prose prose-lg max-w-none">
<h1></h1>
<p className="text-xl text-gray-600">
NewspaperUI 24
</p>
<h2></h2>
<p>
24 12 24
使 1/32/31/43/4
</p>
<h2>Layout </h2>
<p>
<code>Layout</code>
</p>
<PropsTable
data={[
{
name: 'columns',
type: 'number',
default: '24',
description: '栅格总列数,默认 24 列',
},
{
name: 'maxWidth',
type: 'string',
default: '"1440px"',
description: '容器最大宽度',
},
{
name: 'gutter',
type: 'number',
default: '16',
description: '列间距(单位 px',
},
{
name: 'padding',
type: 'string',
default: '"1rem"',
description: '容器内边距',
},
{
name: 'className',
type: 'string',
description: '自定义 CSS 类名',
},
]}
/>
<Demo
title="基础 Layout"
description="创建一个 24 列的栅格容器"
code={`<Layout columns={24}>
{/* 内容 */}
</Layout>`}
>
<Layout columns={24}>
<div className="bg-blue-50 border-2 border-blue-200 p-4 text-center">
24
</div>
</Layout>
</Demo>
<h2>Section </h2>
<p>
<code>Section</code> Layout Section
Section span Section columns
</p>
<PropsTable
data={[
{
name: 'columns',
type: 'number',
required: true,
description: 'Section 占据的列数,必须 ≤ Layout.columns',
},
{
name: 'breakable',
type: 'boolean',
default: 'true',
description: '是否允许在此处分页断开',
},
{
name: 'priority',
type: "'High' | 'Medium' | 'Low'",
default: "'Medium'",
description: '优先级,用于排序',
},
{
name: 'className',
type: 'string',
description: '自定义 CSS 类名',
},
]}
/>
<Demo
title="多 Section 布局"
description="将 24 列划分为 8 列和 16 列两个区域"
code={`<Layout columns={24}>
<Section columns={8}>
<Article span={8}>
<Headline weight="High">8 列区域</Headline>
<BodyText>这个区域占据 8 列</BodyText>
</Article>
</Section>
<Section columns={16}>
<Article span={8}>
<Headline weight="Medium">16 列区域 - 左</Headline>
<BodyText>这个区域占据 16 列,分为两个 8 列文章</BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium">16 列区域 - 右</Headline>
<BodyText>第二个 8 列文章</BodyText>
</Article>
</Section>
</Layout>`}
>
<Layout columns={24}>
<Section columns={8}>
<Article span={8}>
<Headline weight="High">8 </Headline>
<BodyText> 8 </BodyText>
</Article>
</Section>
<Section columns={16}>
<Article span={8}>
<Headline weight="Medium">16 - </Headline>
<BodyText> 16 8 </BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium">16 - </Headline>
<BodyText> 8 </BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2></h2>
<p>
12-16
CSS
</p>
<Demo
title="响应式栅格示例"
description="在不同屏幕尺寸下自动调整列数"
>
<Layout columns={24}>
<Section columns={6}>
<Article span={6}>
<Headline weight="Low">6 </Headline>
<BodyText></BodyText>
</Article>
</Section>
<Section columns={6}>
<Article span={6}>
<Headline weight="Low">6 </Headline>
<BodyText></BodyText>
</Article>
</Section>
<Section columns={6}>
<Article span={6}>
<Headline weight="Low">6 </Headline>
<BodyText></BodyText>
</Article>
</Section>
<Section columns={6}>
<Article span={6}>
<Headline weight="Low">6 </Headline>
<BodyText></BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2></h2>
<ul>
<li>Layout columns 24</li>
<li>Section columns Layout columns</li>
<li>Section span Section columns</li>
<li> span </li>
<li> 12-16 </li>
</ul>
<h2></h2>
<ul>
<li>使 24 1/21/31/41/61/8 </li>
<li>使 8-16 </li>
<li>使 4-8 </li>
<li></li>
<li> Section 8:166:1812:12 </li>
</ul>
</div>
);
}
+6 -20
View File
@@ -1,29 +1,15 @@
import type { Metadata } from 'next';
import { Sidebar } from '@/components/Sidebar';
import './globals.css';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'NewspaperUI - Documentation',
description: 'A newspaper-style UI component library',
title: 'NewspaperUI — Production Newspaper Components',
description: '生产级报纸布局组件库,参考 InDesign 与经典严肃风排版传统,24 列栅格、跨栏、视觉权重和主题系统',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body className="antialiased">
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1 overflow-x-hidden">
<div className="max-w-5xl mx-auto px-8 py-12">
{children}
</div>
</main>
</div>
</body>
<html lang="zh">
<body>{children}</body>
</html>
);
}
+190 -84
View File
@@ -1,102 +1,208 @@
'use client';
import { Demo } from '@/components/Demo';
import { Layout, Section, Article } from '@newspaperui/components';
import { Headline, BodyText } from '@newspaperui/components';
import {
Layout, Section, Article, Masthead, Rule,
Headline, Subhead, Kicker, BodyText, Byline, Dateline,
Figure, PullQuote,
} from '@newspaperui/components';
export default function HomePage() {
export default function FrontPage() {
return (
<div className="prose prose-lg max-w-none">
<h1>NewspaperUI</h1>
<p className="text-xl text-gray-600">
InDesign 24
</p>
<Layout columns={24} maxWidth="1200px" padding="2rem 1.5rem">
<Masthead
variant="classic"
kicker="Late City Edition"
title="The Daily Chronicle"
edition="Vol. CXLIX · No. 51,895"
date="Tuesday, May 19, 2026"
price="$4.00"
/>
<h2></h2>
<p>
NewspaperUI InDesign Web
</p>
<Section columns={24} divider="bottom" gap="2rem" style={{ marginTop: '2rem' }}>
<Article span={5} style={{ borderRight: '1px solid var(--nui-rule-hairline)', paddingRight: '1.5rem' }}>
<Kicker>Inside Today</Kicker>
<Headline weight="Low" as="h3" style={{ marginTop: 0 }}>
Senate Approves Climate Resolution After Months of Debate
</Headline>
<BodyText weight="Low">
<p>The unanimous vote concludes a contentious legislative session marked by partisan disputes
and last-minute amendments. Page A6.</p>
</BodyText>
<h2></h2>
<ul>
<li><strong>24 </strong> - </li>
<li><strong></strong> - </li>
<li><strong></strong> - High/Medium/Low </li>
<li><strong></strong> - CSS Variables</li>
<li><strong></strong> - 12-16 </li>
<li><strong> Layer</strong> - 广</li>
</ul>
<Rule variant="hairline" style={{ margin: '1rem 0' }} />
<h2></h2>
<Headline weight="Low" as="h3">Tech Sector Gains as Inflation Eases</Headline>
<BodyText weight="Low">
<p>Major indices climbed for a fifth consecutive session as new data showed price growth
slowing across consumer goods. Business B1.</p>
</BodyText>
<h3></h3>
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
<code>pnpm add @newspaperui/components @newspaperui/theme</code>
</pre>
<Rule variant="hairline" style={{ margin: '1rem 0' }} />
<h3>使</h3>
<Demo
title="简单的报纸布局"
description="使用 Layout 和 Section 创建基础的栅格布局"
code={`import { Layout, Section, Article, Headline, BodyText } from '@newspaperui/components';
<Headline weight="Low" as="h3">Drought Conditions Worsen Across the Plains</Headline>
<BodyText weight="Low">
<p>Officials in seven states have requested federal disaster relief as reservoir levels reach
historic lows. National A12.</p>
</BodyText>
function MyPage() {
return (
<Layout columns={24}>
<Section columns={8}>
<Article span={8}>
<Headline weight="High">主要新闻标题</Headline>
<BodyText>这是一篇重要的新闻内容...</BodyText>
<Rule variant="hairline" style={{ margin: '1rem 0' }} />
<Headline weight="Low" as="h3">New Exhibit Opens at the Metropolitan</Headline>
<BodyText weight="Low">
<p>A retrospective of mid-century textile design draws record opening crowds. Arts C3.</p>
</BodyText>
</Article>
<Article span={14}>
<div style={{ textAlign: 'center' }}><Kicker>Capitol · Breaking</Kicker></div>
<Headline weight="High" align="center">
Historic Accord Reshapes Continental Trade After Marathon Session
</Headline>
<Subhead weight="High" style={{ textAlign: 'center', marginTop: 0 }}>
Negotiators emerge with sweeping framework on tariffs, labor, and emissions; ratification expected within weeks
</Subhead>
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', margin: '0.5rem 0 1rem', alignItems: 'baseline' }}>
<Byline>By Eleanor Whitcombe and Marcus Reyes</Byline>
<span style={{ color: 'var(--nui-text-muted)' }}>·</span>
<span style={{ fontFamily: 'var(--font-family-meta)', fontSize: '12px', color: 'var(--nui-text-muted)' }}>5 min read</span>
</div>
<Figure
src="https://images.unsplash.com/photo-1572949645841-094f3a9c4c94?auto=format&fit=crop&w=1200&q=80"
alt="Diplomats applaud after the final draft was approved"
caption="Negotiators applaud after the final draft was approved Monday evening at the Continental Conference Center."
credit="Photograph by Jane Doe / Pool"
/>
<BodyText weight="High" columns={3} dropCap style={{ marginTop: '1.5rem' }}>
<p><Dateline>Brussels </Dateline> After eleven consecutive days of negotiation that several
participants described as the most demanding in a generation, delegates from twenty-three nations
announced on Monday a sweeping framework to reorganize commerce across the continent. The accord,
which still requires ratification by member parliaments, 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. The chief negotiator, Margarethe Lindqvist, called the
outcome &ldquo;a long argument that finally became a conversation.&rdquo;</p>
<p>The framework&rsquo;s most consequential provisions target heavy industry. Cement, steel, and
chemical producers would face a graduated carbon levy beginning in 2028, with revenues recycled
into a continental investment fund for low-carbon manufacturing. Industry associations expressed
cautious support, while environmental groups praised the levy&rsquo;s binding architecture but warned
that the timeline gives polluters too much room to delay.</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. The currency
strengthened against the dollar by 0.7 percent. Bond yields, which had climbed throughout the
negotiations on fiscal-stability concerns, retreated to levels seen before the talks began.</p>
<p>Domestic political reaction was mixed. The accord&rsquo;s labor provisions, which establish minimum
standards for paid leave and collective bargaining, drew immediate praise from union federations
and equally immediate concern from chambers of commerce. The chairman of the Federation of
Industries warned that small firms would struggle with compliance costs absent transitional support.</p>
<p>Parliamentary leaders in three capitals signaled that ratification could occur before the
summer recess. Two governments, however, indicated that they would seek public referenda before
committing, a process likely to extend into the autumn. Analysts at the Centre for Trade Studies
estimated that full implementation, even on the most expedited timeline, would require at least
eighteen months.</p>
<p>For ordinary travelers and consumers, the immediate effects will be modest. Border procedures
and product standards remain governed by existing arrangements pending ratification. The longer
arc is what matters: a continent of historically fractious neighbors agreeing on a single set of
rules for the most consequential decade in living memory.</p>
</BodyText>
<PullQuote weight="High" author="Margarethe Lindqvist, Chief Negotiator" align="center" style={{ margin: '2rem 0' }}>
A long argument that finally became a conversation.
</PullQuote>
<BodyText weight="High" columns={2} style={{ marginTop: '1rem' }}>
<p>The accord&rsquo;s signing ceremony, originally scheduled for last Friday, was delayed three times
as drafters reconciled competing texts on dispute resolution. The final compromise establishes
an arbitration panel of nine jurists, three appointed by each of the bloc&rsquo;s three regional
groupings, with binding authority over commercial disputes exceeding twenty million units.</p>
<p>Critics on the populist right denounced the framework as an erosion of national sovereignty,
while critics on the left argued that the labor floor was set too low to meaningfully protect
workers in tighter regulatory regimes. Both camps signaled that ratification battles would be
fierce, particularly in legislatures with narrow majorities.</p>
</BodyText>
</Article>
<Article span={5} style={{ borderLeft: '1px solid var(--nui-rule-hairline)', paddingLeft: '1.5rem' }}>
<Kicker>Foreign Desk</Kicker>
<Headline weight="Medium" as="h2">
Coastal Nations Pledge Joint Action on Maritime Pollution
</Headline>
<Subhead weight="Medium">
Pact follows years of stalled regional talks and a cascade of recent shipping accidents.
</Subhead>
<Byline>By Tomás Almeida</Byline>
<BodyText weight="Medium" style={{ marginTop: '0.75rem' }}>
<p><Dateline>Lisbon </Dateline> Eleven coastal nations announced a binding compact to coordinate
cleanup operations and harmonize liability rules for vessels exceeding fifty thousand tons. The
agreement establishes a shared rapid-response fund and creates a regional inspectorate empowered
to detain non-compliant ships in any signatory port.</p>
<p>Maritime industry groups received the news with caution. A spokesperson for the Continental
Shipping Council acknowledged that &ldquo;stronger common rules are overdue&rdquo; but warned that
implementation costs could fall disproportionately on smaller operators.</p>
<p>The compact takes effect on January 1, pending technical annexes. Environmental observers
described the pact as the most consequential maritime accord in a decade.</p>
</BodyText>
</Article>
</Section>
<Section columns={16}>
<Article span={8}>
<Headline weight="Medium">次要新闻</Headline>
<BodyText>这是另一篇新闻...</BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium">更多新闻</Headline>
<BodyText>更多内容...</BodyText>
<Section columns={24} divider="top" gap="2rem" style={{ marginTop: '2rem', paddingTop: '2rem' }}>
<Article span={24}>
<Kicker>National · Investigation</Kicker>
<Headline weight="Medium" as="h2">
Records Reveal Years of Overlooked Warnings at Aging Reservoirs
</Headline>
<Subhead weight="Medium">
Internal inspection memoranda, obtained through public records requests, suggest that
structural concerns flagged repeatedly by field engineers were not escalated to senior staff.
</Subhead>
<Byline style={{ marginBottom: '1rem' }}>By Ravi Nair, Anita Kowalski, and Charles Weston</Byline>
<BodyText weight="High" columns={4}>
<p><Dateline>Sacramento </Dateline> A six-month review of more than four thousand pages of
inspection records, interviews with twenty-three current and former engineers, and reconstructions
of three near-failure incidents reveals a pattern of unheeded warnings about the structural
integrity of mid-twentieth-century earthen dams across the western states.</p>
<p>The records show that field engineers documented concerns about seepage, erosion, and spillway
capacity in repeated annual assessments dating back at least fifteen years. In several instances,
those concerns were rated &ldquo;moderate&rdquo; in the field reports but downgraded to &ldquo;low&rdquo; by the time they
reached senior officials. The pattern was particularly pronounced at three facilities serving
regions of more than two million residents.</p>
<p>Officials at the Department of Water Resources, asked to review excerpts of the records, said
in a written statement that &ldquo;every reservoir under our oversight has been deemed safe for current
operations&rdquo; but did not specifically address the discrepancies between field and final ratings.
The agency declined to make senior staff available for interviews.</p>
<p>The findings come amid renewed scrutiny of aging infrastructure following the partial collapse
of an earthen embankment in March that displaced more than fifteen hundred residents. Federal
inspectors who responded to that incident found the proximate cause to be precisely the type of
seepage concern that field engineers had flagged in three of the past four annual assessments.</p>
<p>The investigative review found that of forty-seven reservoirs surveyed, sixteen had at least
one instance in which a &ldquo;moderate&rdquo; or &ldquo;high&rdquo; field rating was downgraded before reaching senior
management. In nine cases, the downgrades persisted for three or more consecutive years. None of
the affected facilities have publicly disclosed the discrepancies.</p>
<p>Engineering professional associations have, in recent years, called for an independent review
of inspection workflows in the western states. A spokesperson for the Society of Hydraulic
Engineers said the Society was &ldquo;deeply concerned&rdquo; by the patterns described and would convene a
working group to examine reform options.</p>
</BodyText>
</Article>
</Section>
</Layout>
);
}`}
>
<Layout columns={24}>
<Section columns={8}>
<Article span={8}>
<Headline weight="High"></Headline>
<BodyText>使 High </BodyText>
</Article>
</Section>
<Section columns={16}>
<Article span={8}>
<Headline weight="Medium"></Headline>
<BodyText>使 Medium </BodyText>
</Article>
<Article span={8}>
<Headline weight="Medium"></Headline>
<BodyText></BodyText>
</Article>
</Section>
</Layout>
</Demo>
<h2></h2>
<p>
</p>
<ul>
<li><a href="/grid-system"></a> - 24 </li>
<li><a href="/components/article"></a> - ArticleLayer </li>
<li><a href="/text"></a> - </li>
<li><a href="/theme"></a> - </li>
<li><a href="/examples/spanning"></a> - </li>
</ul>
</div>
);
}
-334
View File
@@ -1,334 +0,0 @@
'use client';
import { Demo } from '@/components/Demo';
import { PropsTable } from '@/components/PropsTable';
import { Headline, Subhead, BodyText, Quote, Byline, Caption } from '@newspaperui/components';
export default function TextPage() {
return (
<div className="prose prose-lg max-w-none">
<h1></h1>
<p className="text-xl text-gray-600">
NewspaperUI
</p>
<h2></h2>
<p>
Visual Weight NewspaperUI
HighMediumLow
</p>
<h2>Headline </h2>
<p>
<code>Headline</code>
</p>
<PropsTable
data={[
{
name: 'weight',
type: '"High" | "Medium" | "Low"',
default: '"Medium"',
description: '视觉权重级别',
},
{
name: 'as',
type: '"h1" | "h2" | "h3" | "h4" | "h5" | "h6"',
default: '"h2"',
description: 'HTML 标签类型',
},
{
name: 'className',
type: 'string',
description: '自定义 CSS 类名',
},
{
name: 'children',
type: 'React.ReactNode',
required: true,
description: '标题内容',
},
]}
/>
<Demo
title="标题视觉权重"
description="展示不同权重的标题效果"
code={`<Headline weight="High">High 权重标题</Headline>
<Headline weight="Medium">Medium 权重标题</Headline>
<Headline weight="Low">Low 权重标题</Headline>`}
>
<div className="space-y-4">
<Headline weight="High">High (36-48px, 700)</Headline>
<Headline weight="Medium">Medium (28-34px, 600)</Headline>
<Headline weight="Low">Low (22-26px, 500)</Headline>
</div>
</Demo>
<h2>Subhead </h2>
<p>
<code>Subhead</code>
</p>
<PropsTable
data={[
{
name: 'weight',
type: '"High" | "Medium"',
default: '"Medium"',
description: '视觉权重级别',
},
{
name: 'className',
type: 'string',
description: '自定义 CSS 类名',
},
{
name: 'children',
type: 'React.ReactNode',
required: true,
description: '副标题内容',
},
]}
/>
<Demo
title="副标题示例"
description="标题和副标题的组合使用"
code={`<Headline weight="High">主标题</Headline>
<Subhead weight="High">副标题说明文字</Subhead>
<BodyText>正文内容...</BodyText>`}
>
<div className="space-y-2">
<Headline weight="High"></Headline>
<Subhead weight="High"></Subhead>
<BodyText>...</BodyText>
</div>
</Demo>
<h2>BodyText </h2>
<p>
<code>BodyText</code>
</p>
<PropsTable
data={[
{
name: 'weight',
type: '"High" | "Medium" | "Low"',
default: '"Medium"',
description: '视觉权重级别',
},
{
name: 'className',
type: 'string',
description: '自定义 CSS 类名',
},
{
name: 'children',
type: 'React.ReactNode',
required: true,
description: '正文内容',
},
]}
/>
<Demo
title="正文文本权重"
description="不同权重的正文文本"
code={`<BodyText weight="High">High 权重正文 (16px)</BodyText>
<BodyText weight="Medium">Medium 权重正文 (14-15px)</BodyText>
<BodyText weight="Low">Low 权重正文 (12-14px)</BodyText>`}
>
<div className="space-y-3">
<BodyText weight="High">
High 16px
</BodyText>
<BodyText weight="Medium">
Medium 14-15px
</BodyText>
<BodyText weight="Low">
Low 12-14px
</BodyText>
</div>
</Demo>
<h2>Quote </h2>
<p>
<code>Quote</code>
</p>
<PropsTable
data={[
{
name: 'weight',
type: '"High" | "Medium"',
default: '"Medium"',
description: '视觉权重级别',
},
{
name: 'className',
type: 'string',
description: '自定义 CSS 类名',
},
{
name: 'children',
type: 'React.ReactNode',
required: true,
description: '引用内容',
},
]}
/>
<Demo
title="引用文本"
description="在文章中插入引用"
code={`<Quote weight="High">
这是一段重要的引用文字,来自某位专家的观点。
</Quote>`}
>
<Quote weight="High">
</Quote>
</Demo>
<h2>Byline Caption </h2>
<p>
<code>Byline</code> <code>Caption</code>
</p>
<PropsTable
data={[
{
name: 'className',
type: 'string',
description: '自定义 CSS 类名',
},
{
name: 'children',
type: 'React.ReactNode',
required: true,
description: '文本内容',
},
]}
/>
<Demo
title="署名和图注"
description="使用 Byline 和 Caption"
code={`<Headline weight="High">文章标题</Headline>
<Byline>作者:张三 | 2024年1月1日</Byline>
<BodyText>文章内容...</BodyText>
<Caption>图1:示例图片的说明文字</Caption>`}
>
<div className="space-y-2">
<Headline weight="High"></Headline>
<Byline> | 202411 | </Byline>
<BodyText>...</BodyText>
<div className="bg-gray-200 h-32 flex items-center justify-center">
<span className="text-gray-500"></span>
</div>
<Caption>1</Caption>
</div>
</Demo>
<h2></h2>
<p>
24
</p>
<div className="my-6 overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b-2 border-gray-300">
<th className="text-left py-2 px-3"></th>
<th className="text-left py-2 px-3"></th>
<th className="text-left py-2 px-3"></th>
<th className="text-left py-2 px-3"></th>
<th className="text-left py-2 px-3"></th>
<th className="text-left py-2 px-3"></th>
</tr>
</thead>
<tbody className="text-sm">
<tr className="border-b border-gray-200">
<td className="py-2 px-3">Headline</td>
<td className="py-2 px-3">High</td>
<td className="py-2 px-3">36-48px</td>
<td className="py-2 px-3">700</td>
<td className="py-2 px-3">1.1</td>
<td className="py-2 px-3">#111</td>
</tr>
<tr className="border-b border-gray-200">
<td className="py-2 px-3">Headline</td>
<td className="py-2 px-3">Medium</td>
<td className="py-2 px-3">28-34px</td>
<td className="py-2 px-3">600</td>
<td className="py-2 px-3">1.2</td>
<td className="py-2 px-3">#111</td>
</tr>
<tr className="border-b border-gray-200">
<td className="py-2 px-3">Headline</td>
<td className="py-2 px-3">Low</td>
<td className="py-2 px-3">22-26px</td>
<td className="py-2 px-3">500</td>
<td className="py-2 px-3">1.3</td>
<td className="py-2 px-3">#222</td>
</tr>
<tr className="border-b border-gray-200">
<td className="py-2 px-3">Subhead</td>
<td className="py-2 px-3">High</td>
<td className="py-2 px-3">20-24px</td>
<td className="py-2 px-3">600</td>
<td className="py-2 px-3">1.25</td>
<td className="py-2 px-3">#222</td>
</tr>
<tr className="border-b border-gray-200">
<td className="py-2 px-3">Subhead</td>
<td className="py-2 px-3">Medium</td>
<td className="py-2 px-3">16-18px</td>
<td className="py-2 px-3">500</td>
<td className="py-2 px-3">1.3</td>
<td className="py-2 px-3">#333</td>
</tr>
<tr className="border-b border-gray-200">
<td className="py-2 px-3">BodyText</td>
<td className="py-2 px-3">High</td>
<td className="py-2 px-3">16px</td>
<td className="py-2 px-3">400</td>
<td className="py-2 px-3">1.5</td>
<td className="py-2 px-3">#333</td>
</tr>
<tr className="border-b border-gray-200">
<td className="py-2 px-3">BodyText</td>
<td className="py-2 px-3">Medium</td>
<td className="py-2 px-3">14-15px</td>
<td className="py-2 px-3">400</td>
<td className="py-2 px-3">1.5</td>
<td className="py-2 px-3">#444</td>
</tr>
<tr className="border-b border-gray-200">
<td className="py-2 px-3">BodyText</td>
<td className="py-2 px-3">Low</td>
<td className="py-2 px-3">12-14px</td>
<td className="py-2 px-3">400</td>
<td className="py-2 px-3">1.4</td>
<td className="py-2 px-3">#555</td>
</tr>
</tbody>
</table>
</div>
<h2>使</h2>
<ul>
<li>使 Headline High </li>
<li>使 Headline Medium Low </li>
<li>使 BodyText Medium </li>
<li>使 BodyText High </li>
<li>使 BodyText Low </li>
<li>使 Quote </li>
<li>使 Byline </li>
<li>使 Caption </li>
</ul>
</div>
);
}
-310
View File
@@ -1,310 +0,0 @@
import { CodeBlock } from '@/components/CodeBlock';
export default function ThemePage() {
return (
<div className="prose prose-lg max-w-none">
<h1></h1>
<p className="text-xl text-gray-600">
NewspaperUI CSS Variables
</p>
<h2>CSS Variables</h2>
<p>
使 CSS CSS Variables
使
</p>
<h3></h3>
<CodeBlock
title="CSS Variables"
language="css"
code={`:root {
/* 颜色系统 */
--color-text-primary: #111;
--color-text-secondary: #222;
--color-text-tertiary: #333;
--color-text-muted: #555;
--color-background: #ffffff;
--color-border: #e5e7eb;
/* 字体系统 */
--font-family-serif: Georgia, 'Times New Roman', serif;
--font-family-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-family-mono: 'Courier New', monospace;
/* 间距系统 */
--spacing-gutter: 1rem;
--spacing-section: 2rem;
--spacing-article: 1.5rem;
/* 栅格系统 */
--grid-columns: 24;
--grid-max-width: 1280px;
}`}
/>
<h2>Tailwind </h2>
<p>
NewspaperUI Tailwind CSS Tailwind 使
</p>
<CodeBlock
title="tailwind.config.js"
language="javascript"
code={`import { newspaperTheme } from '@newspaperui/theme';
export default {
content: ['./src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
newspaper: {
text: {
primary: 'var(--color-text-primary)',
secondary: 'var(--color-text-secondary)',
tertiary: 'var(--color-text-tertiary)',
muted: 'var(--color-text-muted)',
},
},
},
fontFamily: {
serif: 'var(--font-family-serif)',
sans: 'var(--font-family-sans)',
mono: 'var(--font-family-mono)',
},
},
},
plugins: [newspaperTheme],
};`}
/>
<h2>使</h2>
<p>
使 CSS Variables Tailwind
</p>
<CodeBlock
title="使用示例"
language="tsx"
code={`// 使用 CSS Variables
<div style={{ color: 'var(--color-text-primary)' }}>
主要文本
</div>
// 使用 Tailwind 工具类
<div className="text-newspaper-text-primary">
主要文本
</div>`}
/>
<h2></h2>
<p>
CSS Variables
</p>
<CodeBlock
title="自定义主题"
language="css"
code={`:root {
/* 覆盖默认颜色 */
--color-text-primary: #000000;
--color-text-secondary: #1a1a1a;
--color-background: #f9f9f9;
/* 自定义字体 */
--font-family-serif: 'Merriweather', Georgia, serif;
/* 调整间距 */
--spacing-gutter: 1.5rem;
--grid-max-width: 1440px;
}`}
/>
<h2></h2>
<p>
CSS Variables
</p>
<CodeBlock
title="深色模式"
language="css"
code={`@media (prefers-color-scheme: dark) {
:root {
--color-text-primary: #f5f5f5;
--color-text-secondary: #e0e0e0;
--color-text-tertiary: #cccccc;
--color-text-muted: #999999;
--color-background: #1a1a1a;
--color-border: #333333;
}
}
/* 或使用类名切换 */
.dark {
--color-text-primary: #f5f5f5;
--color-text-secondary: #e0e0e0;
--color-background: #1a1a1a;
}`}
/>
<h2></h2>
<p>
</p>
<CodeBlock
title="自定义视觉权重"
language="typescript"
code={`import { visualWeights } from '@newspaperui/theme';
// 查看默认配置
console.log(visualWeights.Headline.High);
// {
// fontSize: '36px',
// fontWeight: 700,
// lineHeight: 1.1,
// color: '#111',
// span: [6, 8],
// margin: '0 0 1rem 0',
// }
// 自定义权重配置
const customWeights = {
...visualWeights,
Headline: {
...visualWeights.Headline,
High: {
...visualWeights.Headline.High,
fontSize: '48px', // 更大的标题
fontWeight: 800,
},
},
};`}
/>
<h2></h2>
<p>
</p>
<CodeBlock
title="响应式变量"
language="css"
code={`:root {
--grid-columns: 24;
--spacing-gutter: 1rem;
}
@media (max-width: 768px) {
:root {
--grid-columns: 12;
--spacing-gutter: 0.75rem;
}
}
@media (max-width: 480px) {
:root {
--grid-columns: 6;
--spacing-gutter: 0.5rem;
}
}`}
/>
<h2></h2>
<ul>
<li>使 <code>--color-text-primary</code> <code>--color-black</code></li>
<li> WCAG </li>
<li>使remempx</li>
<li></li>
<li></li>
<li></li>
</ul>
<h2></h2>
<p>
NewspaperUI
</p>
<CodeBlock
title="导入主题"
language="typescript"
code={`// 在你的主 CSS 文件中
import '@newspaperui/theme/variables.css';
// 或在 Next.js 的 _app.tsx 中
import '@newspaperui/theme/variables.css';
import '@newspaperui/theme/tailwind.css';`}
/>
<h2></h2>
<p>
NewspaperUI 使
</p>
<CodeBlock
title="主题工具"
language="typescript"
code={`import { getVisualWeight, applyTheme } from '@newspaperui/theme';
// 获取特定组件的视觉权重配置
const headlineHighConfig = getVisualWeight('Headline', 'High');
// 应用主题到元素
applyTheme(element, {
'--color-text-primary': '#000',
'--font-family-serif': 'Georgia',
});`}
/>
<h2></h2>
<p>
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 my-8">
<div className="border border-gray-200 rounded-lg p-6">
<h3 className="text-lg font-semibold mb-3"></h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span></span>
<span className="font-mono">#111111</span>
</div>
<div className="flex justify-between">
<span></span>
<span className="font-mono">#ffffff</span>
</div>
<div className="flex justify-between">
<span></span>
<span className="font-mono">Georgia</span>
</div>
</div>
</div>
<div className="border border-gray-200 rounded-lg p-6 bg-gray-900 text-white">
<h3 className="text-lg font-semibold mb-3"></h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span></span>
<span className="font-mono">#f5f5f5</span>
</div>
<div className="flex justify-between">
<span></span>
<span className="font-mono">#1a1a1a</span>
</div>
<div className="flex justify-between">
<span></span>
<span className="font-mono">Georgia</span>
</div>
</div>
</div>
</div>
<h2></h2>
<ul>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties">MDN: CSS Custom Properties</a></li>
<li><a href="https://tailwindcss.com/docs/customizing-colors">Tailwind CSS: Customizing Colors</a></li>
<li><a href="https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html">WCAG: Contrast Guidelines</a></li>
</ul>
</div>
);
}
+111 -73
View File
@@ -1,96 +1,134 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cx } from '@newspaperui/utils';
interface NavItem {
title: string;
label: string;
href: string;
items?: NavItem[];
children?: NavItem[];
}
const navigation: NavItem[] = [
const nav: NavItem[] = [
{ label: '概览 / 头版', href: '/' },
{ label: '栅格系统', href: '/grid-system' },
{
title: '概览',
href: '/',
},
{
title: '栅格系统',
href: '/grid-system',
},
{
title: '核心组件',
href: '/components',
items: [
{ title: 'Article', href: '/components/article' },
{ title: 'Layer', href: '/components/layer' },
{ title: '媒体组件', href: '/components/media' },
label: '布局组件',
href: '/components/article',
children: [
{ label: 'Masthead', href: '/components/masthead' },
{ label: 'Article + Layer', href: '/components/article' },
{ label: 'Rule 分隔线', href: '/components/rule' },
],
},
{ label: '文本组件', href: '/text' },
{ label: '媒体组件', href: '/components/media' },
{ label: '主题与颜色', href: '/theme' },
{
title: '文本组件',
href: '/text',
},
{
title: '主题系统',
href: '/theme',
},
{
title: '示例',
href: '/examples',
items: [
{ title: '跨栏布局', href: '/examples/spanning' },
{ title: '响应式布局', href: '/examples/responsive' },
label: '示例',
href: '/examples/spanning',
children: [
{ label: '跨栏布局', href: '/examples/spanning' },
{ label: '响应式', href: '/examples/responsive' },
{ label: 'Blackletter 头版', href: '/examples/blackletter-frontpage' },
],
},
];
export function Sidebar() {
const pathname = usePathname();
return (
<nav className="w-64 border-r border-gray-200 bg-white h-screen sticky top-0 overflow-y-auto">
<div className="p-6">
<Link href="/" className="text-xl font-bold text-gray-900">
NewspaperUI
</Link>
<p className="mt-1 text-sm text-gray-600"></p>
<aside
style={{
width: '240px',
flexShrink: 0,
padding: '2rem 1rem',
borderRight: '1px solid var(--nui-rule-hairline)',
background: 'var(--nui-bg-surface)',
fontFamily: 'var(--font-family-meta)',
fontSize: '14px',
position: 'sticky',
top: 0,
alignSelf: 'flex-start',
maxHeight: '100vh',
overflowY: 'auto',
}}
>
<Link
href="/"
style={{
display: 'block',
fontFamily: 'var(--font-family-masthead)',
fontSize: '22px',
fontWeight: 700,
color: 'var(--nui-text-primary)',
textDecoration: 'none',
marginBottom: '0.25rem',
letterSpacing: '0.02em',
}}
>
NewspaperUI
</Link>
<div
className="nui-small-caps"
style={{
fontSize: '11px',
color: 'var(--nui-text-muted)',
marginBottom: '2rem',
letterSpacing: '0.08em',
}}
>
Production Newspaper Components
</div>
<div className="px-4 pb-6">
{navigation.map((item) => (
<div key={item.href} className="mb-4">
<Link
href={item.href}
className={`block px-3 py-2 rounded-md text-sm font-medium ${
pathname === item.href
? 'bg-gray-100 text-gray-900'
: 'text-gray-700 hover:bg-gray-50'
}`}
>
{item.title}
</Link>
{item.items && (
<div className="ml-4 mt-2 space-y-1">
{item.items.map((subItem) => (
<Link
key={subItem.href}
href={subItem.href}
className={`block px-3 py-2 rounded-md text-sm ${
pathname === subItem.href
? 'bg-gray-100 text-gray-900'
: 'text-gray-600 hover:bg-gray-50'
}`}
>
{subItem.title}
</Link>
))}
</div>
)}
</div>
))}
</div>
</nav>
<nav>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{nav.map((item) => (
<li key={item.href} style={{ marginBottom: '0.75rem' }}>
<Link
href={item.href}
className={cx(pathname === item.href && 'active')}
style={{
display: 'block',
padding: '0.25rem 0',
color:
pathname === item.href
? 'var(--nui-accent-primary)'
: 'var(--nui-text-secondary)',
textDecoration: 'none',
fontWeight: pathname === item.href ? 600 : 400,
}}
>
{item.label}
</Link>
{item.children && (
<ul style={{ listStyle: 'none', padding: '0 0 0 1rem', margin: '0.25rem 0 0' }}>
{item.children.map((child) => (
<li key={child.href} style={{ marginBottom: '0.25rem' }}>
<Link
href={child.href}
style={{
display: 'block',
padding: '0.15rem 0',
fontSize: '13px',
color:
pathname === child.href
? 'var(--nui-accent-primary)'
: 'var(--nui-text-muted)',
textDecoration: 'none',
fontWeight: pathname === child.href ? 600 : 400,
}}
>
{child.label}
</Link>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</nav>
</aside>
);
}
+1
View File
@@ -12,6 +12,7 @@
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./dist/style.css": "./dist/style.css",
"./variables.css": "./src/variables.css",
"./tailwind.config": "./src/tailwind.config.js"
},
+4 -19
View File
@@ -1,21 +1,6 @@
/**
* @newspaperui/theme
* Theme tokens and CSS variables for newspaperui
*/
// Import CSS variables
import './fonts.css';
import './variables.css';
import './typography.css';
// Export visual weight types and configuration
export type {
VisualWeight,
ComponentType,
VisualWeightConfig,
} from './visual-weights';
export { visualWeights, resolveFontSize } from './visual-weights';
// Export Tailwind config for consumers
export { default as tailwindConfig } from './tailwind.config.js';
export const version = '0.0.0';
export { visualWeights, resolveFontSize, resolveSpan } from './visual-weights';
export type { VisualWeight, ComponentType, VisualWeightConfig } from './visual-weights';
+23 -95
View File
@@ -1,103 +1,31 @@
/**
* Tailwind CSS Configuration for NewspaperUI
* Extends Tailwind with newspaper-specific utilities and 24-column grid
*/
const config = {
/** @type {import('tailwindcss').Config} */
module.exports = {
theme: {
extend: {
colors: {
nui: {
text: {
primary: 'var(--nui-color-text-primary)',
secondary: 'var(--nui-color-text-secondary)',
tertiary: 'var(--nui-color-text-tertiary)',
quaternary: 'var(--nui-color-text-quaternary)',
muted: 'var(--nui-color-text-muted)',
},
gray: {
50: 'var(--nui-color-gray-50)',
100: 'var(--nui-color-gray-100)',
200: 'var(--nui-color-gray-200)',
300: 'var(--nui-color-gray-300)',
400: 'var(--nui-color-gray-400)',
500: 'var(--nui-color-gray-500)',
600: 'var(--nui-color-gray-600)',
700: 'var(--nui-color-gray-700)',
800: 'var(--nui-color-gray-800)',
900: 'var(--nui-color-gray-900)',
},
accent: {
primary: 'var(--nui-color-accent-primary)',
secondary: 'var(--nui-color-accent-secondary)',
tertiary: 'var(--nui-color-accent-tertiary)',
},
},
},
spacing: {
'nui-gutter': 'var(--nui-gutter)',
'nui-gutter-sm': 'var(--nui-gutter-sm)',
'nui-gutter-lg': 'var(--nui-gutter-lg)',
},
fontFamily: {
'nui-serif': 'var(--nui-font-serif)',
'nui-serif-display': 'var(--nui-font-serif-display)',
'nui-sans': 'var(--nui-font-sans)',
'nui-sans-condensed': 'var(--nui-font-sans-condensed)',
'nui-mono': 'var(--nui-font-mono)',
masthead: ['var(--font-family-masthead)'],
blackletter: ['var(--font-family-blackletter)'],
display: ['var(--font-family-display)'],
headline: ['var(--font-family-headline)'],
body: ['var(--font-family-body)'],
meta: ['var(--font-family-meta)'],
},
fontSize: {
'nui-xs': 'var(--nui-font-size-xs)',
'nui-sm': 'var(--nui-font-size-sm)',
'nui-base': 'var(--nui-font-size-base)',
'nui-lg': 'var(--nui-font-size-lg)',
'nui-xl': 'var(--nui-font-size-xl)',
'nui-2xl': 'var(--nui-font-size-2xl)',
'nui-3xl': 'var(--nui-font-size-3xl)',
'nui-4xl': 'var(--nui-font-size-4xl)',
'nui-5xl': 'var(--nui-font-size-5xl)',
'nui-6xl': 'var(--nui-font-size-6xl)',
},
fontWeight: {
'nui-normal': 'var(--nui-font-weight-normal)',
'nui-medium': 'var(--nui-font-weight-medium)',
'nui-semibold': 'var(--nui-font-weight-semibold)',
'nui-bold': 'var(--nui-font-weight-bold)',
},
lineHeight: {
'nui-tight': 'var(--nui-line-height-tight)',
'nui-snug': 'var(--nui-line-height-snug)',
'nui-normal': 'var(--nui-line-height-normal)',
'nui-relaxed': 'var(--nui-line-height-relaxed)',
'nui-loose': 'var(--nui-line-height-loose)',
'nui-body': 'var(--nui-line-height-body)',
},
maxWidth: {
'nui-grid': 'var(--nui-grid-max-width)',
},
// 24-column grid utilities
gridTemplateColumns: {
'nui-24': 'repeat(24, minmax(0, 1fr))',
'nui-16': 'repeat(16, minmax(0, 1fr))',
'nui-12': 'repeat(12, minmax(0, 1fr))',
},
gridColumn: {
'span-13': 'span 13 / span 13',
'span-14': 'span 14 / span 14',
'span-15': 'span 15 / span 15',
'span-16': 'span 16 / span 16',
'span-17': 'span 17 / span 17',
'span-18': 'span 18 / span 18',
'span-19': 'span 19 / span 19',
'span-20': 'span 20 / span 20',
'span-21': 'span 21 / span 21',
'span-22': 'span 22 / span 22',
'span-23': 'span 23 / span 23',
'span-24': 'span 24 / span 24',
colors: {
page: 'var(--nui-bg-page)',
surface: 'var(--nui-bg-surface)',
primary: 'var(--nui-text-primary)',
body: 'var(--nui-text-body)',
secondary: 'var(--nui-text-secondary)',
muted: 'var(--nui-text-muted)',
quote: 'var(--nui-text-quote)',
hairline: 'var(--nui-rule-hairline)',
decorative: 'var(--nui-rule-decorative)',
accent: {
primary: 'var(--nui-accent-primary)',
'ink-blue': 'var(--nui-accent-ink-blue)',
},
highlight: 'var(--nui-highlight)',
},
},
},
plugins: [],
};
export default config;
+45 -117
View File
@@ -1,127 +1,55 @@
/**
* NewspaperUI Theme Variables
* Global CSS variables for newspaper layout system
*/
:root {
/* ========== Color System ========== */
/* Primary text colors */
--nui-color-text-primary: #111111;
--nui-color-text-secondary: #222222;
--nui-color-text-tertiary: #333333;
--nui-color-text-quaternary: #444444;
--nui-color-text-muted: #555555;
/* font families */
--font-family-masthead: "Cormorant Garamond", "Playfair Display", Georgia, serif;
--font-family-blackletter: "UnifrakturMaguntia", "Cormorant Garamond", serif;
--font-family-display: "Source Serif 4", Georgia, "Times New Roman", serif;
--font-family-headline: "Source Serif 4", Georgia, "Times New Roman", serif;
--font-family-body: "Source Serif 4", Georgia, "Times New Roman", serif;
--font-family-meta: "Inter", system-ui, sans-serif;
/* Gray scale */
--nui-color-gray-50: #fafafa;
--nui-color-gray-100: #f5f5f5;
--nui-color-gray-200: #e5e5e5;
--nui-color-gray-300: #d4d4d4;
--nui-color-gray-400: #a3a3a3;
--nui-color-gray-500: #737373;
--nui-color-gray-600: #525252;
--nui-color-gray-700: #404040;
--nui-color-gray-800: #262626;
--nui-color-gray-900: #171717;
/* page background */
--nui-bg-page: #F7F4ED;
--nui-bg-surface: #FBF9F4;
/* Accent colors */
--nui-color-accent-primary: #0066cc;
--nui-color-accent-secondary: #cc0000;
--nui-color-accent-tertiary: #006600;
/* text */
--nui-text-primary: #1A1A1A;
--nui-text-body: #22201C;
--nui-text-secondary: #4A4742;
--nui-text-muted: #6E6A63;
--nui-text-quote: #2E2A24;
/* ========== Spacing System ========== */
/* Gutter (column gap) */
--nui-gutter: 1rem;
--nui-gutter-sm: 0.75rem;
--nui-gutter-lg: 1.5rem;
/* rules */
--nui-rule-hairline: #C9C2B2;
--nui-rule-decorative: #1A1A1A;
/* Margin */
--nui-margin-xs: 0.25rem;
--nui-margin-sm: 0.5rem;
--nui-margin-md: 0.75rem;
--nui-margin-lg: 1rem;
--nui-margin-xl: 1.5rem;
/* accents */
--nui-accent-primary: #7A1F1F;
--nui-accent-ink-blue: #1B2A4A;
--nui-highlight: #F2E9C8;
/* Padding */
--nui-padding-xs: 0.25rem;
--nui-padding-sm: 0.5rem;
--nui-padding-md: 0.75rem;
--nui-padding-lg: 1rem;
--nui-padding-xl: 1.5rem;
/* ========== Font System ========== */
/* Serif fonts for headlines and body text */
--nui-font-serif: 'Georgia', 'Times New Roman', serif;
--nui-font-serif-display: 'Playfair Display', 'Georgia', serif;
/* Sans-serif fonts for UI elements */
--nui-font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
--nui-font-sans-condensed: 'Arial Narrow', 'Helvetica Condensed', sans-serif;
/* Monospace for code */
--nui-font-mono: 'Courier New', Courier, monospace;
/* ========== Grid System ========== */
/* 24-column grid */
--nui-grid-columns: 24;
--nui-grid-max-width: 1440px;
--nui-grid-container-padding: 2rem;
/* Responsive breakpoints */
--nui-breakpoint-sm: 768px;
--nui-breakpoint-md: 1024px;
--nui-breakpoint-lg: 1440px;
/* ========== Typography Scale ========== */
/* Font sizes */
--nui-font-size-xs: 0.75rem; /* 12px */
--nui-font-size-sm: 0.875rem; /* 14px */
--nui-font-size-base: 1rem; /* 16px */
--nui-font-size-lg: 1.125rem; /* 18px */
--nui-font-size-xl: 1.25rem; /* 20px */
--nui-font-size-2xl: 1.5rem; /* 24px */
--nui-font-size-3xl: 1.75rem; /* 28px */
--nui-font-size-4xl: 2rem; /* 32px */
--nui-font-size-5xl: 2.25rem; /* 36px */
--nui-font-size-6xl: 3rem; /* 48px */
/* Font weights */
--nui-font-weight-normal: 400;
--nui-font-weight-medium: 500;
--nui-font-weight-semibold: 600;
--nui-font-weight-bold: 700;
/* Line heights */
--nui-line-height-tight: 1.1;
--nui-line-height-snug: 1.2;
--nui-line-height-normal: 1.25;
--nui-line-height-relaxed: 1.3;
--nui-line-height-loose: 1.4;
--nui-line-height-body: 1.5;
/* spacing */
--nui-gutter: 1.5rem;
--nui-space-1: 0.25rem;
--nui-space-2: 0.5rem;
--nui-space-3: 0.75rem;
--nui-space-4: 1rem;
--nui-space-6: 1.5rem;
--nui-space-8: 2rem;
}
[data-theme="dark"] {
/* Primary text colors */
--nui-color-text-primary: #eeeeee;
--nui-color-text-secondary: #dddddd;
--nui-color-text-tertiary: #cccccc;
--nui-color-text-quaternary: #bbbbbb;
--nui-color-text-muted: #aaaaaa;
/* Gray scale */
--nui-color-gray-50: #171717;
--nui-color-gray-100: #262626;
--nui-color-gray-200: #404040;
--nui-color-gray-300: #525252;
--nui-color-gray-400: #737373;
--nui-color-gray-500: #a3a3a3;
--nui-color-gray-600: #d4d4d4;
--nui-color-gray-700: #e5e5e5;
--nui-color-gray-800: #f5f5f5;
--nui-color-gray-900: #fafafa;
/* Accent colors */
--nui-color-accent-primary: #4da6ff;
--nui-color-accent-secondary: #ff6666;
--nui-color-accent-tertiary: #66cc66;
--nui-bg-page: #14110D;
--nui-bg-surface: #1C1812;
--nui-text-primary: #EDE6D6;
--nui-text-body: #DCD4C2;
--nui-text-secondary: #A89F8C;
--nui-text-muted: #857C6A;
--nui-text-quote: #DCD4C2;
--nui-rule-hairline: #3A352C;
--nui-rule-decorative: #EDE6D6;
--nui-accent-primary: #C97A6E;
--nui-accent-ink-blue: #7E94C2;
--nui-highlight: #3A2F18;
}
html, body { background: var(--nui-bg-page); color: var(--nui-text-body); }
+157 -74
View File
@@ -1,164 +1,247 @@
/**
* Resolves a fontSize value to a single CSS string.
* For range tuples [min, max], returns the lower bound (min).
* Visual weight mapping for newspaper components.
* Reference: design.md §5 + classic-style design audit revision.
*
* fontFamily / color use CSS variable NAMES (e.g. '--font-family-headline').
* Components do `var(${config.fontFamily})` at usage site.
*/
export function resolveFontSize(fontSize: string | [string, string]): string {
return Array.isArray(fontSize) ? fontSize[0] : fontSize;
}
export type VisualWeight = 'High' | 'Medium' | 'Low' | 'Standard';
export type ComponentType =
| 'Masthead'
| 'Headline'
| 'Subhead'
| 'Kicker'
| 'BodyText'
| 'Quote'
| 'PullQuote'
| 'Byline'
| 'Dateline'
| 'Caption';
export interface VisualWeightConfig {
fontFamily: string; // CSS variable name, e.g. '--font-family-headline'
fontSize: string | [string, string];
fontWeight: number;
lineHeight: number;
color: string;
letterSpacing?: string;
fontStyle?: 'normal' | 'italic';
fontVariant?: string; // 'small-caps' | 'oldstyle-nums proportional-nums' …
color: string; // CSS variable name, e.g. '--nui-text-primary'
span: number | [number, number];
margin: string;
}
/**
* Visual weight configuration mapping
* Maps component types and their visual weights to specific styling configurations
*/
export const visualWeights: Record<
ComponentType,
Partial<Record<VisualWeight, VisualWeightConfig>>
> = {
export const visualWeights: Record<ComponentType, Partial<Record<VisualWeight, VisualWeightConfig>>> = {
Masthead: {
Standard: {
fontFamily: '--font-family-masthead',
fontSize: ['56px', '96px'],
fontWeight: 700,
lineHeight: 1.0,
letterSpacing: '0.02em',
fontVariant: 'lining-nums',
color: '--nui-text-primary',
span: 24,
margin: '0',
},
},
Headline: {
High: {
fontSize: ['36px', '48px'],
fontWeight: 700,
lineHeight: 1.1,
color: '#111',
span: [6, 8],
fontFamily: '--font-family-display',
fontSize: ['48px', '72px'],
fontWeight: 600,
lineHeight: 1.05,
letterSpacing: '-0.01em',
fontVariant: 'lining-nums',
color: '--nui-text-primary',
span: [8, 16],
margin: '0 0 1rem 0',
},
Medium: {
fontSize: ['28px', '34px'],
fontFamily: '--font-family-headline',
fontSize: ['32px', '40px'],
fontWeight: 600,
lineHeight: 1.2,
color: '#111',
span: [4, 6],
lineHeight: 1.1,
letterSpacing: '-0.005em',
fontVariant: 'lining-nums',
color: '--nui-text-primary',
span: [6, 10],
margin: '0 0 0.75rem 0',
},
Low: {
fontFamily: '--font-family-headline',
fontSize: ['22px', '26px'],
fontWeight: 500,
lineHeight: 1.3,
color: '#222',
span: [2, 4],
lineHeight: 1.2,
fontVariant: 'lining-nums',
color: '--nui-text-body',
span: [4, 6],
margin: '0 0 0.5rem 0',
},
},
Subhead: {
High: {
fontSize: ['20px', '24px'],
fontWeight: 600,
lineHeight: 1.25,
color: '#222',
span: [2, 3],
margin: '0 0 0.5rem 0',
fontFamily: '--font-family-headline',
fontSize: ['18px', '22px'],
fontWeight: 500,
fontStyle: 'italic',
lineHeight: 1.3,
fontVariant: 'oldstyle-nums',
color: '--nui-text-secondary',
span: [4, 8],
margin: '0 0 0.75rem 0',
},
Medium: {
fontFamily: '--font-family-headline',
fontSize: ['16px', '18px'],
fontWeight: 500,
lineHeight: 1.3,
color: '#333',
span: [1, 2],
fontWeight: 400,
fontStyle: 'italic',
lineHeight: 1.35,
fontVariant: 'oldstyle-nums',
color: '--nui-text-secondary',
span: [2, 4],
margin: '0 0 0.5rem 0',
},
},
Kicker: {
Standard: {
fontFamily: '--font-family-meta',
fontSize: ['12px', '13px'],
fontWeight: 600,
lineHeight: 1.2,
letterSpacing: '0.08em',
fontVariant: 'small-caps',
color: '--nui-accent-primary',
span: [1, 4],
margin: '0 0 0.25rem 0',
},
},
BodyText: {
High: {
fontSize: '16px',
fontFamily: '--font-family-body',
fontSize: ['16px', '17px'],
fontWeight: 400,
lineHeight: 1.5,
color: '#333',
span: 1,
margin: '0 0 1rem 0',
},
Medium: {
fontSize: ['14px', '15px'],
fontWeight: 400,
lineHeight: 1.5,
color: '#444',
lineHeight: 1.6,
fontVariant: 'oldstyle-nums',
color: '--nui-text-body',
span: 1,
margin: '0 0 0.75rem 0',
},
Low: {
fontSize: ['12px', '14px'],
Medium: {
fontFamily: '--font-family-body',
fontSize: '15px',
fontWeight: 400,
lineHeight: 1.4,
color: '#555',
lineHeight: 1.55,
fontVariant: 'oldstyle-nums',
color: '--nui-text-body',
span: 1,
margin: '0 0 0.5rem 0',
},
Low: {
fontFamily: '--font-family-body',
fontSize: ['13px', '14px'],
fontWeight: 400,
lineHeight: 1.5,
fontVariant: 'oldstyle-nums',
color: '--nui-text-secondary',
span: 1,
margin: '0 0 0.5rem 0',
},
},
Quote: {
High: {
fontSize: ['20px', '24px'],
fontWeight: 500,
lineHeight: 1.4,
color: '#222',
fontFamily: '--font-family-body',
fontSize: ['17px', '19px'],
fontWeight: 400,
lineHeight: 1.55,
fontVariant: 'oldstyle-nums',
color: '--nui-text-quote',
span: 2,
margin: '0 0 0.75rem 0',
margin: '1rem 0 1rem 1.5em',
},
Medium: {
fontSize: ['16px', '18px'],
fontFamily: '--font-family-body',
fontSize: ['15px', '17px'],
fontWeight: 400,
lineHeight: 1.4,
color: '#333',
lineHeight: 1.5,
fontVariant: 'oldstyle-nums',
color: '--nui-text-quote',
span: 1,
margin: '0 0 0.5rem 0',
margin: '0.75rem 0',
},
},
PullQuote: {
High: {
fontSize: ['24px', '28px'],
fontFamily: '--font-family-display',
fontSize: ['26px', '32px'],
fontWeight: 600,
lineHeight: 1.2,
color: '#111',
letterSpacing: '-0.005em',
fontVariant: 'lining-nums',
color: '--nui-text-primary',
span: [2, 3],
margin: '0 0 0.5rem 0',
margin: '1.5rem 0',
},
Medium: {
fontSize: ['18px', '20px'],
fontFamily: '--font-family-display',
fontSize: ['20px', '24px'],
fontWeight: 500,
lineHeight: 1.25,
color: '#222',
fontVariant: 'lining-nums',
color: '--nui-text-body',
span: [1, 2],
margin: '0 0 0.25rem 0',
margin: '1rem 0',
},
},
Byline: {
Standard: {
fontSize: ['12px', '14px'],
fontWeight: 400,
fontFamily: '--font-family-meta',
fontSize: ['12px', '13px'],
fontWeight: 500,
lineHeight: 1.3,
color: '#555',
letterSpacing: '0.06em',
fontVariant: 'small-caps',
color: '--nui-text-secondary',
span: 1,
margin: '0 0 0.25rem 0',
},
},
Dateline: {
Standard: {
fontFamily: '--font-family-meta',
fontSize: ['12px', '13px'],
fontWeight: 600,
lineHeight: 1.3,
letterSpacing: '0.08em',
fontVariant: 'small-caps',
color: '--nui-text-primary',
span: 1,
margin: '0',
},
},
Caption: {
Standard: {
fontSize: ['12px', '14px'],
fontFamily: '--font-family-body',
fontSize: '13px',
fontWeight: 400,
lineHeight: 1.3,
color: '#555',
fontStyle: 'italic',
lineHeight: 1.4,
fontVariant: 'oldstyle-nums',
color: '--nui-text-secondary',
span: 1,
margin: '0.25rem 0',
margin: '0.5rem 0 0 0',
},
},
};
/** Resolve fontSize: tuple → first value (lower bound used by default). */
export function resolveFontSize(value: string | [string, string]): string {
return Array.isArray(value) ? value[0] : value;
}
/** Resolve span: tuple → first value (lower bound). */
export function resolveSpan(value: number | [number, number]): number {
return Array.isArray(value) ? value[0] : value;
}
+37 -52
View File
@@ -1,60 +1,45 @@
import { describe, it, expect } from 'vitest';
import { calculateSpanWidth, validateSpan, calculateGutter } from '../grid';
import { validateSpan, clampSpan } from '../grid';
describe('grid utilities', () => {
describe('calculateSpanWidth', () => {
it('should calculate correct width percentage for 24-column grid', () => {
expect(calculateSpanWidth(6, 24)).toBe('25.000%');
expect(calculateSpanWidth(12, 24)).toBe('50.000%');
expect(calculateSpanWidth(24, 24)).toBe('100.000%');
expect(calculateSpanWidth(1, 24)).toBe('4.167%');
});
it('should calculate correct width percentage for 12-column grid', () => {
expect(calculateSpanWidth(6, 12)).toBe('50.000%');
expect(calculateSpanWidth(3, 12)).toBe('25.000%');
expect(calculateSpanWidth(12, 12)).toBe('100.000%');
});
it('should use 24 columns as default', () => {
expect(calculateSpanWidth(6)).toBe('25.000%');
expect(calculateSpanWidth(12)).toBe('50.000%');
});
it('should throw error for invalid span', () => {
expect(() => calculateSpanWidth(0, 24)).toThrow();
expect(() => calculateSpanWidth(25, 24)).toThrow();
expect(() => calculateSpanWidth(-1, 24)).toThrow();
});
describe('validateSpan', () => {
it('returns true for valid integers in [1, max]', () => {
expect(validateSpan(1, 24)).toBe(true);
expect(validateSpan(12, 24)).toBe(true);
expect(validateSpan(24, 24)).toBe(true);
});
describe('validateSpan', () => {
it('should return true for valid spans', () => {
expect(validateSpan(1, 24)).toBe(true);
expect(validateSpan(12, 24)).toBe(true);
expect(validateSpan(24, 24)).toBe(true);
});
it('should return false for invalid spans', () => {
expect(validateSpan(0, 24)).toBe(false);
expect(validateSpan(25, 24)).toBe(false);
expect(validateSpan(-1, 24)).toBe(false);
expect(validateSpan(1.5, 24)).toBe(false);
});
it('returns false for out-of-range values', () => {
expect(validateSpan(0, 24)).toBe(false);
expect(validateSpan(-1, 24)).toBe(false);
expect(validateSpan(25, 24)).toBe(false);
});
describe('calculateGutter', () => {
it('should calculate gutter width correctly', () => {
const gutter = calculateGutter(1440, 24, 0.05);
expect(gutter).toBeGreaterThan(0);
expect(gutter).toBeLessThan(100);
});
it('should throw error for invalid inputs', () => {
expect(() => calculateGutter(0, 24, 0.05)).toThrow();
expect(() => calculateGutter(1440, 0, 0.05)).toThrow();
expect(() => calculateGutter(1440, 24, -0.1)).toThrow();
expect(() => calculateGutter(1440, 24, 1.5)).toThrow();
});
it('returns false for non-integers', () => {
expect(validateSpan(1.5, 24)).toBe(false);
expect(validateSpan(NaN, 24)).toBe(false);
});
});
describe('clampSpan', () => {
it('returns the value when in range', () => {
expect(clampSpan(8, 24)).toBe(8);
});
it('clamps to max when over', () => {
expect(clampSpan(30, 24)).toBe(24);
});
it('clamps to 1 when under', () => {
expect(clampSpan(0, 24)).toBe(1);
expect(clampSpan(-5, 24)).toBe(1);
});
it('floors fractional input', () => {
expect(clampSpan(3.9, 24)).toBe(3);
});
it('returns 1 for non-finite input', () => {
expect(clampSpan(NaN, 24)).toBe(1);
expect(clampSpan(Infinity, 24)).toBe(24);
});
});
@@ -1,62 +0,0 @@
import { describe, it, expect } from 'vitest';
import {
calculateSpanWidth,
calculateLayout,
getResponsiveColumns,
adjustSpanForScreen,
} from '../index';
describe('integration tests', () => {
it('should work together for responsive layout calculation', () => {
// Scenario: Layout 3 items on different screen sizes
const items = [
{ id: 'headline', span: 12 },
{ id: 'image', span: 8 },
{ id: 'text', span: 4 },
];
// Large screen (24 columns)
const largeScreenColumns = getResponsiveColumns(1440);
expect(largeScreenColumns).toBe(24);
const largeLayout = calculateLayout(items, largeScreenColumns);
expect(largeLayout).toHaveLength(3);
// All items fit in one row: 12 + 8 + 4 = 24
expect(largeLayout[0].row).toBe(0);
expect(largeLayout[1].row).toBe(0);
expect(largeLayout[2].row).toBe(0);
// Medium screen (16 columns) - adjust spans
const mediumScreenColumns = getResponsiveColumns(900);
expect(mediumScreenColumns).toBe(16);
const adjustedItems = items.map((item) => ({
...item,
span: adjustSpanForScreen(item.span, 900),
}));
const mediumLayout = calculateLayout(adjustedItems, mediumScreenColumns);
expect(mediumLayout).toHaveLength(3);
// Small screen (12 columns) - adjust spans
const smallScreenColumns = getResponsiveColumns(600);
expect(smallScreenColumns).toBe(12);
const smallAdjustedItems = items.map((item) => ({
...item,
span: adjustSpanForScreen(item.span, 600),
}));
const smallLayout = calculateLayout(smallAdjustedItems, smallScreenColumns);
expect(smallLayout).toHaveLength(3);
});
it('should calculate correct widths for newspaper layout', () => {
// 6-column headline in 24-column grid
const headlineWidth = calculateSpanWidth(6, 24);
expect(headlineWidth).toBe('25.000%');
// 8-column image in 24-column grid
const imageWidth = calculateSpanWidth(8, 24);
expect(imageWidth).toBe('33.333%');
// Full-width article
const fullWidth = calculateSpanWidth(24, 24);
expect(fullWidth).toBe('100.000%');
});
});
@@ -1,90 +0,0 @@
import { describe, it, expect } from 'vitest';
import {
getResponsiveColumns,
adjustSpanForScreen,
isBreakpoint,
getCurrentBreakpoint,
BREAKPOINTS,
} from '../responsive';
describe('responsive utilities', () => {
describe('getResponsiveColumns', () => {
it('should return 12 columns for small screens', () => {
expect(getResponsiveColumns(320)).toBe(12);
expect(getResponsiveColumns(767)).toBe(12);
});
it('should return 16 columns for medium screens', () => {
expect(getResponsiveColumns(768)).toBe(16);
expect(getResponsiveColumns(1023)).toBe(16);
});
it('should return 24 columns for large screens', () => {
expect(getResponsiveColumns(1024)).toBe(24);
expect(getResponsiveColumns(1440)).toBe(24);
expect(getResponsiveColumns(1920)).toBe(24);
});
});
describe('adjustSpanForScreen', () => {
it('should not adjust span for large screens (24 columns)', () => {
expect(adjustSpanForScreen(12, 1440)).toBe(12);
expect(adjustSpanForScreen(6, 1920)).toBe(6);
expect(adjustSpanForScreen(24, 1024)).toBe(24);
});
it('should proportionally adjust span for medium screens (16 columns)', () => {
expect(adjustSpanForScreen(12, 800)).toBe(8); // 12/24 * 16 = 8
expect(adjustSpanForScreen(6, 900)).toBe(4); // 6/24 * 16 = 4
expect(adjustSpanForScreen(24, 1000)).toBe(16); // 24/24 * 16 = 16
});
it('should proportionally adjust span for small screens (12 columns)', () => {
expect(adjustSpanForScreen(12, 600)).toBe(6); // 12/24 * 12 = 6
expect(adjustSpanForScreen(6, 400)).toBe(3); // 6/24 * 12 = 3
expect(adjustSpanForScreen(24, 700)).toBe(12); // 24/24 * 12 = 12
});
it('should ensure minimum span of 1', () => {
expect(adjustSpanForScreen(1, 600)).toBe(1);
expect(adjustSpanForScreen(2, 600)).toBeGreaterThanOrEqual(1);
});
it('should not exceed target columns', () => {
expect(adjustSpanForScreen(24, 600)).toBeLessThanOrEqual(12);
expect(adjustSpanForScreen(24, 800)).toBeLessThanOrEqual(16);
});
});
describe('isBreakpoint', () => {
it('should correctly identify breakpoints', () => {
expect(isBreakpoint(768, 'sm')).toBe(true);
expect(isBreakpoint(767, 'sm')).toBe(false);
expect(isBreakpoint(1024, 'md')).toBe(true);
expect(isBreakpoint(1023, 'md')).toBe(false);
expect(isBreakpoint(1440, 'lg')).toBe(true);
expect(isBreakpoint(1439, 'lg')).toBe(false);
});
});
describe('getCurrentBreakpoint', () => {
it('should return correct breakpoint names', () => {
expect(getCurrentBreakpoint(320)).toBe('xs');
expect(getCurrentBreakpoint(767)).toBe('xs');
expect(getCurrentBreakpoint(768)).toBe('sm');
expect(getCurrentBreakpoint(1023)).toBe('sm');
expect(getCurrentBreakpoint(1024)).toBe('md');
expect(getCurrentBreakpoint(1439)).toBe('md');
expect(getCurrentBreakpoint(1440)).toBe('lg');
expect(getCurrentBreakpoint(1920)).toBe('lg');
});
});
describe('BREAKPOINTS', () => {
it('should have correct breakpoint values', () => {
expect(BREAKPOINTS.sm).toBe(768);
expect(BREAKPOINTS.md).toBe(1024);
expect(BREAKPOINTS.lg).toBe(1440);
});
});
});
-74
View File
@@ -1,74 +0,0 @@
import { describe, it, expect } from 'vitest';
import { isSpanValid, calculateLayout, type LayoutItem } from '../span';
describe('span utilities', () => {
describe('isSpanValid', () => {
it('should return true for valid spans', () => {
expect(isSpanValid(1, 24)).toBe(true);
expect(isSpanValid(12, 24)).toBe(true);
expect(isSpanValid(24, 24)).toBe(true);
expect(isSpanValid(6, 12)).toBe(true);
});
it('should return false for invalid spans', () => {
expect(isSpanValid(0, 24)).toBe(false);
expect(isSpanValid(25, 24)).toBe(false);
expect(isSpanValid(-1, 24)).toBe(false);
expect(isSpanValid(1.5, 24)).toBe(false);
expect(isSpanValid(13, 12)).toBe(false);
});
});
describe('calculateLayout', () => {
it('should layout items in a single row when they fit', () => {
const items: LayoutItem[] = [
{ id: 'a', span: 6 },
{ id: 'b', span: 6 },
{ id: 'c', span: 6 },
];
const layout = calculateLayout(items, 24);
expect(layout).toHaveLength(3);
expect(layout[0]).toEqual({ item: items[0], row: 0, col: 0 });
expect(layout[1]).toEqual({ item: items[1], row: 0, col: 6 });
expect(layout[2]).toEqual({ item: items[2], row: 0, col: 12 });
});
it('should wrap to next row when items do not fit', () => {
const items: LayoutItem[] = [
{ id: 'a', span: 16 },
{ id: 'b', span: 12 },
];
const layout = calculateLayout(items, 24);
expect(layout).toHaveLength(2);
expect(layout[0]).toEqual({ item: items[0], row: 0, col: 0 });
expect(layout[1]).toEqual({ item: items[1], row: 1, col: 0 });
});
it('should handle exact row fills', () => {
const items: LayoutItem[] = [
{ id: 'a', span: 12 },
{ id: 'b', span: 12 },
{ id: 'c', span: 24 },
];
const layout = calculateLayout(items, 24);
expect(layout).toHaveLength(3);
expect(layout[0]).toEqual({ item: items[0], row: 0, col: 0 });
expect(layout[1]).toEqual({ item: items[1], row: 0, col: 12 });
expect(layout[2]).toEqual({ item: items[2], row: 1, col: 0 });
});
it('should throw error for invalid section columns', () => {
const items: LayoutItem[] = [{ id: 'a', span: 6 }];
expect(() => calculateLayout(items, 0)).toThrow();
expect(() => calculateLayout(items, -1)).toThrow();
});
it('should throw error for invalid item span', () => {
const items: LayoutItem[] = [{ id: 'a', span: 25 }];
expect(() => calculateLayout(items, 24)).toThrow();
});
});
});
+9 -58
View File
@@ -1,61 +1,12 @@
/**
* Grid calculation utilities for 24-column newspaper layout system
*/
/**
* Calculate element width percentage in a grid system
* @param span - Number of columns the element spans
* @param totalColumns - Total number of columns in the grid (default: 24)
* @returns Width as a percentage string (e.g., "33.333%")
*/
export function calculateSpanWidth(
span: number,
totalColumns: number = 24
): string {
if (!validateSpan(span, totalColumns)) {
throw new Error(
`Invalid span: ${span}. Must be between 1 and ${totalColumns}`
);
}
const percentage = (span / totalColumns) * 100;
return `${percentage.toFixed(3)}%`;
/** Validate span is within [1, max]. */
export function validateSpan(span: number, max: number): boolean {
return Number.isInteger(span) && span >= 1 && span <= max;
}
/**
* Validate if a span value is within valid range
* @param span - Number of columns to validate
* @param maxColumns - Maximum number of columns allowed
* @returns True if span is valid, false otherwise
*/
export function validateSpan(span: number, maxColumns: number): boolean {
return Number.isInteger(span) && span >= 1 && span <= maxColumns;
}
/**
* Calculate gutter width based on container width and column count
* @param containerWidth - Total width of the container in pixels
* @param columns - Number of columns in the grid
* @param gutterRatio - Ratio of gutter to column width (default: 0.05)
* @returns Gutter width in pixels
*/
export function calculateGutter(
containerWidth: number,
columns: number,
gutterRatio: number = 0.05
): number {
if (containerWidth <= 0 || columns <= 0) {
throw new Error('Container width and columns must be positive numbers');
}
if (gutterRatio < 0 || gutterRatio > 1) {
throw new Error('Gutter ratio must be between 0 and 1');
}
// Calculate column width considering gutters
// Formula: containerWidth = (columns * columnWidth) + ((columns - 1) * gutter)
// Where gutter = columnWidth * gutterRatio
const totalGutterRatio = (columns - 1) * gutterRatio;
const columnWidth = containerWidth / (columns + totalGutterRatio);
const gutterWidth = columnWidth * gutterRatio;
return Math.round(gutterWidth * 100) / 100;
/** Clamp span into [1, max]. Used by Section/Article to recover from invalid input. */
export function clampSpan(span: number, max: number): number {
if (Number.isNaN(span)) return 1;
if (span < 1) return 1;
if (span > max) return max;
return Math.floor(span);
}
+2 -30
View File
@@ -1,30 +1,2 @@
/**
* @newspaperui/utils
* Utility functions for newspaperui component library
*/
// Grid utilities
export {
calculateSpanWidth,
validateSpan,
calculateGutter,
} from './grid';
// Span and layout utilities
export {
isSpanValid,
calculateLayout,
type LayoutItem,
type PositionedLayoutItem,
} from './span';
// Responsive utilities
export {
getResponsiveColumns,
adjustSpanForScreen,
isBreakpoint,
getCurrentBreakpoint,
BREAKPOINTS,
} from './responsive';
export const version = '0.0.0';
export { validateSpan, clampSpan } from './grid';
export { cx } from './cx';
-85
View File
@@ -1,85 +0,0 @@
/**
* Responsive utilities for newspaper layout system
*/
/**
* Breakpoint thresholds for responsive design
*/
export const BREAKPOINTS = {
sm: 768,
md: 1024,
lg: 1440,
} as const;
/**
* Get recommended grid column count based on screen width
* @param screenWidth - Current screen width in pixels
* @returns Recommended number of columns (12, 16, or 24)
*/
export function getResponsiveColumns(screenWidth: number): number {
if (screenWidth < BREAKPOINTS.sm) {
return 12; // Small screens: 12 columns
} else if (screenWidth < BREAKPOINTS.md) {
return 16; // Medium screens: 16 columns
} else {
return 24; // Large screens: 24 columns
}
}
/**
* Adjust span value for different screen sizes
* Proportionally scales span based on available columns
* @param span - Original span value (based on 24 columns)
* @param screenWidth - Current screen width in pixels
* @returns Adjusted span value for current screen size
*/
export function adjustSpanForScreen(
span: number,
screenWidth: number
): number {
const targetColumns = getResponsiveColumns(screenWidth);
const baseColumns = 24;
// If already at 24 columns, no adjustment needed
if (targetColumns === baseColumns) {
return span;
}
// Calculate proportional span
const adjustedSpan = Math.round((span / baseColumns) * targetColumns);
// Ensure at least 1 column and not exceeding target columns
return Math.max(1, Math.min(adjustedSpan, targetColumns));
}
/**
* Check if current screen width matches a breakpoint
* @param screenWidth - Current screen width in pixels
* @param breakpoint - Breakpoint name to check
* @returns True if screen width is at or above the breakpoint
*/
export function isBreakpoint(
screenWidth: number,
breakpoint: keyof typeof BREAKPOINTS
): boolean {
return screenWidth >= BREAKPOINTS[breakpoint];
}
/**
* Get current breakpoint name based on screen width
* @param screenWidth - Current screen width in pixels
* @returns Current breakpoint name ('sm', 'md', 'lg', or 'xs' for below sm)
*/
export function getCurrentBreakpoint(
screenWidth: number
): 'xs' | 'sm' | 'md' | 'lg' {
if (screenWidth >= BREAKPOINTS.lg) {
return 'lg';
} else if (screenWidth >= BREAKPOINTS.md) {
return 'md';
} else if (screenWidth >= BREAKPOINTS.sm) {
return 'sm';
} else {
return 'xs';
}
}
-92
View File
@@ -1,92 +0,0 @@
/**
* Span and layout utilities for newspaper column system
*/
/**
* Check if an object's span is valid within a section's column count
* @param objectSpan - Number of columns the object spans
* @param sectionColumns - Total number of columns in the section
* @returns True if the span is valid, false otherwise
*/
export function isSpanValid(
objectSpan: number,
sectionColumns: number
): boolean {
return (
Number.isInteger(objectSpan) &&
Number.isInteger(sectionColumns) &&
objectSpan >= 1 &&
objectSpan <= sectionColumns
);
}
/**
* Layout item interface for grid positioning
*/
export interface LayoutItem {
span: number;
id: string;
}
/**
* Positioned layout item with row and column information
*/
export interface PositionedLayoutItem {
item: LayoutItem;
row: number;
col: number;
}
/**
* Calculate simple flow layout for items in a section
* Items are placed left-to-right, wrapping to new rows when needed
* @param items - Array of items to layout
* @param sectionColumns - Total number of columns in the section
* @returns Array of positioned items with row and column information
*/
export function calculateLayout(
items: LayoutItem[],
sectionColumns: number
): PositionedLayoutItem[] {
if (sectionColumns <= 0) {
throw new Error('Section columns must be a positive number');
}
const positioned: PositionedLayoutItem[] = [];
let currentRow = 0;
let currentCol = 0;
for (const item of items) {
// Validate item span
if (!isSpanValid(item.span, sectionColumns)) {
throw new Error(
`Invalid span ${item.span} for item ${item.id}. Must be between 1 and ${sectionColumns}`
);
}
// Check if item fits in current row
if (currentCol + item.span > sectionColumns) {
// Move to next row
currentRow++;
currentCol = 0;
}
// Position the item
positioned.push({
item,
row: currentRow,
col: currentCol,
});
// Update current column position
currentCol += item.span;
// If we've filled the row exactly, move to next row
if (currentCol === sectionColumns) {
currentRow++;
currentCol = 0;
}
}
return positioned;
}