diff --git a/design.md b/design.md index 8e6b983..55ee8b0 100644 --- a/design.md +++ b/design.md @@ -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 + Rollup(components/theme/utils);Next.js 14+(docs) --- ## 2. 全局栅格系统 - `` 顶层容器: - - maxWidth, padding, gutter, theme - - 栅格总列数 24 - - snap-to-grid 支持 -- Section 内对象 span ≤ Section.columns -- 响应式调整:小屏 12–16 列 + - `columns`(默认 24)、`maxWidth`、`padding`、`theme`('light' | 'dark') + - 通过 React Context 把 columns 下传给 `
` +- `
` 内部使用 `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. `
`: - - columns, breakable, padding/margin, priority - - 内部对象 span ≤ Section.columns + - `columns`(必填)、`gap`、`breakable`、`divider`('none' | 'top' | 'bottom' | 'both') + - 内部对象 span ≤ Section.columns,超过自动 clamp 并 console.warn 2. `
`: - - span, priority/weight, breakable - - 内含 Headline / Subhead / BodyText / Image / PullQuote + - `span`、`breakable` + - 跨栏统一通过 `grid-column: span N` 3. ``: - - position, top/left/right/bottom, zIndex - - 用于浮动广告、拉引、浮动图片 + - `position`('absolute' | 'fixed' | 'sticky')、`top/left/right/bottom`、`zIndex` + +4. `` 报头组件: + - `title`、`kicker`、`edition`、`date`、`price` + - `variant`:'classic'(双线居中 + Cormorant Garamond)、'blackletter'(UnifrakturMaguntia 哥特体)、'modern'(左对齐 + accent 色) + +5. `` 分隔线组件: + - `variant`:'hairline' | 'double' | 'thick' + - `orientation`:'horizontal' | 'vertical' + - `span`(横向时占多少列,默认 `1 / -1`) --- ## 4. 内容组件 -- 文本类:Headline / Subhead / BodyText / Quote / Byline / Caption -- 媒体类:Image / Figure / Video / PullQuote -- 属性:span(跨栏)、weight(High/Medium/Low)、margin/padding -- 所有 span 基于 Section.columns +- 文本类:`` / `` / `` / `` / `` / `` / `` / `` +- 媒体类:`` / `
` / `
); }; diff --git a/packages/components/src/Section/Section.tsx b/packages/components/src/Section/Section.tsx deleted file mode 100644 index db195b4..0000000 --- a/packages/components/src/Section/Section.tsx +++ /dev/null @@ -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({ columns: 24 }); - -export const useSection = () => useContext(SectionContext); - -export const Section: React.FC = ({ - 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 ( - -
- {children} -
-
- ); -}; diff --git a/packages/components/src/Text/BodyText.tsx b/packages/components/src/Text/BodyText.tsx index 05406d0..0052ef3 100644 --- a/packages/components/src/Text/BodyText.tsx +++ b/packages/components/src/Text/BodyText.tsx @@ -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 = ({ - 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 ( -

{children} -

+ ); }; diff --git a/packages/components/src/Text/Byline.tsx b/packages/components/src/Text/Byline.tsx index 0369691..8479ffb 100644 --- a/packages/components/src/Text/Byline.tsx +++ b/packages/components/src/Text/Byline.tsx @@ -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 = ({ children }) => { - const config = visualWeights.Byline.Standard; - - if (!config) { - throw new Error('Byline configuration not found'); - } - +export const Byline: React.FC = ({ className, style, children }) => { + const config = visualWeights.Byline.Standard!; return (
{children} diff --git a/packages/components/src/Text/Caption.tsx b/packages/components/src/Text/Caption.tsx index 57236fd..f0712c5 100644 --- a/packages/components/src/Text/Caption.tsx +++ b/packages/components/src/Text/Caption.tsx @@ -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 = ({ children }) => { - const config = visualWeights.Caption.Standard; - - if (!config) { - throw new Error('Caption configuration not found'); - } - +export const Caption: React.FC = ({ credit, className, style, children }) => { + const config = visualWeights.Caption.Standard!; return (
{children} + {credit && ( + + {credit} + + )}
); }; diff --git a/packages/components/src/Text/Headline.tsx b/packages/components/src/Text/Headline.tsx index e196ae7..23ba99a 100644 --- a/packages/components/src/Text/Headline.tsx +++ b/packages/components/src/Text/Headline.tsx @@ -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 = ({ - 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 ( {children} diff --git a/packages/components/src/Text/Quote.tsx b/packages/components/src/Text/Quote.tsx index 9eae513..e05dc52 100644 --- a/packages/components/src/Text/Quote.tsx +++ b/packages/components/src/Text/Quote.tsx @@ -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 = ({ - 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 ( + + {children} + + ); } - const finalSpan = span || (Array.isArray(config.span) ? config.span[0] : config.span); - const width = calculateSpanWidth(finalSpan, section.columns); - return (
{children}
diff --git a/packages/components/src/Text/Subhead.tsx b/packages/components/src/Text/Subhead.tsx index ee05ade..4f4f0ab 100644 --- a/packages/components/src/Text/Subhead.tsx +++ b/packages/components/src/Text/Subhead.tsx @@ -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 = ({ - 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 ( -

{children} -

+ ); }; diff --git a/packages/components/src/__tests__/Article.test.tsx b/packages/components/src/__tests__/Article.test.tsx index 5e54963..8ddaa63 100644 --- a/packages/components/src/__tests__/Article.test.tsx +++ b/packages/components/src/__tests__/Article.test.tsx @@ -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(
-
-
Article Content
-
+
x
-
+ , ); - 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( - -
-
-
Clamped
-
-
-
- ); - 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( -
-
-
Half Width
-
+
+
x
- + , ); - 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( + +
+
x
+
+
, + ); + const article = container.querySelector('article') as HTMLElement; + expect(article.style.gridColumn).toBe('span 6'); + expect(article.getAttribute('data-span')).toBe('6'); }); }); diff --git a/packages/components/src/__tests__/Layout.test.tsx b/packages/components/src/__tests__/Layout.test.tsx index c7c4b0f..0c03699 100644 --- a/packages/components/src/__tests__/Layout.test.tsx +++ b/packages/components/src/__tests__/Layout.test.tsx @@ -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 {columns}; +}; + +describe('Layout', () => { + it('renders children', () => { render( -
Test Content
-
+

hello

+ , ); - 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( + + + , + ); + expect(screen.getByTestId('cols').textContent).toBe('24'); + }); + + it('applies maxWidth and padding', () => { const { container } = render( - -
Content
-
+ +

x

+
, ); - const layout = container.querySelector('.newspaper-layout'); - expect(layout).toHaveStyle({ maxWidth: '1200px', padding: '2rem' }); - }); - - it('provides default columns value of 24', () => { - render( - -
-
Test
-
-
- ); - expect(screen.getByText('Test')).toBeInTheDocument(); + const root = container.firstChild as HTMLElement; + expect(root.style.maxWidth).toBe('900px'); + expect(root.style.padding).toBe('16px'); }); }); diff --git a/packages/components/src/__tests__/Section.test.tsx b/packages/components/src/__tests__/Section.test.tsx index 982a348..4adf7fd 100644 --- a/packages/components/src/__tests__/Section.test.tsx +++ b/packages/components/src/__tests__/Section.test.tsx @@ -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( - -
-
Section Content
-
-
- ); - expect(container.textContent).toContain('Section Content'); - }); +const ColsProbe = () => { + const { columns } = useSection(); + return {columns}; +}; - 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( -
-
Clamped
+
+

x

- + , ); - 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( + +
+ +
+
, + ); + expect(screen.getByTestId('section-cols').textContent).toBe('12'); + }); + + it('applies top and bottom borders when divider="both"', () => { const { container } = render( -
-
Content
+
+

x

- + , ); - 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'); }); }); diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 4b6b2a1..79ff6ba 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -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'; diff --git a/packages/docs/app/components/article/page.tsx b/packages/docs/app/components/article/page.tsx deleted file mode 100644 index edafd8b..0000000 --- a/packages/docs/app/components/article/page.tsx +++ /dev/null @@ -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 ( -
-

Article 组件

-

- Article 是内容容器组件,用于组织文章内容,支持跨栏布局和优先级控制。 -

- -

组件说明

-

- Article 组件是报纸布局中的基本内容单元,可以包含标题、正文、图片等多种内容。 - 通过 span 属性控制文章占据的列数,实现灵活的跨栏布局。 -

- -

API 文档

- - -

基础用法

- -
-
- 文章标题 - 副标题说明 - - 这是文章的正文内容。Article 组件提供了内容容器, - 可以包含多种文本和媒体组件。 - -
-
-`} - > - -
-
- 文章标题 - 副标题说明 - - 这是文章的正文内容。Article 组件提供了内容容器, - 可以包含多种文本和媒体组件。 - -
-
-
-
- -

跨栏布局

-

- 通过调整 span 属性,可以实现不同宽度的文章布局。较大的 span 值适合重要内容, - 较小的 span 值适合次要内容或侧边栏。 -

- - -
-
- 主要文章 - 占据 8 列的主要内容 -
-
- 次要文章 - 占据 8 列的次要内容 -
-
- 更多内容 - 占据 8 列的更多内容 -
-
-`} - > - -
-
- 主要文章 - 占据 8 列的主要内容,使用 High 权重突出显示。 -
-
- 次要文章 - 占据 8 列的次要内容,使用 Medium 权重。 -
-
- 更多内容 - 占据 8 列的更多内容,与其他文章并排显示。 -
-
-
-
- -

不同宽度组合

- -
-
- 宽文章 (16 列) - - 这是一篇占据 16 列的宽文章,适合放置重要内容或长篇文章。 - -
-
- 窄文章 (8 列) - 这是占据 8 列的窄文章,适合侧边栏或简短内容。 -
-
-`} - > - -
-
- 宽文章 (16 列) - - 这是一篇占据 16 列的宽文章,适合放置重要内容或长篇文章。 - 更宽的列宽提供了更好的阅读体验。 - -
-
- 窄文章 (8 列) - 这是占据 8 列的窄文章,适合侧边栏或简短内容。 -
-
-
-
- -

使用建议

-
    -
  • 主要文章建议使用 12-16 列,提供良好的阅读宽度
  • -
  • 次要文章或侧边栏使用 6-8 列
  • -
  • 确保同一 Section 内所有 Article 的 span 总和 ≤ Section.columns
  • -
  • 使用 priority 属性控制文章的显示顺序
  • -
  • 通过 weight 属性影响内部文本组件的默认样式
  • -
  • 重要内容使用更大的 span 值以获得更多视觉权重
  • -
- -

注意事项

-
    -
  • Article 的 span 值必须小于或等于所在 Section 的 columns 值
  • -
  • 如果 Section 内多个 Article 的 span 总和超过 Section.columns,会自动换行
  • -
  • 在响应式布局中,Article 会根据屏幕尺寸自动调整宽���
  • -
  • breakable 属性在打印或分页场景中很有用
  • -
-
- ); -} diff --git a/packages/docs/app/components/layer/page.tsx b/packages/docs/app/components/layer/page.tsx deleted file mode 100644 index 43829bb..0000000 --- a/packages/docs/app/components/layer/page.tsx +++ /dev/null @@ -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 ( -
-

Layer 组件

-

- Layer 是浮动层组件,支持绝对定位,用于实现浮动广告、拉引、浮动图片等效果。 -

- -

组件说明

-

- Layer 组件脱离正常的栅格流,使用绝对定位。 - 它可以浮动在其他内容之上,常用于实现报纸中的浮动元素,如拉引、浮动广告、浮动图片等。 -

- -

API 文档

- - -

基础用法

- -
-
-
- 主要文章 - - 这是一篇主要文章的内容。旁边有一个浮动的拉引, - 用于突出显示文章中的重要引用或观点。 - -
- - -
-

- "这是一段重要的引用文字" -

-
-
-
-
-`} - > - -
-
-
- 主要文章 - - 这是一篇主要文章的内容。旁边有一个浮动的拉引, - 用于突出显示文章中的重要引用或观点。Layer 组件使用绝对定位, - 可以精确控制元素的位置。 - -
- - -
-

- "这是一段重要的引用文字" -

-
-
-
-
-
-
- -

浮动广告

- -
-
-
- 文章内容 - - 这是文章的主要内容区域。右侧有一个浮动的广告位, - 使用 Layer 组件实现。 - -
- - -
-

广告位

-

300x250

-
-
-
-
-`} - > - -
-
-
- 文章内容 - - 这是文章的主要内容区域。右侧有一个浮动的广告位, - 使用 Layer 组件实现。广告不会影响正常的文章布局流。 - - - Layer 组件非常适合实现这类需要脱离文档流的元素。 - -
- - -
-

广告位

-

300x250

-
-
-
-
-
-
- -

使用建议

-
    -
  • Layer 的父容器需要设置 position: relative
  • -
  • 使用 zIndex 控制多个 Layer 的层叠顺序
  • -
  • 浮动元素不应遮挡重要内容
  • -
  • 考虑移动端的显示效果,必要时隐藏或调整 Layer
  • -
  • 使用 position="fixed" 可以实现固定在视口的效果
  • -
  • 使用 position="sticky" 可以实现粘性定位效果
  • -
- -

注意事项

-
    -
  • Layer 脱离了栅格系统,不受 Section 和 Article 的列数限制
  • -
  • 需要手动控制 Layer 的宽度和位置
  • -
  • 在响应式设计中,可能需要根据屏幕尺寸调整 Layer 的位置
  • -
  • 过多的 Layer 可能影响页面性能和可访问性
  • -
  • 确保 Layer 内容在所有屏幕尺寸下都能正常显示
  • -
- -

典型应用场景

-
    -
  • 浮动拉引 - 突出显示文章中的重要引用
  • -
  • 浮动广告 - 侧边或角落的广告位
  • -
  • 浮动图片 - 文章中的浮动配图
  • -
  • 固定导航 - 使用 fixed 定位的导航栏
  • -
  • 粘性标题 - 使用 sticky 定位的章节标题
  • -
-
- ); -} diff --git a/packages/docs/app/components/media/page.tsx b/packages/docs/app/components/media/page.tsx deleted file mode 100644 index 549aa8c..0000000 --- a/packages/docs/app/components/media/page.tsx +++ /dev/null @@ -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 ( -
-

媒体组件

-

- NewspaperUI 提供了 Image、Figure 和 Video 组件用于展示媒体内容。 -

- -

Image 组件

-

- Image 组件用于显示图片,支持响应式和跨栏布局。 -

- - - - - 带图片的文章 - 示例图片 - 图片下方的文字说明 -
`} - > - -
-
- 带图片的文章 - 示例图片 - 图片下方的文字说明 -
-
-
- - -

Figure 组件

-

- Figure 组件是带标题的图片容器,包含图片和图注。 -

- - - - - 新闻标题 -
- 新闻正文内容... -`} - > - -
-
- 新闻标题 -
- 新闻正文内容... -
-
-
- - -

Video 组件

-

- Video 组件用于嵌入视频内容。 -

- - - - - 视频新闻 - - -

使用建议

-
    -
  • 使用 Image 组件展示简单图片
  • -
  • 使用 Figure 组件为图片添加说明文字
  • -
  • 使用 Video 组件嵌入视频内容
  • -
  • 通过 span 属性控制媒体元素的宽度
  • -
  • 设置合适的 aspectRatio 保持图片比例
  • -
  • 始终提供 alt 文本以提高无障碍性
  • -
  • 考虑图片和视频的加载性能
  • -
- -

响应式媒体

-

- 媒体组件会根据容器宽度自动调整大小。在小屏设备上, - 媒体元素会自动适应屏幕宽度,保持良好的显示效果。 -

- -

性能优化

-
    -
  • 使用适当的图片格式(WebP、AVIF)
  • -
  • 提供多种尺寸的图片用于响应式加载
  • -
  • 使用懒加载(lazy loading)优化页面性能
  • -
  • 压缩图片和视频文件大小
  • -
  • 考虑使用 CDN 加速媒体资源加载
  • -
- - ); -} diff --git a/packages/docs/app/examples/responsive/page.tsx b/packages/docs/app/examples/responsive/page.tsx deleted file mode 100644 index 8d56a08..0000000 --- a/packages/docs/app/examples/responsive/page.tsx +++ /dev/null @@ -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 ( -
-

响应式布局示例

-

- 展示 NewspaperUI 如何在不同屏幕尺寸下自动调整布局。 -

- -

响应式原理

-

- NewspaperUI 的响应式系统基于栅格列数的动态调整。在不同的屏幕尺寸下, - 栅格系统会自动调整列数,确保内容在各种设备上都能良好显示。 -

- -
-

响应式断点

-
    -
  • 大屏(≥1024px): 24 列
  • -
  • 中屏(768px-1023px): 16 列
  • -
  • 小屏(<768px): 12 列
  • -
-
- -

自适应布局示例

-

- 以下示例展示了布局如何在不同屏幕尺寸下自动调整。 - 尝试调整浏览器窗口大小查看效果。 -

- - -
- {/* 大屏: 8+8+8, 中屏: 12+12, 小屏: 24 */} -
- 第一栏 - 内容自动适应屏幕宽度 -
-
- 第二栏 - 响应式布局 -
-
- 第三栏 - 自动换行 -
-
-`} - > - -
-
- 科技新闻 - - 在大屏上,这三栏并排显示。在中屏上,变为两栏布局。 - 在小屏上,每栏占据全宽,垂直堆叠。 - -
-
- 财经要闻 - - 响应式布局确保内容在所有设备上都能良好显示, - 提供最佳的阅读体验。 - -
-
- 文化艺术 - - NewspaperUI 的响应式系统会自动处理列宽调整, - 无需手动编写复杂的媒体查询。 - -
-
-
-
- -

主次内容响应式

-

- 在响应式布局中,主要内容和次要内容的比例也会自动调整。 -

- - -
-
- 主要内容 - - 主要内容在大屏占据 16 列,在中屏占据 12 列, - 在小屏占据全宽。 - -
-
- 侧边栏 - - 侧边栏在大屏占据 8 列,在中屏占据 12 列, - 在小屏移到主内容下方。 - -
-
-`} - > - -
-
- 深度报道 - - 这是主要内容区域,在大屏幕上占据较大的空间。 - 当屏幕变小时,布局会自动调整以保持良好的可读性。 - - - 响应式设计确保用户在任何设备上都能获得最佳的阅读体验。 - -
-
- 相关链接 - - • 相关文章 1
- • 相关文章 2
- • 相关文章 3
- • 更多内容... -
-
-
-
-
- -

移动优先设计

-

- NewspaperUI 采用移动优先的设计理念,确保内容在小屏设备上也能完美呈现。 -

- - - -
-
- 移动端标题 - - 在移动设备上,内容占据全宽,提供最佳的阅读体验。 - 字体大小和行高也会根据屏幕尺寸自动调整。 - -
-
-
-
- -

响应式图片

-

- 图片和媒体元素也会根据容器宽度自动调整大小。 -

- - - -
-
- 带图片的文章 -
- 响应式图片占位 -
- - 图片会根据容器宽度自动缩放,保持宽高比不变。 - -
-
-
-
- -

响应式最佳实践

-
    -
  • 使用相对单位(rem、em)而不是绝对单位(px)
  • -
  • 在小屏设备上简化布局,避免过多的列
  • -
  • 确保文字大小在移动设备上足够大(至少 16px)
  • -
  • 触摸目标(按钮、链接)至少 44x44px
  • -
  • 测试不同设备和屏幕尺寸的显示效果
  • -
  • 考虑横屏和竖屏两种方向
  • -
  • 优化图片和媒体资源的加载性能
  • -
- -

测试响应式布局

-

- 建议在以下设备和尺寸上测试响应式布局: -

- -
-
-

移动设备

-
    -
  • iPhone SE (375px)
  • -
  • iPhone 12/13 (390px)
  • -
  • iPhone 14 Pro Max (430px)
  • -
  • Android 小屏 (360px)
  • -
-
- -
-

平板设备

-
    -
  • iPad Mini (768px)
  • -
  • iPad (810px)
  • -
  • iPad Pro (1024px)
  • -
  • Android 平板 (800px)
  • -
-
- -
-

桌面设备

-
    -
  • 笔记本 (1366px)
  • -
  • 桌面 (1920px)
  • -
  • 大屏 (2560px)
  • -
  • 超宽屏 (3440px)
  • -
-
-
- -

调试工具

-

- 使用浏览器开发者工具的响应式设计模式测试不同屏幕尺寸: -

-
    -
  • Chrome DevTools: Cmd/Ctrl + Shift + M
  • -
  • Firefox: Cmd/Ctrl + Shift + M
  • -
  • Safari: Develop → Enter Responsive Design Mode
  • -
- -

性能优化

-
    -
  • 使用懒加载(lazy loading)延迟加载图片
  • -
  • 提供不同尺寸的图片用于不同设备
  • -
  • 使用现代图片格式(WebP、AVIF)
  • -
  • 压缩和优化资源文件
  • -
  • 使用 CDN 加速资源加载
  • -
  • 减少不必要的 JavaScript 和 CSS
  • -
-
- ); -} diff --git a/packages/docs/app/examples/spanning/page.tsx b/packages/docs/app/examples/spanning/page.tsx deleted file mode 100644 index c88181c..0000000 --- a/packages/docs/app/examples/spanning/page.tsx +++ /dev/null @@ -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 ( -
-

跨栏布局示例

-

- 展示如何使用 NewspaperUI 实现复杂的跨栏报纸布局。 -

- -

经典报纸头版布局

-

- 这是一个典型的报纸头版布局,包含主要新闻、次要新闻和浮动拉引。 -

- - -
-
- {/* 主新闻 - 16 列 */} -
- 重大新闻标题占据主要版面 - - 副标题提供更多背景信息和上下文 - - - 这是主要新闻的正文内容。在报纸布局中,最重要的新闻通常占据最大的版面, - 使用最大的字号和最粗的字重。这样的布局能够立即吸引读者的注意力。 - - - 新闻的后续段落继续详细描述事件的发展。通过合理的跨栏布局, - 我们可以在有限的版面中呈现丰富的内容。 - -
- - {/* 侧边栏 - 8 列 */} -
- 次要新闻 - - 侧边栏的新闻使用较小的字号,适合放置次要但仍然重要的内容。 - -
- - {/* 浮动拉引 */} - -
- - "这是一段重要的引用,用于突出文章中的关键观点" - -
-
-
-
-`} - > - -
-
-
- 重大新闻标题占据主要版面 - - 副标题提供更多背景信息和上下文 - - - 这是主要新闻的正文内容。在报纸布局中,最重要的新闻通常占据最大的版面, - 使用最大的字号和最粗的字重。这样的布局能够立即吸引读者的注意力。 - - - 新闻的后续段落继续详细描述事件的发展。通过合理的跨栏布局, - 我们可以在有限的版面中呈现丰富的内容。 - -
- -
- 次要新闻 - - 侧边栏的新闻使用较小的字号,适合放置次要但仍然重要的内容。 - -
- - -
- - "这是一段重要的引用,用于突出文章中的关键观点" - -
-
-
-
-
-
- -

三栏等宽布局

-

- 将 24 列平均分为三个 8 列的区域,适合展示多个同等重要的内容。 -

- - -
-
- 第一栏标题 - 第一栏的内容... -
-
- 第二栏标题 - 第二栏的内容... -
-
- 第三栏标题 - 第三栏的内容... -
-
-`} - > - -
-
- 科技新闻 - - 最新的科技动态和创新成果,展示技术发展的最新趋势。 - -
-
- 财经要闻 - - 市场动态和经济分析,帮助读者了解财经领域的重要信息。 - -
-
- 文化艺术 - - 文化活动和艺术展览,丰富读者的精神文化生活。 - -
-
-
-
- -

不对称布局

-

- 使用不同的列宽组合创建视觉层次,突出重要内容。 -

- - -
-
- 左侧主要内容 - - 占据 12 列的主要内容区域... - -
-
- 右侧主要内容 - - 同样占据 12 列的内容区域... - -
-
-`} - > - -
-
- 深度报道:城市发展 - - 这是一篇深度报道,详细分析城市发展的现状和未来趋势。 - 12 列的宽度提供了良好的阅读体验。 - -
-
- 专题调查:环境保护 - - 环境保护专题调查报告,展示环保工作的成果和挑战。 - 与左��内容形成对比和呼应。 - -
-
-
-
- -

