Various build shenenigans (#5)
This PR extends the build pipeline of the www service to support locale-prefixed static builds (EN/RU) with build-time SSG output injection, while also refactoring UI typography to a tokenized Tailwind v4 @theme-driven approach. Changes: - Add build-time SSG plugin to @ermnvldmr/rsbuild-config and wire multi-locale builds (/en/, /ru/) into services/www. - Introduce new @ermnvldmr/i18n package (locale constants, path helpers, keysets, pluralization) and apply it to www navigation/text. - Refactor ui typography to a shared getTypographyClassNames utility + Tailwind token definitions, with stl/cn updated for correct Tailwind-merge behavior.
This commit is contained in:
1 parent
ac9c455e5a
commit
b84ef775b8
68 files changed
+2991
-397
No files matched your search
@@ -41,7 +41,7 @@ describe('components/Breadcrumbs', () => {
|
||||
</Breadcrumbs>
|
||||
);
|
||||
const link = screen.getByRole('link', { name: 'Test Link' });
|
||||
expect(link).toHaveAttribute('href', '/test-url');
|
||||
expect(link).toHaveAttribute('href', '/en/test-url');
|
||||
});
|
||||
|
||||
it('supports onClick on Item', () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { localePath } from '@ermnvldmr/i18n';
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import React, { memo } from 'react';
|
||||
@@ -56,7 +57,8 @@ const BreadcrumbsItem = memo(function BreadcrumbsItem({
|
||||
onClick,
|
||||
'data-testid': testId,
|
||||
}: BreadcrumbsItemProps) {
|
||||
const isClickable = !!(href ?? onClick);
|
||||
const resolvedHref = localePath(href);
|
||||
const isClickable = !!(resolvedHref ?? onClick);
|
||||
|
||||
return (
|
||||
<li
|
||||
@@ -69,13 +71,13 @@ const BreadcrumbsItem = memo(function BreadcrumbsItem({
|
||||
data-testid={testId}
|
||||
>
|
||||
<Text
|
||||
as={href ? 'a' : onClick ? 'button' : 'span'}
|
||||
as={resolvedHref ? 'a' : onClick ? 'button' : 'span'}
|
||||
className={cn(
|
||||
'transition-all duration-200',
|
||||
!isCurrent && 'group-hover:text-[var(--rb-text)] group-hover:underline underline-offset-4'
|
||||
)}
|
||||
color={isCurrent ? 'default' : 'muted'}
|
||||
href={href}
|
||||
href={resolvedHref}
|
||||
size="m"
|
||||
type="label"
|
||||
onClick={onClick}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { localePath } from '@ermnvldmr/i18n';
|
||||
import { castRef, cn } from '@ermnvldmr/stl';
|
||||
import React, { memo, useRef } from 'react';
|
||||
import { useLink } from 'react-aria';
|
||||
@@ -50,6 +51,7 @@ export const Link = memo(function Link(props: LinkProps) {
|
||||
);
|
||||
|
||||
if (href) {
|
||||
const resolvedHref = localePath(href);
|
||||
const isAutoExternal = isExternal ?? href.startsWith('http');
|
||||
const externalProps = isAutoExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {};
|
||||
|
||||
@@ -62,7 +64,7 @@ export const Link = memo(function Link(props: LinkProps) {
|
||||
className={sharedClasses}
|
||||
color={color}
|
||||
delay={delay}
|
||||
href={href}
|
||||
href={resolvedHref}
|
||||
size={size}
|
||||
type={type}
|
||||
>
|
||||
|
||||
@@ -127,20 +127,22 @@ export const PageHead = ({
|
||||
<>
|
||||
<header ref={headerRef} className={rootClasses}>
|
||||
<div
|
||||
className={cn('transition-all duration-300', {
|
||||
'opacity-0 invisible h-0 overflow-hidden': isCollapsed,
|
||||
'opacity-100 visible': !isCollapsed,
|
||||
})}
|
||||
className={cn(
|
||||
'grid transition-[grid-template-rows] duration-300 ease-in-out',
|
||||
isCollapsed ? 'grid-rows-[0fr]' : 'grid-rows-[1fr]'
|
||||
)}
|
||||
inert={isCollapsed || undefined}
|
||||
>
|
||||
{expandedContent}
|
||||
<div className="overflow-hidden">{expandedContent}</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn('transition-all duration-300', {
|
||||
'opacity-100 visible': isCollapsed,
|
||||
'opacity-0 invisible h-0 overflow-hidden': !isCollapsed,
|
||||
})}
|
||||
className={cn(
|
||||
'grid transition-[grid-template-rows] duration-300 ease-in-out',
|
||||
isCollapsed ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
|
||||
)}
|
||||
inert={!isCollapsed || undefined}
|
||||
>
|
||||
{collapsedContent}
|
||||
<div className="overflow-hidden">{collapsedContent}</div>
|
||||
</div>
|
||||
</header>
|
||||
<div style={{ height }} />
|
||||
|
||||
@@ -129,4 +129,32 @@ describe('components/Text', () => {
|
||||
);
|
||||
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
|
||||
});
|
||||
|
||||
// TDD: fails before implementation (old code uses text-[14px], not text-rb-body-m)
|
||||
it('applies text-rb-{type}-{size} class', () => {
|
||||
render(<Text size="m" type="body">Content</Text>);
|
||||
expect(screen.getByText('Content')).toHaveClass('text-rb-body-m');
|
||||
});
|
||||
|
||||
it('does not apply legacy px-based font-size classes', () => {
|
||||
render(<Text size="m" type="body">Content</Text>);
|
||||
expect(screen.getByText('Content')).not.toHaveClass('text-[14px]');
|
||||
expect(screen.getByText('Content')).not.toHaveClass('leading-[20px]');
|
||||
});
|
||||
|
||||
// Regression guards: pass before and after
|
||||
it('applies font-medium for label type', () => {
|
||||
render(<Text size="m" type="label">Label</Text>);
|
||||
expect(screen.getByText('Label')).toHaveClass('font-medium');
|
||||
});
|
||||
|
||||
it('applies font-sans for body type', () => {
|
||||
render(<Text size="m" type="body">Body</Text>);
|
||||
expect(screen.getByText('Body')).toHaveClass('font-sans');
|
||||
});
|
||||
|
||||
it('applies font-serif for display type', () => {
|
||||
render(<Text size="l" type="display">Display</Text>);
|
||||
expect(screen.getByText('Display')).toHaveClass('font-serif');
|
||||
});
|
||||
});
|
||||
@@ -1,29 +1,19 @@
|
||||
import { cn, genericMemo, useIntersectionObserver, castMutableRef } from '@ermnvldmr/stl';
|
||||
import React, { forwardRef, useState, useEffect, useRef } from 'react';
|
||||
|
||||
import {
|
||||
getTypographyClassNames,
|
||||
type TextType,
|
||||
type TextSize,
|
||||
type TextColor,
|
||||
type TextAlign,
|
||||
type TextWrap,
|
||||
type TextOverflow,
|
||||
} from '../../lib/typography';
|
||||
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
|
||||
/**
|
||||
* Typography types.
|
||||
*/
|
||||
export type TextType = 'display' | 'headline' | 'title' | 'body' | 'label';
|
||||
|
||||
/**
|
||||
* Typography sizes.
|
||||
*/
|
||||
export type TextSize = 's' | 'm' | 'l';
|
||||
|
||||
/**
|
||||
* Text color variants.
|
||||
*/
|
||||
export type TextColor =
|
||||
| 'default'
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'tertiary'
|
||||
| 'error'
|
||||
| 'muted'
|
||||
| 'inherit';
|
||||
export type { TextType, TextSize, TextColor, TextAlign, TextWrap, TextOverflow };
|
||||
|
||||
/**
|
||||
* Props for the Text component.
|
||||
@@ -53,11 +43,11 @@ export interface TextProps
|
||||
/** Optional href for links */
|
||||
href?: string;
|
||||
/** Text alignment */
|
||||
align?: 'left' | 'center' | 'right' | 'justify';
|
||||
align?: TextAlign;
|
||||
/** Text wrapping behavior */
|
||||
wrap?: 'nowrap' | 'balance' | 'pretty';
|
||||
wrap?: TextWrap;
|
||||
/** Overflow behavior for single lines */
|
||||
overflow?: 'ellipsis' | 'clip';
|
||||
overflow?: TextOverflow;
|
||||
/** Maximum number of lines to show (truncates with ellipsis) */
|
||||
maxLines?: number;
|
||||
/** Delay in milliseconds before the text appears after entering the viewport. */
|
||||
@@ -69,8 +59,8 @@ export interface TextProps
|
||||
/**
|
||||
* A flexible typography component that implements the design system's type scale.
|
||||
*
|
||||
* Text centralizes all typography rules, including font families, sizes,
|
||||
* line heights, and letter spacing, reducing the need for custom CSS.
|
||||
* Text centralizes all typography rules — font families, sizes, line heights,
|
||||
* letter spacing, colors, and decorations — via the getTypographyClassNames utility.
|
||||
*/
|
||||
const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
|
||||
{
|
||||
@@ -98,7 +88,6 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
|
||||
const [isVisible, setIsVisible] = useState(delay === undefined);
|
||||
const internalRef = useRef<HTMLElement>(null);
|
||||
|
||||
// Sync internalRef with forwarded ref
|
||||
useEffect(() => {
|
||||
if (!ref) return;
|
||||
if (typeof ref === 'function') {
|
||||
@@ -125,80 +114,12 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
|
||||
return undefined;
|
||||
}, [delay, isIntersecting]);
|
||||
|
||||
// Base typography styles mapping
|
||||
const typeStyles: Record<TextType, Record<TextSize, string>> = {
|
||||
display: {
|
||||
l: 'font-serif text-[57px] leading-[64px] tracking-[-0.25px]',
|
||||
m: 'font-serif text-[45px] leading-[52px] tracking-normal',
|
||||
s: 'font-serif text-[36px] leading-[44px] tracking-normal',
|
||||
},
|
||||
headline: {
|
||||
l: 'font-serif text-[32px] leading-[40px] tracking-normal',
|
||||
m: 'font-serif text-[28px] leading-[36px] tracking-normal',
|
||||
s: 'font-serif text-[24px] leading-[32px] tracking-normal',
|
||||
},
|
||||
title: {
|
||||
l: 'font-sans text-[22px] leading-[28px] tracking-normal',
|
||||
m: 'font-sans text-[16px] leading-[24px] tracking-[0.15px]',
|
||||
s: 'font-sans text-[14px] leading-[20px] tracking-[0.1px]',
|
||||
},
|
||||
body: {
|
||||
l: 'font-sans text-[16px] leading-[24px] tracking-[0.5px]',
|
||||
m: 'font-sans text-[14px] leading-[20px] tracking-[0.25px]',
|
||||
s: 'font-sans text-[12px] leading-[16px] tracking-[0.4px]',
|
||||
},
|
||||
label: {
|
||||
l: 'font-sans font-medium text-[14px] leading-[20px] tracking-[0.1px]',
|
||||
m: 'font-sans font-medium text-[12px] leading-[16px] tracking-[0.5px]',
|
||||
s: 'font-sans font-medium text-[11px] leading-[16px] tracking-[0.5px]',
|
||||
},
|
||||
};
|
||||
|
||||
const colorClasses: Record<TextColor, string> = {
|
||||
default: 'text-[var(--rb-text)]',
|
||||
primary: 'text-[var(--rb-primary-text)]',
|
||||
secondary: 'text-[var(--rb-secondary-text)]',
|
||||
tertiary: 'text-[var(--rb-tertiary-text)]',
|
||||
error: 'text-[var(--rb-error-text)]',
|
||||
muted: 'text-[var(--rb-muted-text)]',
|
||||
inherit: 'text-inherit',
|
||||
};
|
||||
|
||||
const alignClasses = {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
justify: 'text-justify',
|
||||
};
|
||||
|
||||
const wrapClasses = {
|
||||
nowrap: 'whitespace-nowrap',
|
||||
balance: 'text-balance',
|
||||
pretty: 'text-pretty',
|
||||
};
|
||||
|
||||
let truncationClass = '';
|
||||
if (maxLines) {
|
||||
truncationClass = `line-clamp-${maxLines}`;
|
||||
} else {
|
||||
if (overflow === 'ellipsis') truncationClass = 'truncate';
|
||||
if (overflow === 'clip') truncationClass = 'overflow-clip';
|
||||
}
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
ref={internalRef}
|
||||
className={cn(
|
||||
typeStyles[type][size],
|
||||
colorClasses[color],
|
||||
bold && 'font-bold',
|
||||
italic && 'italic',
|
||||
underline && 'underline',
|
||||
strike && 'line-through',
|
||||
align && alignClasses[align],
|
||||
wrap && wrapClasses[wrap],
|
||||
truncationClass,
|
||||
getTypographyClassNames({ type, size, color, bold, italic, underline, strike, align, wrap, overflow, maxLines }),
|
||||
delay !== undefined &&
|
||||
(isVisible
|
||||
? 'animate-in fade-in slide-in-from-bottom-2 duration-1000 ease-out'
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './theme';
|
||||
export * from './typography';
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import { getTypographyClassNames } from './getTypographyClassNames';
|
||||
|
||||
describe('lib/typography/getTypographyClassNames', () => {
|
||||
describe('type + size → @theme token class', () => {
|
||||
it('returns text-rb-body-m by default', () => {
|
||||
expect(getTypographyClassNames()).toContain('text-rb-body-m');
|
||||
});
|
||||
|
||||
it('returns correct class for each type/size combination', () => {
|
||||
expect(getTypographyClassNames({ type: 'display', size: 'l' })).toContain('text-rb-display-l');
|
||||
expect(getTypographyClassNames({ type: 'headline', size: 'm' })).toContain('text-rb-headline-m');
|
||||
expect(getTypographyClassNames({ type: 'title', size: 's' })).toContain('text-rb-title-s');
|
||||
expect(getTypographyClassNames({ type: 'body', size: 'l' })).toContain('text-rb-body-l');
|
||||
expect(getTypographyClassNames({ type: 'label', size: 'm' })).toContain('text-rb-label-m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('font family', () => {
|
||||
it('applies font-serif for display and headline', () => {
|
||||
expect(getTypographyClassNames({ type: 'display' })).toContain('font-serif');
|
||||
expect(getTypographyClassNames({ type: 'headline' })).toContain('font-serif');
|
||||
});
|
||||
|
||||
it('applies font-sans for title, body, and label', () => {
|
||||
expect(getTypographyClassNames({ type: 'title' })).toContain('font-sans');
|
||||
expect(getTypographyClassNames({ type: 'body' })).toContain('font-sans');
|
||||
expect(getTypographyClassNames({ type: 'label' })).toContain('font-sans');
|
||||
});
|
||||
});
|
||||
|
||||
describe('font weight', () => {
|
||||
it('adds font-medium for label type', () => {
|
||||
expect(getTypographyClassNames({ type: 'label' })).toContain('font-medium');
|
||||
});
|
||||
|
||||
it('does not add font-medium for non-label types', () => {
|
||||
expect(getTypographyClassNames({ type: 'body' })).not.toContain('font-medium');
|
||||
expect(getTypographyClassNames({ type: 'title' })).not.toContain('font-medium');
|
||||
});
|
||||
|
||||
it('adds font-bold when bold is true', () => {
|
||||
expect(getTypographyClassNames({ bold: true })).toContain('font-bold');
|
||||
});
|
||||
|
||||
it('does not add font-bold by default', () => {
|
||||
expect(getTypographyClassNames()).not.toContain('font-bold');
|
||||
});
|
||||
});
|
||||
|
||||
describe('color', () => {
|
||||
it('applies default color class', () => {
|
||||
expect(getTypographyClassNames()).toContain('text-[var(--rb-text)]');
|
||||
});
|
||||
|
||||
it('applies each color variant', () => {
|
||||
expect(getTypographyClassNames({ color: 'muted' })).toContain('text-[var(--rb-muted-text)]');
|
||||
expect(getTypographyClassNames({ color: 'primary' })).toContain('text-[var(--rb-primary-text)]');
|
||||
expect(getTypographyClassNames({ color: 'error' })).toContain('text-[var(--rb-error-text)]');
|
||||
expect(getTypographyClassNames({ color: 'inherit' })).toContain('text-inherit');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decorations', () => {
|
||||
it('applies italic', () => {
|
||||
expect(getTypographyClassNames({ italic: true })).toContain('italic');
|
||||
});
|
||||
|
||||
it('applies underline', () => {
|
||||
expect(getTypographyClassNames({ underline: true })).toContain('underline');
|
||||
});
|
||||
|
||||
it('applies line-through for strike', () => {
|
||||
expect(getTypographyClassNames({ strike: true })).toContain('line-through');
|
||||
});
|
||||
|
||||
it('does not apply decorations by default', () => {
|
||||
const result = getTypographyClassNames();
|
||||
expect(result).not.toContain('italic');
|
||||
expect(result).not.toContain('underline');
|
||||
expect(result).not.toContain('line-through');
|
||||
});
|
||||
});
|
||||
|
||||
describe('alignment', () => {
|
||||
it('applies text-center', () => {
|
||||
expect(getTypographyClassNames({ align: 'center' })).toContain('text-center');
|
||||
});
|
||||
|
||||
it('applies each alignment', () => {
|
||||
expect(getTypographyClassNames({ align: 'left' })).toContain('text-left');
|
||||
expect(getTypographyClassNames({ align: 'right' })).toContain('text-right');
|
||||
expect(getTypographyClassNames({ align: 'justify' })).toContain('text-justify');
|
||||
});
|
||||
|
||||
it('does not apply alignment by default', () => {
|
||||
const result = getTypographyClassNames();
|
||||
expect(result).not.toMatch(/text-(left|center|right|justify)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrap', () => {
|
||||
it('applies whitespace-nowrap', () => {
|
||||
expect(getTypographyClassNames({ wrap: 'nowrap' })).toContain('whitespace-nowrap');
|
||||
});
|
||||
|
||||
it('applies text-balance', () => {
|
||||
expect(getTypographyClassNames({ wrap: 'balance' })).toContain('text-balance');
|
||||
});
|
||||
|
||||
it('applies text-pretty', () => {
|
||||
expect(getTypographyClassNames({ wrap: 'pretty' })).toContain('text-pretty');
|
||||
});
|
||||
});
|
||||
|
||||
describe('overflow / maxLines', () => {
|
||||
it('applies truncate for overflow ellipsis', () => {
|
||||
expect(getTypographyClassNames({ overflow: 'ellipsis' })).toContain('truncate');
|
||||
});
|
||||
|
||||
it('applies overflow-clip for overflow clip', () => {
|
||||
expect(getTypographyClassNames({ overflow: 'clip' })).toContain('overflow-clip');
|
||||
});
|
||||
|
||||
it('applies line-clamp for maxLines', () => {
|
||||
expect(getTypographyClassNames({ maxLines: 3 })).toContain('line-clamp-3');
|
||||
});
|
||||
|
||||
it('maxLines takes precedence over overflow', () => {
|
||||
const result = getTypographyClassNames({ maxLines: 2, overflow: 'ellipsis' });
|
||||
expect(result).toContain('line-clamp-2');
|
||||
expect(result).not.toContain('truncate');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Typography type roles.
|
||||
* Determines font family and base visual treatment.
|
||||
*/
|
||||
export type TextType = 'display' | 'headline' | 'title' | 'body' | 'label';
|
||||
|
||||
/**
|
||||
* Typography size scale.
|
||||
* Applied within a type role to select the appropriate @theme token.
|
||||
*/
|
||||
export type TextSize = 's' | 'm' | 'l';
|
||||
|
||||
/**
|
||||
* Text color variants mapped to design-system CSS custom properties.
|
||||
*/
|
||||
export type TextColor =
|
||||
| 'default'
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'tertiary'
|
||||
| 'error'
|
||||
| 'muted'
|
||||
| 'inherit';
|
||||
|
||||
/**
|
||||
* Text alignment.
|
||||
*/
|
||||
export type TextAlign = 'left' | 'center' | 'right' | 'justify';
|
||||
|
||||
/**
|
||||
* Text wrapping behaviour.
|
||||
*/
|
||||
export type TextWrap = 'nowrap' | 'balance' | 'pretty';
|
||||
|
||||
/**
|
||||
* Overflow handling for single-line text.
|
||||
*/
|
||||
export type TextOverflow = 'ellipsis' | 'clip';
|
||||
|
||||
/**
|
||||
* Full set of typography options supported by getTypographyClassNames.
|
||||
*/
|
||||
export interface TypographyOptions {
|
||||
/** Typography role — determines font family and base style. */
|
||||
type?: TextType;
|
||||
/** Size scale within the role. */
|
||||
size?: TextSize;
|
||||
/** Text color variant. */
|
||||
color?: TextColor;
|
||||
/** Bold weight. */
|
||||
bold?: boolean;
|
||||
/** Italic style. */
|
||||
italic?: boolean;
|
||||
/** Underline decoration. */
|
||||
underline?: boolean;
|
||||
/** Strikethrough decoration. */
|
||||
strike?: boolean;
|
||||
/** Text alignment. */
|
||||
align?: TextAlign;
|
||||
/** Text wrapping behaviour. */
|
||||
wrap?: TextWrap;
|
||||
/** Overflow truncation for single lines. */
|
||||
overflow?: TextOverflow;
|
||||
/** Maximum visible lines (truncates with ellipsis). Takes precedence over overflow. */
|
||||
maxLines?: number;
|
||||
}
|
||||
|
||||
const fontFamilyClasses: Record<TextType, string> = {
|
||||
display: 'font-serif',
|
||||
headline: 'font-serif',
|
||||
title: 'font-sans',
|
||||
body: 'font-sans',
|
||||
label: 'font-sans',
|
||||
};
|
||||
|
||||
const colorClasses: Record<TextColor, string> = {
|
||||
default: 'text-[var(--rb-text)]',
|
||||
primary: 'text-[var(--rb-primary-text)]',
|
||||
secondary: 'text-[var(--rb-secondary-text)]',
|
||||
tertiary: 'text-[var(--rb-tertiary-text)]',
|
||||
error: 'text-[var(--rb-error-text)]',
|
||||
muted: 'text-[var(--rb-muted-text)]',
|
||||
inherit: 'text-inherit',
|
||||
};
|
||||
|
||||
const alignClasses: Record<TextAlign, string> = {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
justify: 'text-justify',
|
||||
};
|
||||
|
||||
const wrapClasses: Record<TextWrap, string> = {
|
||||
nowrap: 'whitespace-nowrap',
|
||||
balance: 'text-balance',
|
||||
pretty: 'text-pretty',
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a space-separated string of Tailwind CSS classes for the given
|
||||
* typography options.
|
||||
*
|
||||
* The returned string is safe to pass directly to `cn()` alongside other
|
||||
* classes — it contains no internal conflicts. Tailwind-merge is configured
|
||||
* (via the stl `cn` utility) to treat `text-rb-{type}-{size}` as a
|
||||
* font-size group so it never conflicts with text-color classes.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // In a component:
|
||||
* <p className={cn(getTypographyClassNames({ type: 'body', size: 'l' }), className)}>
|
||||
*
|
||||
* // In a CSS-cascade parent/child pattern:
|
||||
* <div className="rb-scale-l">
|
||||
* <p className={cn(getTypographyClassNames({ type: 'body' }))}>…</p>
|
||||
* </div>
|
||||
* ```
|
||||
*/
|
||||
export function getTypographyClassNames(options: TypographyOptions = {}): string {
|
||||
const {
|
||||
type = 'body',
|
||||
size = 'm',
|
||||
color = 'default',
|
||||
bold = false,
|
||||
italic = false,
|
||||
underline = false,
|
||||
strike = false,
|
||||
align,
|
||||
wrap,
|
||||
overflow,
|
||||
maxLines,
|
||||
} = options;
|
||||
|
||||
let truncationClass = '';
|
||||
if (maxLines) {
|
||||
truncationClass = `line-clamp-${maxLines}`;
|
||||
} else if (overflow === 'ellipsis') {
|
||||
truncationClass = 'truncate';
|
||||
} else if (overflow === 'clip') {
|
||||
truncationClass = 'overflow-clip';
|
||||
}
|
||||
|
||||
return [
|
||||
`text-rb-${type}-${size}`,
|
||||
fontFamilyClasses[type],
|
||||
type === 'label' && 'font-medium',
|
||||
colorClasses[color],
|
||||
bold && 'font-bold',
|
||||
italic && 'italic',
|
||||
underline && 'underline',
|
||||
strike && 'line-through',
|
||||
align && alignClasses[align],
|
||||
wrap && wrapClasses[wrap],
|
||||
truncationClass,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './getTypographyClassNames/getTypographyClassNames';
|
||||
@@ -76,6 +76,11 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
/* Safelist: text-rb-{type}-{size} utilities are assembled via template literals
|
||||
in getTypographyClassNames and cannot be detected by Tailwind's static scanner.
|
||||
@source inline forces inclusion regardless of source scanning. */
|
||||
@source inline("text-rb-display-l text-rb-display-m text-rb-display-s text-rb-headline-l text-rb-headline-m text-rb-headline-s text-rb-title-l text-rb-title-m text-rb-title-s text-rb-body-l text-rb-body-m text-rb-body-s text-rb-label-l text-rb-label-m text-rb-label-s");
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
@@ -184,6 +189,33 @@
|
||||
--rb-color-neutral-975: #141414;
|
||||
}
|
||||
|
||||
@theme {
|
||||
/* display */
|
||||
--text-rb-display-l: 3.5625rem; --text-rb-display-l--line-height: 4rem; --text-rb-display-l--letter-spacing: -0.015625rem;
|
||||
--text-rb-display-m: 2.8125rem; --text-rb-display-m--line-height: 3.25rem; --text-rb-display-m--letter-spacing: 0rem;
|
||||
--text-rb-display-s: 2.25rem; --text-rb-display-s--line-height: 2.75rem; --text-rb-display-s--letter-spacing: 0rem;
|
||||
|
||||
/* headline */
|
||||
--text-rb-headline-l: 2rem; --text-rb-headline-l--line-height: 2.5rem; --text-rb-headline-l--letter-spacing: 0rem;
|
||||
--text-rb-headline-m: 1.75rem; --text-rb-headline-m--line-height: 2.25rem; --text-rb-headline-m--letter-spacing: 0rem;
|
||||
--text-rb-headline-s: 1.5rem; --text-rb-headline-s--line-height: 2rem; --text-rb-headline-s--letter-spacing: 0rem;
|
||||
|
||||
/* title */
|
||||
--text-rb-title-l: 1.375rem; --text-rb-title-l--line-height: 1.75rem; --text-rb-title-l--letter-spacing: 0rem;
|
||||
--text-rb-title-m: 1rem; --text-rb-title-m--line-height: 1.5rem; --text-rb-title-m--letter-spacing: 0.009375rem;
|
||||
--text-rb-title-s: 0.875rem; --text-rb-title-s--line-height: 1.25rem; --text-rb-title-s--letter-spacing: 0.00625rem;
|
||||
|
||||
/* body — baseline raised: m = 1rem (was 14px/0.875rem) */
|
||||
--text-rb-body-l: 1.125rem; --text-rb-body-l--line-height: 1.75rem; --text-rb-body-l--letter-spacing: 0.03125rem;
|
||||
--text-rb-body-m: 1rem; --text-rb-body-m--line-height: 1.5rem; --text-rb-body-m--letter-spacing: 0.015625rem;
|
||||
--text-rb-body-s: 0.875rem; --text-rb-body-s--line-height: 1.25rem; --text-rb-body-s--letter-spacing: 0.025rem;
|
||||
|
||||
/* label */
|
||||
--text-rb-label-l: 0.875rem; --text-rb-label-l--line-height: 1.25rem; --text-rb-label-l--letter-spacing: 0.00625rem; --text-rb-label-l--font-weight: 500;
|
||||
--text-rb-label-m: 0.75rem; --text-rb-label-m--line-height: 1rem; --text-rb-label-m--letter-spacing: 0.03125rem; --text-rb-label-m--font-weight: 500;
|
||||
--text-rb-label-s: 0.6875rem; --text-rb-label-s--line-height: 1rem; --text-rb-label-s--letter-spacing: 0.03125rem; --text-rb-label-s--font-weight: 500;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Core palette */
|
||||
--rb-base: var(--rb-color-neutral-50);
|
||||
@@ -250,6 +282,39 @@
|
||||
--rb-chart-5: var(--rb-color-red-400);
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ── Scale parents — set CSS vars for all type children below ── */
|
||||
.rb-scale-s {
|
||||
--rb-sz-display: var(--text-rb-display-s); --rb-lh-display: var(--text-rb-display-s--line-height);
|
||||
--rb-sz-headline: var(--text-rb-headline-s); --rb-lh-headline: var(--text-rb-headline-s--line-height);
|
||||
--rb-sz-title: var(--text-rb-title-s); --rb-lh-title: var(--text-rb-title-s--line-height);
|
||||
--rb-sz-body: var(--text-rb-body-s); --rb-lh-body: var(--text-rb-body-s--line-height);
|
||||
--rb-sz-label: var(--text-rb-label-s); --rb-lh-label: var(--text-rb-label-s--line-height);
|
||||
}
|
||||
.rb-scale-m {
|
||||
--rb-sz-display: var(--text-rb-display-m); --rb-lh-display: var(--text-rb-display-m--line-height);
|
||||
--rb-sz-headline: var(--text-rb-headline-m); --rb-lh-headline: var(--text-rb-headline-m--line-height);
|
||||
--rb-sz-title: var(--text-rb-title-m); --rb-lh-title: var(--text-rb-title-m--line-height);
|
||||
--rb-sz-body: var(--text-rb-body-m); --rb-lh-body: var(--text-rb-body-m--line-height);
|
||||
--rb-sz-label: var(--text-rb-label-m); --rb-lh-label: var(--text-rb-label-m--line-height);
|
||||
}
|
||||
.rb-scale-l {
|
||||
--rb-sz-display: var(--text-rb-display-l); --rb-lh-display: var(--text-rb-display-l--line-height);
|
||||
--rb-sz-headline: var(--text-rb-headline-l); --rb-lh-headline: var(--text-rb-headline-l--line-height);
|
||||
--rb-sz-title: var(--text-rb-title-l); --rb-lh-title: var(--text-rb-title-l--line-height);
|
||||
--rb-sz-body: var(--text-rb-body-l); --rb-lh-body: var(--text-rb-body-l--line-height);
|
||||
--rb-sz-label: var(--text-rb-label-l); --rb-lh-label: var(--text-rb-label-l--line-height);
|
||||
}
|
||||
|
||||
/* ── Type children — declare type, read from parent scale vars ── */
|
||||
/* Fallback (second arg to var()) applies m-size when no rb-scale-* parent exists */
|
||||
.rb-text-display { font-family: var(--font-serif); font-size: var(--rb-sz-display, var(--text-rb-display-m)); line-height: var(--rb-lh-display, var(--text-rb-display-m--line-height)); }
|
||||
.rb-text-headline { font-family: var(--font-serif); font-size: var(--rb-sz-headline, var(--text-rb-headline-m)); line-height: var(--rb-lh-headline, var(--text-rb-headline-m--line-height)); }
|
||||
.rb-text-title { font-family: var(--font-sans); font-size: var(--rb-sz-title, var(--text-rb-title-m)); line-height: var(--rb-lh-title, var(--text-rb-title-m--line-height)); }
|
||||
.rb-text-body { font-family: var(--font-sans); font-size: var(--rb-sz-body, var(--text-rb-body-m)); line-height: var(--rb-lh-body, var(--text-rb-body-m--line-height)); }
|
||||
.rb-text-label { font-family: var(--font-sans); font-weight: 500; font-size: var(--rb-sz-label, var(--text-rb-label-m)); line-height: var(--rb-lh-label, var(--text-rb-label-m--line-height)); }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
|
||||
Reference in new issue
Block a user