feat: Add generic components for UI

This commit is contained in:
deytenit committed 2026-01-31 17:52:45 +03:00
1 parent 08a7f44588
commit 054cf4f5e3
37 files changed
+1561 -240

No files matched your search

@@ -0,0 +1,46 @@
# Design Plan: PaperArticle Component & Interactive Container
**Date:** 2026-01-31
**Status:** Approved
## Overview
This plan outlines the implementation of the `PaperArticle` component within the `paper` component set and the enhancement of the generic `Container` component to support interactivity.
## 1. Interactive Container Enhancement
Enhance `packages/ui/src/components/generic/Container` to support press interactions.
### Changes
- **Prop Expansion:** Add `onPress?: (e: PressEvent) => void` to `ContainerProps`.
- **Interactivity:** Integrate `usePress` from `react-aria`.
- **Ref Handling:** Use `useRef` for the underlying `div` to support `usePress`.
- **Visual Feedback:**
- Add `cursor-pointer` when `onPress` is present.
- Add a subtle active state (e.g., `active:opacity-80`) for immediate haptic/visual feedback.
- **Accessibility:** `usePress` handles keyboard interactions (Enter/Space) and ARIA attributes automatically.
## 2. PaperArticle Component
A specialized layout component for newspaper-style articles.
### Location
`packages/ui/src/components/paper/PaperArticle/PaperArticle.tsx`
### Props
- `headline` (string): The main article title.
- `subHeadline` (string, optional): Italicized label text between separators.
- `additionalText` (string, optional): Small, muted, italicized text at the bottom.
- `onPress` (handler, optional): Propagated to the wrapping `Container`.
- `children` (ReactNode): The main body content.
### Layout (VStack)
1. **Header:** `level={4}` (headline-m), centered.
2. **Separator:** `PaperSeparator` (type: single).
3. **Sub-headline (if present):** `Text` (type: label, size: m, italic: true), centered.
4. **Separator (if sub-headline present):** `PaperSeparator` (type: single).
5. **Main Body:** `Text` (type: body, size: m) wrapping `children`.
6. **Additional Text (if present):** `Text` (type: label, size: s, italic: true, color: muted).
## 3. Implementation Steps
1. **Modify `Container`**: Add `react-aria` hooks and `onPress` logic.
2. **Create `PaperArticle`**: Implement the structured layout.
3. **Export**: Update `packages/ui/src/components/paper/index.ts` and `packages/ui/src/index.ts`.
4. **Verify**: Add Storybook stories for `PaperArticle` and interactive `Container` states.
+8
View File
@@ -53,6 +53,12 @@ export const baseConfig = tseslint.config(
'error',
{ prefer: 'type-imports', disallowTypeAnnotations: false },
],
'@typescript-eslint/consistent-type-assertions': [
'error',
{
assertionStyle: 'never',
},
],
'@typescript-eslint/explicit-function-return-type': [
'error',
{ allowExpressions: true, allowTypedFunctionExpressions: true },
@@ -139,6 +145,7 @@ export const baseConfig = tseslint.config(
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/consistent-type-assertions': 'off',
'jsdoc/require-jsdoc': 'off',
},
},
@@ -159,6 +166,7 @@ export const baseConfig = tseslint.config(
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/consistent-type-assertions': 'off',
'jsdoc/require-jsdoc': 'off',
'no-undef': 'off', // For CommonJS files
},
+6
View File
@@ -31,12 +31,18 @@
"@ermnvldmr/eslint-config": "workspace:*",
"@ermnvldmr/jest-config": "workspace:*",
"@ermnvldmr/vite-config": "workspace:*",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"jest": "^30.2.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"ts-jest": "^29.4.6",
"typescript": "^5.8.3",
"vite": "^7.2.2"
},
"peerDependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwindcss": "^4.1.4"
}
}
+1
View File
@@ -1,2 +1,3 @@
export * from './types';
export * from './functions';
export * from './react';
@@ -0,0 +1,11 @@
import { castRef } from './castRef';
import type React from 'react';
describe('react/castRef', () => {
it('should cast a ref to a different type', () => {
const ref = { current: null } as unknown as React.ForwardedRef<HTMLDivElement>;
const casted = castRef<HTMLSpanElement>(ref);
expect(casted).toBe(ref);
});
});
+19
View File
@@ -0,0 +1,19 @@
import type React from 'react';
/**
* Safely casts a React ref to a specific type.
* This is a controlled escape hatch for situations where TypeScript's
* built-in Ref types are too restrictive (e.g., in generic forwardRef components).
*
* @param ref - The ref to cast
* @returns The casted ref
* @example
* ```tsx
* <Component ref={castRef<HTMLDivElement>(ref)} />
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function castRef<T>(ref: React.ForwardedRef<any>): React.ForwardedRef<T> {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return ref as React.ForwardedRef<T>;
}
@@ -0,0 +1,14 @@
import React from 'react';
import { genericMemo } from './genericMemo';
describe('react/genericMemo', () => {
it('should memoize a component', () => {
const Component = ({ name }: { name: string }) => <div>{name}</div>;
const Memoized = genericMemo(Component);
expect(Memoized).toBeDefined();
// In React, memo returns a special object with type: component
expect((Memoized as any).type).toBe(Component);
});
});
@@ -0,0 +1,16 @@
import React from 'react';
/**
* A type-safe version of React.memo that preserves generic types.
*
* @param component - The component to memoize
* @returns The memoized component
* @example
* ```tsx
* export const MyComponent = genericMemo(MyGenericComponent);
* ```
*/
export function genericMemo<T>(component: T): T {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
return React.memo(component as any) as any as T;
}
+10
View File
@@ -0,0 +1,10 @@
export { castRef } from './castRef/castRef';
export { genericMemo } from './genericMemo/genericMemo';
import type React from 'react';
/**
* Common props for any React element.
* Useful for forwarding props to dynamic components.
*/
export type ReactElementProps = React.HTMLAttributes<HTMLElement> & React.AllHTMLAttributes<HTMLElement>;
+2
View File
@@ -1,6 +1,7 @@
import type { StorybookConfig } from '@storybook/react-vite';
import { mergeConfig } from 'vite';
import { resolve } from 'path';
import tailwindcss from '@tailwindcss/vite';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
@@ -25,6 +26,7 @@ const config: StorybookConfig = {
},
viteFinal: async (config) => {
return mergeConfig(config, {
plugins: [tailwindcss()],
resolve: {
alias: {
'@ermnvldmr/stl': resolve(__dirname, '../../stl/src/index.ts'),
+7 -1
View File
@@ -1,3 +1,9 @@
import { reactJestConfig } from '@ermnvldmr/jest-config/react';
export default reactJestConfig;
export default {
...reactJestConfig,
moduleNameMapper: {
...reactJestConfig.moduleNameMapper,
'^@ermnvldmr/stl$': '<rootDir>/../stl/src/index.ts',
},
};
@@ -0,0 +1,66 @@
import React from 'react';
import { Breadcrumbs } from './Breadcrumbs';
import { Link } from '../Link/Link';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Breadcrumbs> = {
title: 'components/generic/Breadcrumbs',
component: Breadcrumbs,
tags: ['autodocs'],
};
export default meta;
/**
*
*/
type Story = StoryObj<typeof Breadcrumbs>;
export const Default: Story = {
render: () => (
<Breadcrumbs>
<Breadcrumbs.Item>
<Link href="/">Home</Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator variant="slash" />
<Breadcrumbs.Item>
<Link href="/blog">Blog</Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator variant="slash" />
<Breadcrumbs.Item>Modern Web Design</Breadcrumbs.Item>
</Breadcrumbs>
),
};
export const WithArrows: Story = {
render: () => (
<Breadcrumbs>
<Breadcrumbs.Item>
<Link href="/">Home</Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator variant="arrow" />
<Breadcrumbs.Item>
<Link href="/docs">Docs</Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator variant="arrow" />
<Breadcrumbs.Item>Components</Breadcrumbs.Item>
</Breadcrumbs>
),
};
export const WithBullets: Story = {
render: () => (
<Breadcrumbs>
<Breadcrumbs.Item>
<Link href="/">Home</Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator variant="bull" />
<Breadcrumbs.Item>
<Link href="/about">About</Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator variant="bull" />
<Breadcrumbs.Item>Team</Breadcrumbs.Item>
</Breadcrumbs>
),
};
@@ -0,0 +1,40 @@
import React from 'react';
import { Breadcrumbs } from './Breadcrumbs';
import { render, screen } from '../../../test-utils';
import { Link } from '../Link/Link';
describe('components/generic/Breadcrumbs', () => {
it('renders correctly with multiple items and separators', () => {
render(
<Breadcrumbs>
<Breadcrumbs.Item>
<Link href="/">Home</Link>
</Breadcrumbs.Item>
<Breadcrumbs.Separator variant="slash" />
<Breadcrumbs.Item>Current</Breadcrumbs.Item>
</Breadcrumbs>
);
expect(screen.getByRole('navigation')).toHaveAttribute('aria-label', 'Breadcrumb');
expect(screen.getByRole('link', { name: 'Home' })).toBeInTheDocument();
expect(screen.getByText('/')).toBeInTheDocument();
expect(screen.getByText('Current')).toBeInTheDocument();
});
it('supports different separator variants', () => {
const { rerender } = render(
<Breadcrumbs>
<Breadcrumbs.Separator data-testid="sep" variant="bull" />
</Breadcrumbs>
);
expect(screen.getByTestId('sep')).toHaveTextContent('•');
rerender(
<Breadcrumbs>
<Breadcrumbs.Separator data-testid="sep" variant="slash" />
</Breadcrumbs>
);
expect(screen.getByTestId('sep')).toHaveTextContent('/');
});
});
@@ -0,0 +1,102 @@
import { cn } from '@ermnvldmr/stl';
import { ChevronRight } from 'lucide-react';
import React, { memo } from 'react';
import { HStack } from '../HStack/HStack';
import { Text } from '../Text/Text';
import type { HStackProps } from '../HStack/HStack';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
/**
* Props for the Breadcrumbs component.
*/
export interface BreadcrumbsProps extends HStackProps {
/** Breadcrumb items and separators */
children: React.ReactNode;
}
/**
* Root component for hierarchical navigation.
*/
const BreadcrumbsRoot = memo(function Breadcrumbs({ children, ...props }: BreadcrumbsProps) {
return (
<nav aria-label="Breadcrumb">
<HStack align="center" as="ol" gap={2} {...props}>
{children}
</HStack>
</nav>
);
});
/**
* Individual step in the breadcrumb trail.
*/
const BreadcrumbsItem = memo(function BreadcrumbsItem({
children,
className,
'data-testid': testId,
}: {
children: React.ReactNode;
className?: string;
} & TestIdProps) {
return (
<li className={className} data-testid={testId}>
<Text color="muted" size="m" type="label">
{children}
</Text>
</li>
);
});
/**
* Visual divider between breadcrumb items.
*/
export interface BreadcrumbsSeparatorProps extends ClassNameProps, TestIdProps {
/** Visual variant of the separator */
variant?: 'slash' | 'bull' | 'arrow';
}
const BreadcrumbsSeparator = memo(function BreadcrumbsSeparator({
variant = 'slash',
className,
'data-testid': testId,
}: BreadcrumbsSeparatorProps) {
const content = {
slash: '/',
bull: '•',
arrow: <ChevronRight className="size-4" />,
};
return (
<li
aria-hidden="true"
className={cn('text-[var(--rb-muted-text)]', className)}
data-testid={testId}
>
<Text color="muted" size="m" type="label">
{content[variant]}
</Text>
</li>
);
});
/**
* A hierarchical navigation component.
*
* Breadcrumbs provides a flexible way to display the current location within
* a site's hierarchy. It uses a compound component pattern for maximum flexibility.
*
* @example
* ```tsx
* <Breadcrumbs>
* <Breadcrumbs.Item><Link href="/">Home</Link></Breadcrumbs.Item>
* <Breadcrumbs.Separator variant="arrow" />
* <Breadcrumbs.Item>Current Page</Breadcrumbs.Item>
* </Breadcrumbs>
* ```
*/
export const Breadcrumbs = Object.assign(BreadcrumbsRoot, {
Item: BreadcrumbsItem,
Separator: BreadcrumbsSeparator,
});
@@ -0,0 +1,133 @@
import React from 'react';
import { Container } from './Container';
import { Text } from '../Text/Text';
import { VStack } from '../VStack/VStack';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Container> = {
title: 'components/generic/Container',
component: Container,
tags: ['autodocs'],
argTypes: {
bg: {
control: 'select',
options: ['base', 'primary', 'secondary', 'tertiary', 'error', 'muted', 'transparent'],
},
padding: {
control: 'number',
},
maxWidth: {
control: 'select',
options: ['sm', 'md', 'lg', 'xl', 'none'],
},
rounded: {
control: 'select',
options: ['none', 'sm', 'md', 'lg', 'full'],
},
},
};
export default meta;
/**
*
*/
type Story = StoryObj<typeof Container>;
export const Default: Story = {
args: {
children: 'This is a container with some content.',
padding: 4,
bg: 'base',
border: true,
rounded: 'md',
},
};
export const SemanticColors: Story = {
render: () => (
<VStack gap={4}>
<Container border bg="base" padding={4} rounded="md">Base Surface</Container>
<Container bg="primary" padding={4} rounded="md">Primary Surface</Container>
<Container bg="secondary" padding={4} rounded="md">Secondary Surface</Container>
<Container bg="tertiary" padding={4} rounded="md">Tertiary Surface</Container>
<Container bg="error" padding={4} rounded="md">Error Surface</Container>
<Container bg="muted" padding={4} rounded="md">Muted Surface</Container>
<Container border bg="transparent" padding={4} rounded="md">Transparent Surface</Container>
</VStack>
),
};
export const PaddingScale: Story = {
render: () => (
<VStack gap={4}>
<Container border bg="muted" padding={1}>Padding 1</Container>
<Container border bg="muted" padding={2}>Padding 2</Container>
<Container border bg="muted" padding={4}>Padding 4</Container>
<Container border bg="muted" padding={8}>Padding 8</Container>
<Container border bg="muted" padding={12}>Padding 12</Container>
</VStack>
),
};
export const MaxWidths: Story = {
render: () => (
<VStack className="w-full" gap={4}>
<Container bg="tertiary" maxWidth="sm" padding={4} rounded="md">Max Width SM (640px)</Container>
<Container bg="tertiary" maxWidth="md" padding={4} rounded="md">Max Width MD (768px)</Container>
<Container bg="tertiary" maxWidth="lg" padding={4} rounded="md">Max Width LG (1024px)</Container>
<Container bg="tertiary" maxWidth="xl" padding={4} rounded="md">Max Width XL (1280px)</Container>
</VStack>
),
};
export const Rounding: Story = {
render: () => (
<div className="flex flex-wrap gap-4">
<Container bg="secondary" padding={8} rounded="none">None</Container>
<Container bg="secondary" padding={8} rounded="sm">Small</Container>
<Container bg="secondary" padding={8} rounded="md">Medium</Container>
<Container bg="secondary" padding={8} rounded="lg">Large</Container>
<Container bg="secondary" padding={8} rounded="full">Full</Container>
</div>
),
};
export const ElevationAndBorders: Story = {
render: () => (
<VStack gap={8}>
<Container border bg="base" padding={6} rounded="lg">
Border Only
</Container>
<Container shadow bg="base" padding={6} rounded="lg">
Shadow Only
</Container>
<Container border shadow bg="base" padding={6} rounded="lg">
Border + Shadow
</Container>
</VStack>
),
};
export const Composition: Story = {
render: () => (
<Container border shadow bg="base" maxWidth="md" padding={0} rounded="lg">
<Container bg="primary" padding={4} rounded="none">
<Text bold color="inherit" size="l" type="title">Card Header</Text>
</Container>
<Container padding={6}>
<VStack gap={4}>
<Text size="m" type="body">
This example demonstrates how containers can be nested and composed.
The outer container has a shadow and border, while the header uses a primary background.
</Text>
<Container border bg="muted" padding={4} rounded="md">
<Text size="m" type="label">Inside a nested muted container</Text>
</Container>
</VStack>
</Container>
</Container>
),
};
@@ -0,0 +1,28 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Container } from './Container';
import { render, screen } from '../../../test-utils';
describe('components/generic/Container', () => {
it('handles onPress event', async () => {
const user = userEvent.setup();
const handlePress = jest.fn();
render(<Container onPress={handlePress}>Content</Container>);
const container = screen.getByText('Content');
await user.click(container);
expect(handlePress).toHaveBeenCalledTimes(1);
});
it('applies cursor-pointer and active styles when onPress is present', () => {
const { rerender } = render(<Container>Content</Container>);
let container = screen.getByText('Content');
expect(container).not.toHaveClass('cursor-pointer');
rerender(<Container onPress={() => {}}>Content</Container>);
container = screen.getByText('Content');
expect(container).toHaveClass('cursor-pointer');
expect(container).toHaveClass('active:opacity-80');
});
});
@@ -0,0 +1,157 @@
import { cn } from '@ermnvldmr/stl';
import React, { memo } from 'react';
import { usePress } from 'react-aria';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
import type { PressEvents } from 'react-aria';
/**
* Tailwind gap spacing scale values.
*/
type GapScale = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96;
/**
* Background variants for the Container.
*/
export type ContainerBackground = 'base' | 'primary' | 'secondary' | 'tertiary' | 'error' | 'muted' | 'transparent';
/**
* Max width constraints for the Container.
*/
export type ContainerMaxWidth = 'sm' | 'md' | 'lg' | 'xl' | 'none';
/**
* Border radius variants for the Container.
*/
export type ContainerRounded = 'none' | 'sm' | 'md' | 'lg' | 'full';
/**
* Props for the Container component.
*/
export interface ContainerProps extends ClassNameProps, TestIdProps {
/** Content to be rendered inside the container */
children: React.ReactNode;
/** Background color variant */
bg?: ContainerBackground;
/** Padding using Tailwind spacing scale */
padding?: GapScale;
/** Maximum width constraint */
maxWidth?: ContainerMaxWidth;
/** Border radius variant */
rounded?: ContainerRounded;
/** Whether to show a shadow */
shadow?: boolean;
/** Whether to show a border */
border?: boolean;
/** Press interaction handler */
onPress?: PressEvents['onPress'];
}
/**
* A generic surface component for creating structural layouts.
*
* Container provides a consistent way to handle backgrounds, padding,
* borders, and width constraints while maintaining theme consistency.
*/
export const Container = memo(function Container({
children,
bg = 'base',
padding = 0,
maxWidth = 'none',
rounded = 'none',
shadow = false,
border = false,
onPress,
className,
'data-testid': testId,
}: ContainerProps) {
const ref = React.useRef<HTMLDivElement>(null);
const { pressProps } = usePress({
onPress,
isDisabled: !onPress,
});
const bgClasses: Record<ContainerBackground, string> = {
base: 'bg-[var(--rb-container-base)] text-[var(--rb-container-text)]',
primary: 'bg-[var(--rb-primary-base)] text-[var(--rb-primary-text)]',
secondary: 'bg-[var(--rb-secondary-base)] text-[var(--rb-secondary-text)]',
tertiary: 'bg-[var(--rb-tertiary-base)] text-[var(--rb-tertiary-text)]',
error: 'bg-[var(--rb-error-base)] text-[var(--rb-error-text)]',
muted: 'bg-[var(--rb-muted-base)] text-[var(--rb-muted-text)]',
transparent: 'bg-transparent',
};
const maxWidthClasses: Record<ContainerMaxWidth, string> = {
sm: 'max-w-screen-sm',
md: 'max-w-screen-md',
lg: 'max-w-screen-lg',
xl: 'max-w-screen-xl',
none: 'max-w-none',
};
const roundedClasses: Record<ContainerRounded, string> = {
none: 'rounded-none',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
full: 'rounded-full',
};
const paddingClasses: Record<GapScale, string> = {
0: 'p-0',
0.5: 'p-0.5',
1: 'p-1',
1.5: 'p-1.5',
2: 'p-2',
2.5: 'p-2.5',
3: 'p-3',
3.5: 'p-3.5',
4: 'p-4',
5: 'p-5',
6: 'p-6',
7: 'p-7',
8: 'p-8',
9: 'p-9',
10: 'p-10',
11: 'p-11',
12: 'p-12',
14: 'p-14',
16: 'p-16',
20: 'p-20',
24: 'p-24',
28: 'p-28',
32: 'p-32',
36: 'p-36',
40: 'p-40',
44: 'p-44',
48: 'p-48',
52: 'p-52',
56: 'p-56',
60: 'p-60',
64: 'p-64',
72: 'p-72',
80: 'p-80',
96: 'p-96',
};
return (
<div
{...pressProps}
ref={ref}
className={cn(
'overflow-hidden',
bgClasses[bg],
maxWidthClasses[maxWidth],
roundedClasses[rounded],
paddingClasses[padding],
shadow && 'shadow-md',
border && 'border border-[var(--rb-outline)]',
onPress && 'cursor-pointer active:opacity-80 transition-opacity',
className
)}
data-testid={testId}
>
{children}
</div>
);
});
@@ -1,4 +1,4 @@
import React, { memo } from 'react';
import React, { memo, forwardRef } from 'react';
import { Stack } from '../Stack/Stack';
@@ -37,6 +37,6 @@ export interface HStackProps extends Omit<StackProps, 'direction'> {
* </HStack>
* ```
*/
export const HStack = memo(function HStack(props: HStackProps) {
return <Stack direction="row" {...props} />;
});
export const HStack = memo(forwardRef<HTMLElement, HStackProps>(function HStack(props, ref) {
return <Stack direction="row" {...props} ref={ref} />;
}));
@@ -0,0 +1,59 @@
import React from 'react';
import { Header } from './Header';
import { VStack } from '../VStack/VStack';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Header> = {
title: 'Generic/Header',
component: Header,
tags: ['autodocs'],
argTypes: {
level: {
control: { type: 'select' },
options: [1, 2, 3, 4, 5, 6],
},
color: {
control: 'select',
options: ['default', 'primary', 'secondary', 'tertiary', 'error', 'muted', 'inherit'],
},
},
};
export default meta;
/**
*
*/
type Story = StoryObj<typeof Header>;
export const Default: Story = {
args: {
children: 'Header Level 1',
level: 1,
},
};
export const AllLevels: Story = {
render: () => (
<VStack gap={4}>
<Header level={1}>Header Level 1 (Display L)</Header>
<Header level={2}>Header Level 2 (Display M)</Header>
<Header level={3}>Header Level 3 (Headline L)</Header>
<Header level={4}>Header Level 4 (Headline M)</Header>
<Header level={5}>Header Level 5 (Title L)</Header>
<Header level={6}>Header Level 6 (Title M)</Header>
</VStack>
),
};
export const Overrides: Story = {
render: () => (
<VStack gap={4}>
<Header level={1} size="s" type="headline">H1 Tag with Headline Small Style</Header>
<Header color="primary" level={3} size="l" type="body">H3 Tag with Body Large Style and Primary Color</Header>
<Header italic underline level={6}>H6 with Modifiers</Header>
</VStack>
),
};
@@ -0,0 +1,60 @@
import React, { memo } from 'react';
import { Text } from '../Text/Text';
import type { TextProps, TextType, TextSize } from '../Text/Text';
/**
* Props for the Header component.
*/
export interface HeaderProps extends Omit<TextProps, 'as'> {
/** The semantic header level (1-6) */
level: 1 | 2 | 3 | 4 | 5 | 6;
}
/**
* A semantic header component that wraps the Text component.
*
* Header automatically maps semantic levels (h1-h6) to the appropriate
* typography variants in the design system, ensuring accessibility
* and visual consistency.
*/
export const Header = memo(function Header({
level,
type,
size,
children,
...props
}: HeaderProps) {
// Default mappings from level to type and size
const levelMapping: Record<number, { type: TextType; size: TextSize }> = {
1: { type: 'display', size: 'l' },
2: { type: 'display', size: 'm' },
3: { type: 'headline', size: 'l' },
4: { type: 'headline', size: 'm' },
5: { type: 'title', size: 'l' },
6: { type: 'title', size: 'm' },
};
const defaultStyles = levelMapping[level];
const tags = {
1: 'h1',
2: 'h2',
3: 'h3',
4: 'h4',
5: 'h5',
6: 'h6',
} as const;
const Tag = tags[level];
return (
<Text
as={Tag}
size={size ?? defaultStyles.size}
type={type ?? defaultStyles.type}
{...props}
>
{children}
</Text>
);
});
@@ -0,0 +1,33 @@
import { Link } from './Link';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Link> = {
title: 'components/generic/Link',
component: Link,
tags: ['autodocs'],
argTypes: {
onPress: { action: 'pressed' },
},
};
export default meta;
/**
*
*/
type Story = StoryObj<typeof Link>;
export const Default: Story = {
args: {
href: 'https://example.com',
children: 'Standard Link',
},
};
export const WithoutHref: Story = {
args: {
children: 'Action Link (no href)',
onPress: () => alert('Link pressed!'),
},
};
@@ -0,0 +1,18 @@
import React from 'react';
import { Link } from './Link';
import { render, screen } from '../../../test-utils';
describe('components/generic/Link', () => {
it('renders correctly as a link when href is provided', () => {
render(<Link href="https://example.com">Click me</Link>);
const link = screen.getByRole('link', { name: 'Click me' });
expect(link).toHaveAttribute('href', 'https://example.com');
expect(link).toHaveClass('text-[var(--rb-ring)]');
});
it('renders as a button/span when no href is provided but has onPress', () => {
render(<Link onPress={() => {}}>Click me</Link>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
});
@@ -0,0 +1,64 @@
import { castRef, cn } from '@ermnvldmr/stl';
import React, { memo, useRef } from 'react';
import { useLink } from 'react-aria';
import { Text } from '../Text/Text';
import type { ClassNameProps } from '@ermnvldmr/stl';
import type { AriaLinkOptions } from 'react-aria';
/**
* Props for the Link component.
*/
export interface LinkProps extends AriaLinkOptions, ClassNameProps {
/** The destination URL. Renders as an <a> tag if provided. */
href?: string;
/** Link content */
children: React.ReactNode;
}
/**
* A generic navigation primitive for interactive text.
*
* Link ensures consistent styling and accessibility across all navigation elements.
* It uses standard design system colors (--rb-ring) and provides responsive hover/focus states.
*/
export const Link = memo(function Link(props: LinkProps) {
const { children, className, href, ...otherProps } = props;
// We use a specific ref type that useLink expects.
// Since Link can be an 'a' or a 'span', we use HTMLElement as common denominator.
const ref = useRef<HTMLAnchorElement>(null);
const { linkProps } = useLink(otherProps, ref);
const sharedClasses = cn(
'text-[var(--rb-ring)] cursor-pointer hover:underline underline-offset-4 transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
);
if (href) {
return (
<Text
{...linkProps}
ref={ref}
as="a"
className={sharedClasses}
href={href}
>
{children}
</Text>
);
}
return (
<Text
{...linkProps}
ref={castRef<HTMLSpanElement>(ref)}
as="span"
className={sharedClasses}
>
{children}
</Text>
);
});
@@ -12,7 +12,7 @@ import type { Meta, StoryObj } from '@storybook/react';
* through semantic prop names that map to Tailwind CSS utility classes.
*/
const meta: Meta<typeof Stack> = {
title: 'components/shared/Stack',
title: 'components/generic/Stack',
component: Stack,
parameters: {
layout: 'centered',
@@ -1,5 +1,5 @@
import { cn } from '@ermnvldmr/stl';
import React, { memo } from 'react';
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
@@ -44,6 +44,8 @@ export interface StackProps extends ClassNameProps, TestIdProps {
wrap?: FlexWrap;
/** Spacing between items using Tailwind's spacing scale */
gap?: GapScale;
/** The HTML element to use for rendering */
as?: React.ElementType;
}
/**
@@ -69,22 +71,23 @@ export interface StackProps extends ClassNameProps, TestIdProps {
* </Stack>
* ```
*/
export const Stack = memo(function Stack({
const StackComponent = forwardRef<HTMLElement, StackProps>(function Stack({
children,
direction = 'row',
justify = 'start',
align = 'start',
wrap = 'nowrap',
gap = 0,
as: Component = 'div',
className,
'data-testid': testId,
}: StackProps) {
}, ref) {
const directionClasses = {
row: 'flex-row',
col: 'flex-col',
'row-reverse': 'flex-row-reverse',
'col-reverse': 'flex-col-reverse',
};
} as const;
const justifyClasses = {
start: 'justify-start',
@@ -93,7 +96,7 @@ export const Stack = memo(function Stack({
between: 'justify-between',
around: 'justify-around',
evenly: 'justify-evenly',
};
} as const;
const alignClasses = {
start: 'items-start',
@@ -101,13 +104,13 @@ export const Stack = memo(function Stack({
center: 'items-center',
baseline: 'items-baseline',
stretch: 'items-stretch',
};
} as const;
const wrapClasses = {
nowrap: 'flex-nowrap',
wrap: 'flex-wrap',
'wrap-reverse': 'flex-wrap-reverse',
};
} as const;
// Convert gap scale to Tailwind class using explicit mapping
// This ensures all gap classes are detected by Tailwind's static analysis
@@ -127,7 +130,7 @@ export const Stack = memo(function Stack({
8: 'gap-8',
9: 'gap-9',
10: 'gap-10',
11: 'gap-11',
11: 'gap-10',
12: 'gap-12',
14: 'gap-14',
16: 'gap-16',
@@ -161,11 +164,14 @@ export const Stack = memo(function Stack({
);
return (
<div
<Component
ref={castRef<HTMLElement>(ref)}
className={stackClasses}
data-testid={testId}
>
{children}
</div>
</Component>
);
});
export const Stack = genericMemo(StackComponent);
@@ -0,0 +1,113 @@
import React from 'react';
import { Text } from './Text';
import { VStack } from '../VStack/VStack';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Text> = {
title: 'components/generic/Text',
component: Text,
tags: ['autodocs'],
argTypes: {
type: {
control: 'select',
options: ['display', 'headline', 'title', 'body', 'label'],
},
size: {
control: 'select',
options: ['s', 'm', 'l'],
},
color: {
control: 'select',
options: ['default', 'primary', 'secondary', 'tertiary', 'error', 'muted', 'inherit'],
},
},
};
export default meta;
/**
*
*/
type Story = StoryObj<typeof Text>;
export const Default: Story = {
args: {
children: 'The quick brown fox jumps over the lazy dog.',
type: 'body',
size: 'm',
},
};
export const TypeScale: Story = {
render: () => (
<VStack gap={8}>
<div>
<Text className="mb-4 block uppercase tracking-widest" color="muted" size="s" type="label">Display</Text>
<VStack gap={2}>
<Text size="l" type="display">Display Large</Text>
<Text size="m" type="display">Display Medium</Text>
<Text size="s" type="display">Display Small</Text>
</VStack>
</div>
<div>
<Text className="mb-4 block uppercase tracking-widest" color="muted" size="s" type="label">Headline</Text>
<VStack gap={2}>
<Text size="l" type="headline">Headline Large</Text>
<Text size="m" type="headline">Headline Medium</Text>
<Text size="s" type="headline">Headline Small</Text>
</VStack>
</div>
<div>
<Text className="mb-4 block uppercase tracking-widest" color="muted" size="s" type="label">Title</Text>
<VStack gap={2}>
<Text size="l" type="title">Title Large</Text>
<Text size="m" type="title">Title Medium</Text>
<Text size="s" type="title">Title Small</Text>
</VStack>
</div>
<div>
<Text className="mb-4 block uppercase tracking-widest" color="muted" size="s" type="label">Body</Text>
<VStack gap={2}>
<Text size="l" type="body">Body Large</Text>
<Text size="m" type="body">Body Medium</Text>
<Text size="s" type="body">Body Small</Text>
</VStack>
</div>
<div>
<Text className="mb-4 block uppercase tracking-widest" color="muted" size="s" type="label">Label</Text>
<VStack gap={2}>
<Text size="l" type="label">Label Large</Text>
<Text size="m" type="label">Label Medium</Text>
<Text size="s" type="label">Label Small</Text>
</VStack>
</div>
</VStack>
),
};
export const Colors: Story = {
render: () => (
<VStack gap={2}>
<Text color="default">Default Text Color</Text>
<Text color="primary">Primary Text Color</Text>
<Text color="secondary">Secondary Text Color</Text>
<Text color="tertiary">Tertiary Text Color</Text>
<Text color="error">Error Text Color</Text>
<Text color="muted">Muted Text Color</Text>
</VStack>
),
};
export const Modifiers: Story = {
render: () => (
<VStack gap={2}>
<Text bold>Bold Text</Text>
<Text italic>Italic Text</Text>
<Text underline>Underlined Text</Text>
<Text strike>Strikethrough Text</Text>
<Text bold italic underline>Combined Modifiers</Text>
</VStack>
),
};
@@ -0,0 +1,127 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
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';
/**
* Props for the Text component.
*/
export interface TextProps extends ClassNameProps, TestIdProps, Omit<React.HTMLAttributes<HTMLElement>, 'as'> {
/** Content to be rendered */
children: React.ReactNode;
/** Typography style type */
type?: TextType;
/** Typography size variant */
size?: TextSize;
/** Text color variant */
color?: TextColor;
/** Whether the text should be bold */
bold?: boolean;
/** Whether the text should be italic */
italic?: boolean;
/** Whether the text should be underlined */
underline?: boolean;
/** Whether the text should have a strike-through */
strike?: boolean;
/** The HTML element to use for rendering */
as?: React.ElementType;
/** Optional href for links */
href?: string;
}
/**
* 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.
*/
const TextComponent = forwardRef<HTMLElement, TextProps>(function Text({
children,
type = 'body',
size = 'm',
color = 'default',
bold = false,
italic = false,
underline = false,
strike = false,
as: Component = 'span',
className,
'data-testid': testId,
...props
}, ref) {
// 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',
};
return (
<Component
{...props}
ref={castRef<HTMLElement>(ref)}
className={cn(
typeStyles[type][size],
colorClasses[color],
bold && 'font-bold',
italic && 'italic',
underline && 'underline',
strike && 'line-through',
className
)}
data-testid={testId}
>
{children}
</Component>
);
});
export const Text = genericMemo(TextComponent);
@@ -1,4 +1,4 @@
import React, { memo } from 'react';
import React, { memo, forwardRef } from 'react';
import { Stack } from '../Stack/Stack';
@@ -37,6 +37,6 @@ export interface VStackProps extends Omit<StackProps, 'direction'> {
* </VStack>
* ```
*/
export const VStack = memo(function VStack(props: VStackProps) {
return <Stack direction="col" {...props} />;
});
export const VStack = memo(forwardRef<HTMLElement, VStackProps>(function VStack(props, ref) {
return <Stack direction="col" {...props} ref={ref} />;
}));
+13 -2
View File
@@ -1,2 +1,13 @@
export { HStack } from './HStack/HStack';
export { VStack } from './VStack/VStack';
export * from './Container/Container';
export * from './Header/Header';
export * from './HStack/HStack';
export * from './Stack/Stack';
export * from './Text/Text';
export * from './VStack/VStack';
export * from './Link/Link';
export * from './Breadcrumbs/Breadcrumbs';
export type { StackProps } from './Stack/Stack';
export type { ContainerProps, ContainerBackground, ContainerMaxWidth, ContainerRounded } from './Container/Container';
export type { TextProps, TextType, TextSize, TextColor } from './Text/Text';
export type { HeaderProps } from './Header/Header';
@@ -0,0 +1,42 @@
import { PaperArticle } from './PaperArticle';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof PaperArticle> = {
title: 'Components/Paper/PaperArticle',
component: PaperArticle,
tags: ['autodocs'],
argTypes: {
onPress: { action: 'pressed' },
},
};
export default meta;
/**
*
*/
type Story = StoryObj<typeof PaperArticle>;
export const Default: Story = {
args: {
headline: 'AI Agent Learns to Code',
children: 'In a shocking turn of events, a large language model has successfully implemented a UI component from scratch while maintaining design system consistency.',
},
};
export const Full: Story = {
args: {
headline: 'The Future of Web Development',
subHeadline: 'Design Systems and AI Collaboration',
additionalText: 'By AI Reporter #42',
children: 'Newspaper-style layouts are making a comeback in modern web design. By combining classical typography principles with modern responsive frameworks, developers can create unique and engaging user experiences.',
},
};
export const Interactive: Story = {
args: {
...Full.args,
onPress: () => alert('Article clicked!'),
},
};
@@ -0,0 +1,31 @@
import React from 'react';
import { PaperArticle } from './PaperArticle';
import { render, screen } from '../../../test-utils';
describe('components/paper/PaperArticle', () => {
const props = {
headline: 'Breaking News',
subHeadline: 'Local cat saves city',
additionalText: 'Reporting by John Doe',
};
it('renders all parts of the article', () => {
render(<PaperArticle {...props}>Article body content</PaperArticle>);
expect(screen.getByText('Breaking News')).toBeInTheDocument();
expect(screen.getByText('Local cat saves city')).toBeInTheDocument();
expect(screen.getByText('Article body content')).toBeInTheDocument();
expect(screen.getByText('Reporting by John Doe')).toBeInTheDocument();
});
it('renders separators only when appropriate', () => {
const { rerender } = render(<PaperArticle headline="Title">Body</PaperArticle>);
// Should have 1 separator (between title and body)
expect(screen.getAllByRole('separator')).toHaveLength(1);
rerender(<PaperArticle headline="Title" subHeadline="Sub">Body</PaperArticle>);
// Should have 2 separators (Title-Sub and Sub-Body)
expect(screen.getAllByRole('separator')).toHaveLength(2);
});
});
@@ -0,0 +1,78 @@
import React, { memo } from 'react';
import { Container } from '../../generic/Container/Container';
import { Header } from '../../generic/Header/Header';
import { Text } from '../../generic/Text/Text';
import { VStack } from '../../generic/VStack/VStack';
import { PaperSeparator } from '../PaperSeparator/PaperSeparator';
import type { ContainerProps } from '../../generic/Container/Container';
/**
* Props for the newspaper-style PaperArticle component.
*/
export interface PaperArticleProps {
/** The main article headline */
headline: string;
/** Optional sub-headline displayed between separators */
subHeadline?: string;
/** Optional additional text (e.g., author) displayed at the bottom in gray italic label */
additionalText?: string;
/** Press interaction handler propagated to the wrapping Container */
onPress?: ContainerProps['onPress'];
/** The main article body content */
children: React.ReactNode;
}
/**
* A newspaper-style article component with a structured layout.
*
* Features:
* - Centered medium headline (headline-m)
* - Single-line separators
* - Optional centered italic sub-headline (label-m)
* - Main body text (body-m)
* - Optional bottom gray italic label (label-s)
* - Interactive wrapper via Container
*/
export const PaperArticle = memo(function PaperArticle({
headline,
subHeadline,
additionalText,
onPress,
children,
}: PaperArticleProps) {
return (
<Container padding={4} onPress={onPress}>
<VStack align="stretch" gap={4}>
<Header className="text-center" level={4}>
{headline}
</Header>
<VStack align="center" gap={2}>
{subHeadline ? (
<>
<PaperSeparator className="w-3/4" type="single" />
<Text italic className="text-center" size="m" type="label">
{subHeadline}
</Text>
<PaperSeparator className="w-3/4" type="single" />
</>
) : <PaperSeparator className="w-3/4" type="single" />}
</VStack>
<Text size="m" type="body">
{children}
</Text>
{additionalText && (
<VStack align='end'>
<Text italic color="muted" size="s" type="label">
{additionalText}
</Text>
</VStack>
)}
</VStack>
</Container>
);
});
+3 -3
View File
@@ -1,3 +1,3 @@
export { PaperButton } from './PaperButton/PaperButton';
export { PaperSeparator } from './PaperSeparator/PaperSeparator'
export * from './PaperButton/PaperButton';
export * from './PaperSeparator/PaperSeparator';
export * from './PaperArticle/PaperArticle';
+177 -182
View File
@@ -1,3 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400..800;1,400..800&family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap');
@import 'tailwindcss';
@import 'tw-animate-css';
@@ -63,200 +64,188 @@
}
@theme {
--color-rainby-red-50: #fef4f2;
--color-rainby-red-100: #ffe7e1;
--color-rainby-red-200: #ffd2c8;
--color-rainby-red-300: #ffb4a3;
--color-rainby-red-400: #fd876c;
--color-rainby-red-500: #f5603e;
--color-rainby-red-600: #e24420;
--color-rainby-red-700: #be3617;
--color-rainby-red-800: #9d3017;
--color-rainby-red-900: #822d1a;
--color-rainby-red-950: #471408;
--rb-color-red-50: #fef4f2;
--rb-color-red-100: #ffe7e1;
--rb-color-red-200: #ffd2c8;
--rb-color-red-300: #ffb4a3;
--rb-color-red-400: #fd876c;
--rb-color-red-500: #f5603e;
--rb-color-red-600: #e24420;
--rb-color-red-700: #be3617;
--rb-color-red-800: #9d3017;
--rb-color-red-900: #822d1a;
--rb-color-red-950: #471408;
--color-rainby-orange-50: #fff8ed;
--color-rainby-orange-100: #ffefd5;
--color-rainby-orange-200: #fedbaa;
--color-rainby-orange-300: #fdc175;
--color-rainby-orange-400: #fb9b3c;
--color-rainby-orange-500: #f97e16;
--color-rainby-orange-600: #ea630c;
--color-rainby-orange-700: #c24a0c;
--color-rainby-orange-800: #9a3b12;
--color-rainby-orange-900: #7c3212;
--color-rainby-orange-950: #431707;
--rb-color-orange-50: #fff8ed;
--rb-color-orange-100: #ffefd5;
--rb-color-orange-200: #fedbaa;
--rb-color-orange-300: #fdc175;
--rb-color-orange-400: #fb9b3c;
--rb-color-orange-500: #f97e16;
--rb-color-orange-600: #ea630c;
--rb-color-orange-700: #c24a0c;
--rb-color-orange-800: #9a3b12;
--rb-color-orange-900: #7c3212;
--rb-color-orange-950: #431707;
--color-rainby-yellow-50: #fefde8;
--color-rainby-yellow-100: #fefdc3;
--color-rainby-yellow-200: #fef9a3;
--color-rainby-yellow-300: #fdec47;
--color-rainby-yellow-400: #f9db16;
--color-rainby-yellow-500: #e9c209;
--color-rainby-yellow-600: #ca9704;
--color-rainby-yellow-700: #a16c07;
--color-rainby-yellow-800: #85550e;
--color-rainby-yellow-900: #714512;
--color-rainby-yellow-950: #422406;
--rb-color-yellow-50: #fefde8;
--rb-color-yellow-100: #fefdc3;
--rb-color-yellow-200: #fef9a3;
--rb-color-yellow-300: #fdec47;
--rb-color-yellow-400: #f9db16;
--rb-color-yellow-500: #e9c209;
--rb-color-yellow-600: #ca9704;
--rb-color-yellow-700: #a16c07;
--rb-color-yellow-800: #85550e;
--rb-color-yellow-900: #714512;
--rb-color-yellow-950: #422406;
--color-rainby-green-50: #f8faeb;
--color-rainby-green-100: #edf4d3;
--color-rainby-green-200: #ddebab;
--color-rainby-green-300: #c4dc7a;
--color-rainby-green-400: #b6d167;
--color-rainby-green-500: #8daf33;
--color-rainby-green-600: #6e8b25;
--color-rainby-green-700: #546b20;
--color-rainby-green-800: #44551f;
--color-rainby-green-900: #3a491e;
--color-rainby-green-950: #1e280b;
--rb-color-green-50: #f8faeb;
--rb-color-green-100: #edf4d3;
--rb-color-green-200: #ddebab;
--rb-color-green-300: #c4dc7a;
--rb-color-green-400: #b6d167;
--rb-color-green-500: #8daf33;
--rb-color-green-600: #6e8b25;
--rb-color-green-700: #546b20;
--rb-color-green-800: #44551f;
--rb-color-green-900: #3a491e;
--rb-color-green-950: #1e280b;
--color-rainby-cyan-50: #effbfc;
--color-rainby-cyan-100: #d7f2f6;
--color-rainby-cyan-200: #b4e6ed;
--color-rainby-cyan-300: #81d3e0;
--color-rainby-cyan-400: #45b6cb;
--color-rainby-cyan-500: #2a9ab0;
--color-rainby-cyan-600: #257d95;
--color-rainby-cyan-700: #24657a;
--color-rainby-cyan-800: #265464;
--color-rainby-cyan-900: #234756;
--color-rainby-cyan-950: #122e3a;
--rb-color-cyan-50: #effbfc;
--rb-color-cyan-100: #d7f2f6;
--rb-color-cyan-200: #b4e6ed;
--rb-color-cyan-300: #81d3e0;
--rb-color-cyan-400: #45b6cb;
--rb-color-cyan-500: #2a9ab0;
--rb-color-cyan-600: #257d95;
--rb-color-cyan-700: #24657a;
--rb-color-cyan-800: #265464;
--rb-color-cyan-900: #234756;
--rb-color-cyan-950: #122e3a;
--color-rainby-blue-50: #eff5ff;
--color-rainby-blue-100: #dbe8fe;
--color-rainby-blue-200: #bfd8fe;
--color-rainby-blue-300: #a2c9fe;
--color-rainby-blue-400: #5f9efb;
--color-rainby-blue-500: #3a79f7;
--color-rainby-blue-600: #2358ed;
--color-rainby-blue-700: #1c44d9;
--color-rainby-blue-800: #1d38b0;
--color-rainby-blue-900: #1d358b;
--color-rainby-blue-950: #172254;
--rb-color-blue-50: #eff5ff;
--rb-color-blue-100: #dbe8fe;
--rb-color-blue-200: #bfd8fe;
--rb-color-blue-300: #a2c9fe;
--rb-color-blue-400: #5f9efb;
--rb-color-blue-500: #3a79f7;
--rb-color-blue-600: #2358ed;
--rb-color-blue-700: #1c44d9;
--rb-color-blue-800: #1d38b0;
--rb-color-blue-900: #1d358b;
--rb-color-blue-950: #172254;
--color-rainby-violet-50: #eef0ff;
--color-rainby-violet-100: #dfe3ff;
--color-rainby-violet-200: #bdc2ff;
--color-rainby-violet-300: #a3a8fe;
--color-rainby-violet-400: #837ffa;
--color-rainby-violet-500: #6e60f4;
--color-rainby-violet-600: #6043e8;
--color-rainby-violet-700: #5435cd;
--color-rainby-violet-800: #442ea5;
--color-rainby-violet-900: #3b2c83;
--color-rainby-violet-950: #231a4c;
--rb-color-violet-50: #eef0ff;
--rb-color-violet-100: #dfe3ff;
--rb-color-violet-200: #bdc2ff;
--rb-color-violet-300: #a3a8fe;
--rb-color-violet-400: #837ffa;
--rb-color-violet-500: #6e60f4;
--rb-color-violet-600: #6043e8;
--rb-color-violet-700: #5435cd;
--rb-color-violet-800: #442ea5;
--rb-color-violet-900: #3b2c83;
--rb-color-violet-950: #231a4c;
--color-rainby-neutral-50: #f6f6f6;
--color-rainby-neutral-100: #e7e7e7;
--color-rainby-neutral-200: #d1d1d1;
--color-rainby-neutral-300: #b0b0b0;
--color-rainby-neutral-400: #888888;
--color-rainby-neutral-500: #737373;
--color-rainby-neutral-600: #5d5d5d;
--color-rainby-neutral-700: #4f4f4f;
--color-rainby-neutral-800: #454545;
--color-rainby-neutral-900: #3d3d3d;
--color-rainby-neutral-950: #262626;
--rb-color-neutral-50: #f6f6f6;
--rb-color-neutral-100: #e7e7e7;
--rb-color-neutral-200: #d1d1d1;
--rb-color-neutral-300: #b0b0b0;
--rb-color-neutral-400: #888888;
--rb-color-neutral-500: #737373;
--rb-color-neutral-600: #5d5d5d;
--rb-color-neutral-700: #4f4f4f;
--rb-color-neutral-800: #454545;
--rb-color-neutral-900: #3d3d3d;
--rb-color-neutral-950: #262626;
}
:root {
/* Core palette */
--background: var(--color-rainby-neutral-50);
--border: var(--color-rainby-neutral-500);
--input: var(--color-rainby-neutral-800);
--foreground: var(--color-rainby-neutral-950);
--ring: var(--color-rainby-cyan-700);
--rb-base: var(--rb-color-neutral-50);
--rb-outline: var(--rb-color-neutral-500);
--rb-input: var(--rb-color-neutral-800);
--rb-text: var(--rb-color-neutral-950);
--rb-ring: var(--rb-color-cyan-700);
/* Surfaces */
--card: var(--color-rainby-neutral-50);
--card-foreground: var(--color-rainby-neutral-950);
--popover: var(--color-rainby-neutral-50);
--popover-foreground: var(--color-rainby-neutral-950);
/* Container */
--rb-container-base: var(--rb-color-neutral-50);
--rb-container-text: var(--rb-color-neutral-950);
/* Semantic */
--primary: var(--color-rainby-cyan-200);
--primary-foreground: var(--color-rainby-cyan-950);
--secondary: var(--color-rainby-green-200);
--secondary-foreground: var(--color-rainby-green-950);
--muted: var(--color-rainby-neutral-300);
--muted-foreground: var(--color-rainby-neutral-600);
--accent: var(--color-rainby-violet-200);
--accent-foreground: var(--color-rainby-violet-950);
--destructive: var(--color-rainby-red-200);
--destructive-foreground: var(--color-rainby-red-950);
--rb-primary-base: var(--rb-color-cyan-200);
--rb-primary-text: var(--rb-color-cyan-950);
--rb-secondary-base: var(--rb-color-green-200);
--rb-secondary-text: var(--rb-color-green-950);
--rb-tertiary-base: var(--rb-color-violet-200);
--rb-tertiary-text: var(--rb-color-violet-950);
--rb-error-base: var(--rb-color-red-200);
--rb-error-text: var(--rb-color-red-950);
--rb-muted-base: var(--rb-color-neutral-300);
--rb-muted-text: var(--rb-color-neutral-600);
/* Charts (e.g. Recharts in ShadCN examples) */
--chart-1: var(--color-rainby-blue-500);
--chart-2: var(--color-rainby-green-500);
--chart-3: var(--color-rainby-yellow-500);
--chart-4: var(--color-rainby-orange-500);
--chart-5: var(--color-rainby-red-500);
/* Charts */
--chart-1: var(--rb-color-blue-500);
--chart-2: var(--rb-color-green-500);
--chart-3: var(--rb-color-yellow-500);
--chart-4: var(--rb-color-orange-500);
--chart-5: var(--rb-color-red-500);
/* Sidebar (if you use ShadCN’s sidebar components) */
--sidebar: var(--color-rainby-neutral-50);
--sidebar-foreground: var(--color-rainby-neutral-950);
--sidebar-primary: var(--color-rainby-cyan-200);
--sidebar-primary-foreground: var(--color-rainby-cyan-950);
--sidebar-accent: var(--color-rainby-violet-200);
--sidebar-accent-foreground: var(--color-rainby-violet-950);
--sidebar-border: var(--color-rainby-neutral-500);
--sidebar-ring: var(--color-rainby-cyan-700);
/* Sidebar */
--sidebar: var(--rb-color-neutral-50);
--sidebar-foreground: var(--rb-color-neutral-950);
--sidebar-primary: var(--rb-color-cyan-200);
--sidebar-primary-foreground: var(--rb-color-cyan-950);
--sidebar-accent: var(--rb-color-violet-200);
--sidebar-accent-foreground: var(--rb-color-violet-950);
--sidebar-border: var(--rb-color-neutral-500);
--sidebar-ring: var(--rb-color-cyan-700);
/* Radii */
--radius: 0.625rem;
}
/* ──────────────── Dark Mode Override ──────────────── */
.dark {
/* Core palette flips */
--background: var(--color-rainby-neutral-950);
--ring: var(--color-rainby-cyan-300);
--border: var(--color-rainby-neutral-500);
--input: var(--color-rainby-neutral-300);
--foreground: var(--color-rainby-neutral-50);
--rb-base: var(--rb-color-neutral-950);
--rb-text: var(--rb-color-neutral-50);
--rb-outline: var(--rb-color-neutral-500);
--rb-input: var(--rb-color-neutral-300);
--rb-ring: var(--rb-color-cyan-300);
/* Surfaces */
--card: var(--color-rainby-neutral-950);
--card-foreground: var(--color-rainby-neutral-50);
--popover: var(--color-rainby-neutral-950);
--popover-foreground: var(--color-rainby-neutral-50);
/* Container */
--rb-container-base: var(--rb-color-neutral-950);
--rb-container-text: var(--rb-color-neutral-50);
/* Semantic */
--primary: var(--color-rainby-cyan-800);
--primary-foreground: var(--color-rainby-cyan-50);
--secondary: var(--color-rainby-green-800);
--secondary-foreground: var(--color-rainby-green-50);
--muted: var(--color-rainby-neutral-600);
--muted-foreground: var(--color-rainby-neutral-100);
--accent: var(--color-rainby-violet-800);
--accent-foreground: var(--color-rainby-violet-50);
--destructive: var(--color-rainby-red-800);
--destructive-foreground: var(--color-rainby-red-50);
--rb-primary-base: var(--rb-color-cyan-800);
--rb-primary-text: var(--rb-color-cyan-50);
--rb-secondary-base: var(--rb-color-green-800);
--rb-secondary-text: var(--rb-color-green-50);
--rb-tertiary-base: var(--rb-color-violet-800);
--rb-tertiary-text: var(--rb-color-violet-50);
--rb-error-base: var(--rb-color-red-800);
--rb-error-text: var(--rb-color-red-50);
--rb-muted-base: var(--rb-color-neutral-600);
--rb-muted-text: var(--rb-color-neutral-100);
/* Charts */
--chart-1: var(--color-rainby-blue-400);
--chart-2: var(--color-rainby-green-400);
--chart-3: var(--color-rainby-yellow-400);
--chart-4: var(--color-rainby-orange-400);
--chart-5: var(--color-rainby-red-400);
--chart-1: var(--rb-color-blue-400);
--chart-2: var(--rb-color-green-400);
--chart-3: var(--rb-color-yellow-400);
--chart-4: var(--rb-color-orange-400);
--chart-5: var(--rb-color-red-400);
/* Sidebar */
--sidebar: var(--color-rainby-neutral-950);
--sidebar-foreground: var(--color-rainby-neutral-50);
--sidebar-primary: var(--color-rainby-cyan-800);
--sidebar-primary-foreground: var(--color-rainby-cyan-50);
--sidebar-accent: var(--color-rainby-violet-800);
--sidebar-accent-foreground: var(--color-rainby-violet-50);
--sidebar-border: var(--color-rainby-neutral-500);
--sidebar-ring: var(--color-rainby-cyan-700);
/* Radii stays same */
--radius: 0.625rem;
}
@utility frosted-glass {
@apply bg-background/30 rounded-lg shadow-lg backdrop-blur-md backdrop-opacity-75 backdrop-filter;
--sidebar: var(--rb-color-neutral-950);
--sidebar-foreground: var(--rb-color-neutral-50);
--sidebar-primary: var(--rb-color-cyan-800);
--sidebar-primary-foreground: var(--rb-color-cyan-50);
--sidebar-accent: var(--rb-color-violet-800);
--sidebar-accent-foreground: var(--rb-color-violet-50);
--sidebar-border: var(--rb-color-neutral-500);
--sidebar-ring: var(--rb-color-cyan-700);
}
@utility vstack {
@@ -272,29 +261,35 @@
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-background: var(--rb-base);
--color-foreground: var(--rb-text);
--color-card: var(--rb-container-base);
--color-card-foreground: var(--rb-container-text);
--color-popover: var(--rb-container-base);
--color-popover-foreground: var(--rb-container-text);
--color-primary: var(--rb-primary-base);
--color-primary-foreground: var(--rb-primary-text);
--color-secondary: var(--rb-secondary-base);
--color-secondary-foreground: var(--rb-secondary-text);
--color-accent: var(--rb-tertiary-base);
--color-accent-foreground: var(--rb-tertiary-text);
--color-destructive: var(--rb-error-base);
--color-destructive-foreground: var(--rb-error-text);
--color-muted: var(--rb-muted-base);
--color-muted-foreground: var(--rb-muted-text);
--color-border: var(--rb-outline);
--color-input: var(--rb-input);
--color-ring: var(--rb-ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
+7 -1
View File
@@ -1,7 +1,13 @@
import { createLibConfig } from '@ermnvldmr/vite-config/lib';
import tailwindcss from '@tailwindcss/vite';
import { mergeConfig } from 'vite';
export default createLibConfig('ErmnvldmrUI', 'src/index.ts', ['@ermnvldmr/stl'], {
const baseConfig = createLibConfig('ErmnvldmrUI', 'src/index.ts', ['@ermnvldmr/stl'], {
dts: {
exclude: ['**/*.test.ts', '**/*.test.tsx', '**/*.stories.tsx', 'src/setupTests.ts', 'src/test-utils.tsx'],
},
});
export default mergeConfig(baseConfig, {
plugins: [tailwindcss()],
});
+44 -24
View File
@@ -83,13 +83,13 @@ importers:
version: 30.0.0
jest:
specifier: ^30.2.0
version: 30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2))
version: 30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3))
jest-environment-jsdom:
specifier: ^30.2.0
version: 30.2.0
ts-jest:
specifier: ^29.4.6
version: 29.4.6(@babel/core@7.28.6)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.6))(esbuild@0.27.2)(jest-util@30.2.0)(jest@30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2)))(typescript@5.8.3)
version: 29.4.6(@babel/core@7.28.6)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.6))(esbuild@0.27.2)(jest-util@30.2.0)(jest@30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3)))(typescript@5.8.3)
devDependencies:
typescript:
specifier: ^5.8.3
@@ -116,12 +116,24 @@ importers:
'@ermnvldmr/vite-config':
specifier: workspace:*
version: link:../vite-config
'@types/react':
specifier: ^19.1.2
version: 19.2.9
'@types/react-dom':
specifier: ^19.1.2
version: 19.2.3(@types/react@19.2.9)
jest:
specifier: ^30.2.0
version: 30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2))
version: 30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3))
react:
specifier: ^19.1.0
version: 19.2.3
react-dom:
specifier: ^19.1.0
version: 19.2.3(react@19.2.3)
ts-jest:
specifier: ^29.4.6
version: 29.4.6(@babel/core@7.28.6)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.6))(esbuild@0.27.2)(jest-util@30.2.0)(jest@30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2)))(typescript@5.8.3)
version: 29.4.6(@babel/core@7.28.6)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.6))(esbuild@0.27.2)(jest-util@30.2.0)(jest@30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3)))(typescript@5.8.3)
typescript:
specifier: ^5.8.3
version: 5.8.3
@@ -279,7 +291,7 @@ importers:
version: 14.1.1
jest:
specifier: ^30.2.0
version: 30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2))
version: 30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.25.12))(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3))
jest-environment-jsdom:
specifier: ^30.2.0
version: 30.2.0
@@ -291,7 +303,7 @@ importers:
version: 8.6.15(prettier@3.5.3)
ts-jest:
specifier: ^29.4.6
version: 29.4.6(@babel/core@7.28.6)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.6))(esbuild@0.27.2)(jest-util@30.2.0)(jest@30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2)))(typescript@5.8.3)
version: 29.4.6(@babel/core@7.28.6)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.6))(esbuild@0.25.12)(jest-util@30.2.0)(jest@30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.25.12))(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3)))(typescript@5.8.3)
typescript:
specifier: ^5.8.3
version: 5.8.3
@@ -356,7 +368,7 @@ importers:
version: link:../../packages/eslint-config
'@tailwindcss/vite':
specifier: ^4.1.4
version: 4.1.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2))
version: 4.1.18(vite@6.4.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2))
eslint:
specifier: ^9.25.1
version: 9.39.2(jiti@2.6.1)
@@ -480,7 +492,7 @@ importers:
version: 14.1.1
jest:
specifier: ^30.2.0
version: 30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.25.12))(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3))
version: 30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3))
jest-environment-jsdom:
specifier: ^30.2.0
version: 30.2.0
@@ -501,7 +513,7 @@ importers:
version: 8.6.15(prettier@3.5.3)
ts-jest:
specifier: ^29.4.6
version: 29.4.6(@babel/core@7.28.6)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.6))(esbuild@0.25.12)(jest-util@30.2.0)(jest@30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.25.12))(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3)))(typescript@5.8.3)
version: 29.4.6(@babel/core@7.28.6)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.6))(esbuild@0.27.2)(jest-util@30.2.0)(jest@30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3)))(typescript@5.8.3)
ts-node:
specifier: ^10.9.2
version: 10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3)
@@ -8605,7 +8617,7 @@ snapshots:
- supports-color
- ts-node
'@jest/core@30.2.0(esbuild-register@3.6.0(esbuild@0.27.2))':
'@jest/core@30.2.0(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3))':
dependencies:
'@jest/console': 30.2.0
'@jest/pattern': 30.0.1
@@ -8620,7 +8632,7 @@ snapshots:
exit-x: 0.2.2
graceful-fs: 4.2.11
jest-changed-files: 30.2.0
jest-config: 30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2))
jest-config: 30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3))
jest-haste-map: 30.2.0
jest-message-util: 30.2.0
jest-regex-util: 30.0.1
@@ -10754,10 +10766,10 @@ snapshots:
jest-circus: 29.7.0
jest-environment-node: 29.7.0
jest-junit: 16.0.0
jest-playwright-preset: 4.0.0(jest-circus@29.7.0)(jest-environment-node@29.7.0)(jest-runner@29.7.0)(jest@29.7.0(@types/node@25.0.10))
jest-playwright-preset: 4.0.0(jest-circus@29.7.0)(jest-environment-node@29.7.0)(jest-runner@29.7.0)(jest@29.7.0(@types/node@25.0.10)(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3)))
jest-runner: 29.7.0
jest-serializer-html: 7.1.0
jest-watch-typeahead: 2.2.2(jest@29.7.0(@types/node@25.0.10))
jest-watch-typeahead: 2.2.2(jest@29.7.0(@types/node@25.0.10)(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3)))
nyc: 15.1.0
playwright: 1.58.0
transitivePeerDependencies:
@@ -10910,6 +10922,13 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.18
'@tailwindcss/oxide-win32-x64-msvc': 4.1.18
'@tailwindcss/vite@4.1.18(vite@6.4.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2))':
dependencies:
'@tailwindcss/node': 4.1.18
'@tailwindcss/oxide': 4.1.18
tailwindcss: 4.1.18
vite: 6.4.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)
'@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2))':
dependencies:
'@tailwindcss/node': 4.1.18
@@ -13672,15 +13691,15 @@ snapshots:
- supports-color
- ts-node
jest-cli@30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2)):
jest-cli@30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3)):
dependencies:
'@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.2))
'@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3))
'@jest/test-result': 30.2.0
'@jest/types': 30.2.0
chalk: 4.1.2
exit-x: 0.2.2
import-local: 3.2.0
jest-config: 30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2))
jest-config: 30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3))
jest-util: 30.2.0
jest-validate: 30.2.0
yargs: 17.7.2
@@ -13756,7 +13775,7 @@ snapshots:
- babel-plugin-macros
- supports-color
jest-config@30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2)):
jest-config@30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3)):
dependencies:
'@babel/core': 7.28.6
'@jest/get-type': 30.1.0
@@ -13785,6 +13804,7 @@ snapshots:
optionalDependencies:
'@types/node': 25.0.10
esbuild-register: 3.6.0(esbuild@0.27.2)
ts-node: 10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
@@ -13958,7 +13978,7 @@ snapshots:
'@types/node': 25.0.10
jest-util: 30.2.0
jest-playwright-preset@4.0.0(jest-circus@29.7.0)(jest-environment-node@29.7.0)(jest-runner@29.7.0)(jest@29.7.0(@types/node@25.0.10)):
jest-playwright-preset@4.0.0(jest-circus@29.7.0)(jest-environment-node@29.7.0)(jest-runner@29.7.0)(jest@29.7.0(@types/node@25.0.10)(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3))):
dependencies:
expect-playwright: 0.8.0
jest: 29.7.0(@types/node@25.0.10)(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3))
@@ -14237,7 +14257,7 @@ snapshots:
leven: 3.1.0
pretty-format: 30.2.0
jest-watch-typeahead@2.2.2(jest@29.7.0(@types/node@25.0.10)):
jest-watch-typeahead@2.2.2(jest@29.7.0(@types/node@25.0.10)(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3))):
dependencies:
ansi-escapes: 6.2.1
chalk: 5.6.2
@@ -14310,12 +14330,12 @@ snapshots:
- supports-color
- ts-node
jest@30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2)):
jest@30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3)):
dependencies:
'@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.2))
'@jest/core': 30.2.0(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3))
'@jest/types': 30.2.0
import-local: 3.2.0
jest-cli: 30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2))
jest-cli: 30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3))
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@@ -16356,12 +16376,12 @@ snapshots:
esbuild: 0.25.12
jest-util: 30.2.0
ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.6))(esbuild@0.27.2)(jest-util@30.2.0)(jest@30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2)))(typescript@5.8.3):
ts-jest@29.4.6(@babel/core@7.28.6)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.6))(esbuild@0.27.2)(jest-util@30.2.0)(jest@30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3)))(typescript@5.8.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
handlebars: 4.7.8
jest: 30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2))
jest: 30.2.0(@types/node@25.0.10)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@25.0.10)(typescript@5.8.3))
json5: 2.2.3
lodash.memoize: 4.1.2
make-error: 1.3.6
@@ -42,13 +42,6 @@ const { title, description } = Astro.props;
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href="https://www.ermnvldmr.com/" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400..800;1,400..800&family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap"
rel="stylesheet"
/>
</head>
<body>
<slot />