复杂混合布局

-

- 组合多种列宽,创建丰富的视觉层次和内容结构。 -

- - -
-
- 侧边栏 - 简短内容 -
-
- 主要内容 - 详细的主要内容... -
-
- 另一侧边栏 - 补充信息 -
-
-`} - > - -
-
- 快讯 - - • 新闻 1
- • 新闻 2
- • 新闻 3 -
-
-
- 今日焦点 - - 主要新闻内容占据中间的 12 列,提供充足的空间展示详细信息。 - 两侧的 6 列侧边栏提供补充内容和快讯。 - -
-
- 相关链接 - - • 链接 1
- • 链接 2
- • 链接 3 -
-
-
-
-
- -

布局技巧

-
    -
  • 使用较大的 span 值(12-16 列)突出重要内容
  • -
  • 侧边栏使用较小的 span 值(4-8 列)
  • -
  • 保持布局的平衡感,避免过于拥挤或空旷
  • -
  • 使用浮动 Layer 添加视觉亮点
  • -
  • 通过视觉权重系统创建清晰的信息层次
  • -
  • 考虑内容的重要性和阅读顺序
  • -
- -

注意事项

-
    -
  • 确保所有 Article 的 span 总和不超过 Section 的 columns
  • -
  • 在小屏设备上测试布局的响应式效果
  • -
  • 避免过多的跨栏,保持布局的可读性
  • -
  • 使用一致的列宽比例,如 8:16、6:12:6 等
  • -
  • 浮动元素不要遮挡重要内容
  • -
-
- ); -} diff --git a/packages/docs/app/globals.css b/packages/docs/app/globals.css index 5d918ba..456b3ad 100644 --- a/packages/docs/app/globals.css +++ b/packages/docs/app/globals.css @@ -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; } diff --git a/packages/docs/app/grid-system/page.tsx b/packages/docs/app/grid-system/page.tsx deleted file mode 100644 index 155e875..0000000 --- a/packages/docs/app/grid-system/page.tsx +++ /dev/null @@ -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 ( -
-

栅格系统

-

- NewspaperUI 采用 24 列栅格系统,提供灵活而精确的布局控制能力。 -

- -

设计原理

-

- 24 列栅格系统借鉴了专业排版软件的设计思想,相比传统的 12 列��格��24 列提供了更细粒度的布局控制。 - 这使得我们可以实现更复杂的报纸风格布局,如 1/3、2/3、1/4、3/4 等多种列宽组合。 -

- -

Layout 组件

-

- Layout 是顶层容器组件,定义了整个页面的栅格系统。 -

- - - - - {/* 内容 */} -`} - > - -
- 24 列栅格容器 -
-
-
- -

