-
- NewspaperUI
-
-
报纸布局组件库
+
+
+ NewspaperUI
+
+
+ Production Newspaper Components
-
- {navigation.map((item) => (
-
-
- {item.title}
-
-
- {item.items && (
-
- {item.items.map((subItem) => (
-
- {subItem.title}
-
- ))}
-
- )}
-
- ))}
-
-
+
+
+ {nav.map((item) => (
+
+
+ {item.label}
+
+ {item.children && (
+
+ {item.children.map((child) => (
+
+
+ {child.label}
+
+
+ ))}
+
+ )}
+
+ ))}
+
+
+
);
}
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;
-}