Section 组件

-

- Section 组件用于将 Layout 划分为多个区域,每个 Section 可以占据不同的列数。 - Section 内的所有对象的 span 总和不能超过 Section 的 columns 值。 -

- - - - -
-
- 8 列区域 - 这个区域占据 8 列 -
-
- -
-
- 16 列区域 - 左 - 这个区域占据 16 列,分为两个 8 列文章 -
-
- 16 列区域 - 右 - 第二个 8 列文章 -
-
-`} - > - -
-
- 8 列区域 - 这个区域占据 8 列,适合放置主要内容或大图。 -
-
- -
-
- 16 列区域 - 左 - 这个区域占据 16 列,分为两个 8 列文章。 -
-
- 16 列区域 - 右 - 第二个 8 列文章,与左侧并排显示。 -
-
-
-
- -

响应式栅格

-

- 在小屏设备上,栅格系统会自动调整为 12-16 列,确保内容在移动设备上也能良好显示。 - 你可以通过 CSS 媒体查询或响应式工具类来进一步控制不同屏幕尺寸下的布局。 -

- - - -
-
- 6 列 - 小屏下可能变为全宽 -
-
-
-
- 6 列 - 自动适应屏幕 -
-
-
-
- 6 列 - 保持良好阅读体验 -
-
-
-
- 6 列 - 响应式布局 -
-
-
-
- -

布局规则

-
    -
  • Layout 的 columns 属性定义了整个页面的栅格总列数(默认 24)
  • -
  • Section 的 columns 属性必须 ≤ Layout 的 columns
  • -
  • Section 内所有对象的 span 总和必须 ≤ Section 的 columns
  • -
  • 对象可以通过 span 属性跨越多列
  • -
  • 小屏设备会自动调整为 12-16 列
  • -
- -

最佳实践

-
    -
  • 使用 24 列栅格可以实现 1/2、1/3、1/4、1/6、1/8 等多种比例
  • -
  • 主要内容区域建议使用 8-16 列
  • -
  • 侧边栏或次要内容使用 4-8 列
  • -
  • 重要内容可以跨越更多列以获得更大的视觉权重
  • -
  • 保持 Section 之间的列数比例协调,如 8:16、6:18、12:12 等
  • -
-
- ); -} diff --git a/packages/docs/app/layout.tsx b/packages/docs/app/layout.tsx index 8af8cb7..f3a9b38 100644 --- a/packages/docs/app/layout.tsx +++ b/packages/docs/app/layout.tsx @@ -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 ( - - -
- -
-
- {children} -
-
-
- + + {children} ); } diff --git a/packages/docs/app/page.tsx b/packages/docs/app/page.tsx index 327a98a..89fafd9 100644 --- a/packages/docs/app/page.tsx +++ b/packages/docs/app/page.tsx @@ -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 ( -
-

NewspaperUI

-

- 生产级报纸布局组件库,参考 InDesign 设计理念,支持 24 列栅格、跨栏、视觉权重和主题系统。 -

+ + -

设计理念

-

- NewspaperUI 借鉴专业排版软件 InDesign 的设计思想,为 Web 应用提供报纸风格的布局能力。 - 组件设计遵循少而精、职责明确、无冗余、无歧义的原则。 -

+
+
+ Inside Today + + Senate Approves Climate Resolution After Months of Debate + + +

The unanimous vote concludes a contentious legislative session marked by partisan disputes + and last-minute amendments. Page A6.

+
-

核心特性

-
    -
  • 24 列栅格系统 - 灵活的栅格布局,支持精确的列控制
  • -
  • 跨栏布局 - 内容可以跨越多列,实现复杂的报纸排版
  • -
  • 视觉权重系统 - High/Medium/Low 三级权重,自动映射字体大小和样式
  • -
  • 主题系统 - 基于 CSS Variables,支持主题切换和自定义
  • -
  • 响应式设计 - 小屏自动调整为 12-16 列,保持良好的阅读体验
  • -
  • 浮动 Layer - 支持绝对定位的浮动元素,如广告、拉引等
  • -
+ -

快速开始

+ Tech Sector Gains as Inflation Eases + +

Major indices climbed for a fifth consecutive session as new data showed price growth + slowing across consumer goods. Business B1.

+
-

安装

-
-        pnpm add @newspaperui/components @newspaperui/theme
-      
+ -

基础使用

- Drought Conditions Worsen Across the Plains + +

Officials in seven states have requested federal disaster relief as reservoir levels reach + historic lows. National A12.

+
-function MyPage() { - return ( - -
-
- 主要新闻标题 - 这是一篇重要的新闻内容... + + + New Exhibit Opens at the Metropolitan + +

A retrospective of mid-century textile design draws record opening crowds. Arts C3.

+
+
+ +
+
Capitol · Breaking
+ + Historic Accord Reshapes Continental Trade After Marathon Session + + + Negotiators emerge with sweeping framework on tariffs, labor, and emissions; ratification expected within weeks + +
+ By Eleanor Whitcombe and Marcus Reyes + · + 5 min read +
+ +
+ + +

Brussels — 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.

+ +

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 “a long argument that finally became a conversation.”

+ +

The framework’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’s binding architecture but warned + that the timeline gives polluters too much room to delay.

+ +

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.

+ +

Domestic political reaction was mixed. The accord’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.

+ +

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.

+ +

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.

+
+ + + A long argument that finally became a conversation. + + + +

The accord’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’s three regional + groupings, with binding authority over commercial disputes exceeding twenty million units.

+ +

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.

+
+
+ +
+ Foreign Desk + + Coastal Nations Pledge Joint Action on Maritime Pollution + + + Pact follows years of stalled regional talks and a cascade of recent shipping accidents. + + By Tomás Almeida + + +

Lisbon — 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.

+ +

Maritime industry groups received the news with caution. A spokesperson for the Continental + Shipping Council acknowledged that “stronger common rules are overdue” but warned that + implementation costs could fall disproportionately on smaller operators.

+ +

The compact takes effect on January 1, pending technical annexes. Environmental observers + described the pact as the most consequential maritime accord in a decade.

+
-
-
- 次要新闻 - 这是另一篇新闻... -
-
- 更多新闻 - 更多内容... +
+
+ National · Investigation + + Records Reveal Years of Overlooked Warnings at Aging Reservoirs + + + Internal inspection memoranda, obtained through public records requests, suggest that + structural concerns flagged repeatedly by field engineers were not escalated to senior staff. + + By Ravi Nair, Anita Kowalski, and Charles Weston + + +

Sacramento — 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.

+ +

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 “moderate” in the field reports but downgraded to “low” by the time they + reached senior officials. The pattern was particularly pronounced at three facilities serving + regions of more than two million residents.

+ +

Officials at the Department of Water Resources, asked to review excerpts of the records, said + in a written statement that “every reservoir under our oversight has been deemed safe for current + operations” but did not specifically address the discrepancies between field and final ratings. + The agency declined to make senior staff available for interviews.

+ +

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.

+ +

The investigative review found that of forty-seven reservoirs surveyed, sixteen had at least + one instance in which a “moderate” or “high” 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.

+ +

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 “deeply concerned” by the patterns described and would convene a + working group to examine reform options.

+
); -}`} - > - -
-
- 主要新闻标题 - 这是一篇重要的新闻内容,使用 High 权重的标题和正文文本。 -
-
- -
-
- 次要新闻 - 这是另一篇新闻,使用 Medium 权重。 -
-
- 更多新闻 - 更多内容在这里展示。 -
-
-
- - -

下一步

-

- 探索文档了解更多功能: -

- -
- ); } diff --git a/packages/docs/app/text/page.tsx b/packages/docs/app/text/page.tsx deleted file mode 100644 index 85434dc..0000000 --- a/packages/docs/app/text/page.tsx +++ /dev/null @@ -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 ( -
-

文本组件

-

- NewspaperUI 提供了完整的文本组件体系,支持视觉权重系统,自动映射字体大小和样式。 -

- -

视觉权重系统

-

- 视觉权重(Visual Weight)是 NewspaperUI 的核心特性之一。 - 通过 High、Medium、Low 三级权重,组件会自动应用对应的字体大小、粗细、行高和颜色。 -

- -

Headline 组件

-

- Headline 是标题组件,支持三级视觉权重。 -

- - - - High 权重标题 -Medium 权重标题 -Low 权重标题`} - > -
- High 权重标题 (36-48px, 700) - Medium 权重标题 (28-34px, 600) - Low 权重标题 (22-26px, 500) -
-
- -

Subhead 组件

-

- Subhead 是副标题组件,用于补充说明主标题。 -

- - - - 主标题 -副标题说明文字 -正文内容...`} - > -
- 重大新闻事件 - 详细的副标题说明,提供更多上下文信息 - 正文内容从这里开始,详细描述新闻事件的来龙去脉... -
-
- -

BodyText 组件

-

- BodyText 是正文文本组件,用于文章主体内容。 -

- - - - High 权重正文 (16px) -Medium 权重正文 (14-15px) -Low 权重正文 (12-14px)`} - > -
- - High 权重正文文本,字号 16px,适合重要段落或引言。 - - - Medium 权重正文文本,字号 14-15px,适合常规正文内容。 - - - Low 权重正文文本,字号 12-14px,适合次要说明或注释。 - -
-
- -

Quote 组件

-

- Quote 是引用组件,用于突出显示引用内容。 -

- - - - - 这是一段重要的引用文字,来自某位专家的观点。 -`} - > - - 这是一段重要的引用文字,来自某位专家的观点。引用组件会自动添加引号样式。 - - - -

Byline 和 Caption 组件

-

- Byline 用于作者署名,Caption 用于图片说明。 -

- - - - 文章标题 -作者:张三 | 2024年1月1日 -文章内容... -图1:示例图片的说明文字`} - > -
- 重要新闻标题 - 记者:张三 | 2024年1月1日 | 北京报道 - 文章正文内容从这里开始... -
- 图片占位 -
- 图1:新闻现场照片,展示事件发生时的情况 -
-
- -

视觉权重映射表

-

- 以下是完整的视觉权重映射规则(基于 24 列栅格): -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
组件权重字号字重行高颜色
HeadlineHigh36-48px7001.1#111
HeadlineMedium28-34px6001.2#111
HeadlineLow22-26px5001.3#222
SubheadHigh20-24px6001.25#222
SubheadMedium16-18px5001.3#333
BodyTextHigh16px4001.5#333
BodyTextMedium14-15px4001.5#444
BodyTextLow12-14px4001.4#555
-
- -

使用建议

-
    -
  • 主标题使用 Headline High 权重
  • -
  • 次要标题使用 Headline Medium 或 Low 权重
  • -
  • 正文使用 BodyText Medium 权重
  • -
  • 重要段落或引言使用 BodyText High 权重
  • -
  • 注释或说明使用 BodyText Low 权重
  • -
  • 引用使用 Quote 组件突出显示
  • -
  • 作者信息使用 Byline 组件
  • -
  • 图片说明使用 Caption 组件
  • -
-
- ); -} diff --git a/packages/docs/app/theme/page.tsx b/packages/docs/app/theme/page.tsx deleted file mode 100644 index d40e99c..0000000 --- a/packages/docs/app/theme/page.tsx +++ /dev/null @@ -1,310 +0,0 @@ -import { CodeBlock } from '@/components/CodeBlock'; - -export default function ThemePage() { - return ( -
-

主题系统

-

- NewspaperUI 基于 CSS Variables 构建主题系统,支持主题切换和自定义。 -

- -

CSS Variables

-

- 主题系统使用 CSS 自定义属性(CSS Variables)定义颜色、字体、间距等设计令牌。 - 这使得主题切换和自定义变得简单而灵活。 -

- -

核心变量

- - -

Tailwind 集成

-

- NewspaperUI 的主题系统与 Tailwind CSS 深度集成,可以直接在 Tailwind 配置中使用主题变量。 -

- - - -

使用主题变量

-

- 在组件中可以直接使用 CSS Variables 或 Tailwind 工具类。 -

- - - 主要文本 -
- -// 使用 Tailwind 工具类 -
- 主要文本 -
`} - /> - -

自定义主题

-

- 你可以通过覆盖 CSS Variables 来自定义主题。 -

- - - -

深色模式

-

- 通过定义深色模式的 CSS Variables,可以轻松实现主题切换。 -

- - - -

视觉权重配置

-

- 视觉权重系统的配置也可以通过主题系统进行自定义。 -

- - - -

响应式主题

-

- 可以根据屏幕尺寸调整主题变量,实现响应式设计。 -

- - - -

主题最佳实践

-
    -
  • 使用语义化的变量名,如 --color-text-primary 而不是 --color-black
  • -
  • 保持颜色对比度符合 WCAG 无障碍标准
  • -
  • 使用相对单位(rem、em)而不是绝对单位(px)以支持用户字体大小偏好
  • -
  • 在深色模式下确保所有颜色都有对应的深色版本
  • -
  • 测试主题在不同设备和浏览器上的表现
  • -
  • 提供主题切换的用户界面
  • -
- -

导入主题

-

- 在你的应用中导入 NewspaperUI 主题: -

- - - -

主题工具

-

- NewspaperUI 提供了一些工具函数来帮助你使用主题系统: -

- - - -

主题示例

-

- 以下是一些预设主题示例: -

- -
-
-

经典报纸

-
-
- 主色调 - #111111 -
-
- 背景色 - #ffffff -
-
- 字体 - Georgia -
-
-
- -
-

深色模式

-
-
- 主色调 - #f5f5f5 -
-
- 背景色 - #1a1a1a -
-
- 字体 - Georgia -
-
-
-
- -

更多资源

- - - ); -} diff --git a/packages/docs/components/Sidebar.tsx b/packages/docs/components/Sidebar.tsx index c9bf057..7132977 100644 --- a/packages/docs/components/Sidebar.tsx +++ b/packages/docs/components/Sidebar.tsx @@ -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 ( - + + ); } diff --git a/packages/theme/package.json b/packages/theme/package.json index 96b2192..e01f23b 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -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" }, diff --git a/packages/theme/src/index.ts b/packages/theme/src/index.ts index df98c5b..0328cae 100644 --- a/packages/theme/src/index.ts +++ b/packages/theme/src/index.ts @@ -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'; diff --git a/packages/theme/src/tailwind.config.js b/packages/theme/src/tailwind.config.js index 8ae255a..e5b3e1c 100644 --- a/packages/theme/src/tailwind.config.js +++ b/packages/theme/src/tailwind.config.js @@ -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; diff --git a/packages/theme/src/variables.css b/packages/theme/src/variables.css index 4e7b33c..4e9edc8 100644 --- a/packages/theme/src/variables.css +++ b/packages/theme/src/variables.css @@ -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); } diff --git a/packages/theme/src/visual-weights.ts b/packages/theme/src/visual-weights.ts index bb0651b..d6383ed 100644 --- a/packages/theme/src/visual-weights.ts +++ b/packages/theme/src/visual-weights.ts @@ -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> -> = { +export const visualWeights: Record>> = { + 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; +} diff --git a/packages/utils/src/__tests__/grid.test.ts b/packages/utils/src/__tests__/grid.test.ts index e98446a..7455e9d 100644 --- a/packages/utils/src/__tests__/grid.test.ts +++ b/packages/utils/src/__tests__/grid.test.ts @@ -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); }); }); diff --git a/packages/utils/src/__tests__/integration.test.ts b/packages/utils/src/__tests__/integration.test.ts deleted file mode 100644 index 3181aa6..0000000 --- a/packages/utils/src/__tests__/integration.test.ts +++ /dev/null @@ -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%'); - }); -}); diff --git a/packages/utils/src/__tests__/responsive.test.ts b/packages/utils/src/__tests__/responsive.test.ts deleted file mode 100644 index 28cc01c..0000000 --- a/packages/utils/src/__tests__/responsive.test.ts +++ /dev/null @@ -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); - }); - }); -}); diff --git a/packages/utils/src/__tests__/span.test.ts b/packages/utils/src/__tests__/span.test.ts deleted file mode 100644 index db7cb56..0000000 --- a/packages/utils/src/__tests__/span.test.ts +++ /dev/null @@ -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(); - }); - }); -}); diff --git a/packages/utils/src/grid.ts b/packages/utils/src/grid.ts index 0809c5b..aaf0894 100644 --- a/packages/utils/src/grid.ts +++ b/packages/utils/src/grid.ts @@ -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); } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 28ae179..234b9ec 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -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'; diff --git a/packages/utils/src/responsive.ts b/packages/utils/src/responsive.ts deleted file mode 100644 index 7b2e28f..0000000 --- a/packages/utils/src/responsive.ts +++ /dev/null @@ -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'; - } -} diff --git a/packages/utils/src/span.ts b/packages/utils/src/span.ts deleted file mode 100644 index 79f3f62..0000000 --- a/packages/utils/src/span.ts +++ /dev/null @@ -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; -}