From 054cf4f5e3f6201a4cac8e1c0187f312c099a143 Mon Sep 17 00:00:00 2001 From: Vladimir Eremin Date: Sat, 31 Jan 2026 17:52:45 +0300 Subject: [PATCH] feat: Add generic components for UI --- ...26-01-31-paper-article-component-design.md | 46 +++ packages/eslint-config/index.js | 8 + packages/stl/package.json | 6 + packages/stl/src/functions/index.ts | 2 +- packages/stl/src/index.ts | 1 + .../stl/src/react/castRef/castRef.test.ts | 11 + packages/stl/src/react/castRef/castRef.ts | 19 + .../react/genericMemo/genericMemo.test.tsx | 14 + .../stl/src/react/genericMemo/genericMemo.ts | 16 + packages/stl/src/react/index.ts | 10 + packages/ui/.storybook/main.ts | 2 + packages/ui/jest.config.mjs | 8 +- .../Breadcrumbs/Breadcrumbs.stories.tsx | 66 ++++ .../generic/Breadcrumbs/Breadcrumbs.test.tsx | 40 ++ .../generic/Breadcrumbs/Breadcrumbs.tsx | 102 +++++ .../generic/Container/Container.stories.tsx | 133 +++++++ .../generic/Container/Container.test.tsx | 28 ++ .../generic/Container/Container.tsx | 157 ++++++++ .../src/components/generic/HStack/HStack.tsx | 8 +- .../generic/Header/Header.stories.tsx | 59 +++ .../src/components/generic/Header/Header.tsx | 60 +++ .../components/generic/Link/Link.stories.tsx | 33 ++ .../src/components/generic/Link/Link.test.tsx | 18 + .../ui/src/components/generic/Link/Link.tsx | 64 ++++ .../generic/Stack/Stack.stories.tsx | 2 +- .../ui/src/components/generic/Stack/Stack.tsx | 28 +- .../components/generic/Text/Text.stories.tsx | 113 ++++++ .../ui/src/components/generic/Text/Text.tsx | 127 +++++++ .../src/components/generic/VStack/VStack.tsx | 8 +- packages/ui/src/components/generic/index.ts | 15 +- .../PaperArticle/PaperArticle.stories.tsx | 42 ++ .../paper/PaperArticle/PaperArticle.test.tsx | 31 ++ .../paper/PaperArticle/PaperArticle.tsx | 78 ++++ packages/ui/src/components/paper/index.ts | 6 +- packages/ui/src/styles/index.css | 359 +++++++++--------- packages/ui/vite.config.ts | 8 +- pnpm-lock.yaml | 68 ++-- .../src/layouts/IndexLayout/IndexLayout.astro | 7 - 38 files changed, 1562 insertions(+), 241 deletions(-) create mode 100644 docs/plans/2026-01-31-paper-article-component-design.md create mode 100644 packages/stl/src/react/castRef/castRef.test.ts create mode 100644 packages/stl/src/react/castRef/castRef.ts create mode 100644 packages/stl/src/react/genericMemo/genericMemo.test.tsx create mode 100644 packages/stl/src/react/genericMemo/genericMemo.ts create mode 100644 packages/stl/src/react/index.ts create mode 100644 packages/ui/src/components/generic/Breadcrumbs/Breadcrumbs.stories.tsx create mode 100644 packages/ui/src/components/generic/Breadcrumbs/Breadcrumbs.test.tsx create mode 100644 packages/ui/src/components/generic/Breadcrumbs/Breadcrumbs.tsx create mode 100644 packages/ui/src/components/generic/Container/Container.stories.tsx create mode 100644 packages/ui/src/components/generic/Container/Container.test.tsx create mode 100644 packages/ui/src/components/generic/Container/Container.tsx create mode 100644 packages/ui/src/components/generic/Header/Header.stories.tsx create mode 100644 packages/ui/src/components/generic/Header/Header.tsx create mode 100644 packages/ui/src/components/generic/Link/Link.stories.tsx create mode 100644 packages/ui/src/components/generic/Link/Link.test.tsx create mode 100644 packages/ui/src/components/generic/Link/Link.tsx create mode 100644 packages/ui/src/components/generic/Text/Text.stories.tsx create mode 100644 packages/ui/src/components/generic/Text/Text.tsx create mode 100644 packages/ui/src/components/paper/PaperArticle/PaperArticle.stories.tsx create mode 100644 packages/ui/src/components/paper/PaperArticle/PaperArticle.test.tsx create mode 100644 packages/ui/src/components/paper/PaperArticle/PaperArticle.tsx diff --git a/docs/plans/2026-01-31-paper-article-component-design.md b/docs/plans/2026-01-31-paper-article-component-design.md new file mode 100644 index 0000000..e1feeeb --- /dev/null +++ b/docs/plans/2026-01-31-paper-article-component-design.md @@ -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. diff --git a/packages/eslint-config/index.js b/packages/eslint-config/index.js index bebfd1b..b0b0a4d 100644 --- a/packages/eslint-config/index.js +++ b/packages/eslint-config/index.js @@ -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 }, diff --git a/packages/stl/package.json b/packages/stl/package.json index ba19c05..939a528 100644 --- a/packages/stl/package.json +++ b/packages/stl/package.json @@ -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" } } diff --git a/packages/stl/src/functions/index.ts b/packages/stl/src/functions/index.ts index 2dc230d..d0c7899 100644 --- a/packages/stl/src/functions/index.ts +++ b/packages/stl/src/functions/index.ts @@ -2,4 +2,4 @@ * Shared helper utilities for common functionality. */ -export { cn } from './cn/cn'; +export { cn } from './cn/cn'; \ No newline at end of file diff --git a/packages/stl/src/index.ts b/packages/stl/src/index.ts index 4e6b595..c0f335a 100644 --- a/packages/stl/src/index.ts +++ b/packages/stl/src/index.ts @@ -1,2 +1,3 @@ export * from './types'; export * from './functions'; +export * from './react'; \ No newline at end of file diff --git a/packages/stl/src/react/castRef/castRef.test.ts b/packages/stl/src/react/castRef/castRef.test.ts new file mode 100644 index 0000000..7d2a6c2 --- /dev/null +++ b/packages/stl/src/react/castRef/castRef.test.ts @@ -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; + const casted = castRef(ref); + expect(casted).toBe(ref); + }); +}); diff --git a/packages/stl/src/react/castRef/castRef.ts b/packages/stl/src/react/castRef/castRef.ts new file mode 100644 index 0000000..3c133df --- /dev/null +++ b/packages/stl/src/react/castRef/castRef.ts @@ -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 + * (ref)} /> + * ``` + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function castRef(ref: React.ForwardedRef): React.ForwardedRef { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return ref as React.ForwardedRef; +} \ No newline at end of file diff --git a/packages/stl/src/react/genericMemo/genericMemo.test.tsx b/packages/stl/src/react/genericMemo/genericMemo.test.tsx new file mode 100644 index 0000000..dfb07d2 --- /dev/null +++ b/packages/stl/src/react/genericMemo/genericMemo.test.tsx @@ -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 }) =>
{name}
; + const Memoized = genericMemo(Component); + + expect(Memoized).toBeDefined(); + // In React, memo returns a special object with type: component + expect((Memoized as any).type).toBe(Component); + }); +}); diff --git a/packages/stl/src/react/genericMemo/genericMemo.ts b/packages/stl/src/react/genericMemo/genericMemo.ts new file mode 100644 index 0000000..aabf5cf --- /dev/null +++ b/packages/stl/src/react/genericMemo/genericMemo.ts @@ -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(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; +} \ No newline at end of file diff --git a/packages/stl/src/react/index.ts b/packages/stl/src/react/index.ts new file mode 100644 index 0000000..d6a228a --- /dev/null +++ b/packages/stl/src/react/index.ts @@ -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 & React.AllHTMLAttributes; diff --git a/packages/ui/.storybook/main.ts b/packages/ui/.storybook/main.ts index 88f8f6a..b95a6cd 100644 --- a/packages/ui/.storybook/main.ts +++ b/packages/ui/.storybook/main.ts @@ -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'), diff --git a/packages/ui/jest.config.mjs b/packages/ui/jest.config.mjs index 65e2636..1f7f0a3 100644 --- a/packages/ui/jest.config.mjs +++ b/packages/ui/jest.config.mjs @@ -1,3 +1,9 @@ import { reactJestConfig } from '@ermnvldmr/jest-config/react'; -export default reactJestConfig; +export default { + ...reactJestConfig, + moduleNameMapper: { + ...reactJestConfig.moduleNameMapper, + '^@ermnvldmr/stl$': '/../stl/src/index.ts', + }, +}; diff --git a/packages/ui/src/components/generic/Breadcrumbs/Breadcrumbs.stories.tsx b/packages/ui/src/components/generic/Breadcrumbs/Breadcrumbs.stories.tsx new file mode 100644 index 0000000..91d0b32 --- /dev/null +++ b/packages/ui/src/components/generic/Breadcrumbs/Breadcrumbs.stories.tsx @@ -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 = { + title: 'components/generic/Breadcrumbs', + component: Breadcrumbs, + tags: ['autodocs'], +}; + +export default meta; +/** + * + */ +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + + Home + + + + Blog + + + Modern Web Design + + ), +}; + +export const WithArrows: Story = { + render: () => ( + + + Home + + + + Docs + + + Components + + ), +}; + +export const WithBullets: Story = { + render: () => ( + + + Home + + + + About + + + Team + + ), +}; diff --git a/packages/ui/src/components/generic/Breadcrumbs/Breadcrumbs.test.tsx b/packages/ui/src/components/generic/Breadcrumbs/Breadcrumbs.test.tsx new file mode 100644 index 0000000..84b33a4 --- /dev/null +++ b/packages/ui/src/components/generic/Breadcrumbs/Breadcrumbs.test.tsx @@ -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( + + + Home + + + Current + + ); + + 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( + + + + ); + expect(screen.getByTestId('sep')).toHaveTextContent('•'); + + rerender( + + + + ); + expect(screen.getByTestId('sep')).toHaveTextContent('/'); + }); +}); diff --git a/packages/ui/src/components/generic/Breadcrumbs/Breadcrumbs.tsx b/packages/ui/src/components/generic/Breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 0000000..fa50c04 --- /dev/null +++ b/packages/ui/src/components/generic/Breadcrumbs/Breadcrumbs.tsx @@ -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 ( + + ); +}); + +/** + * Individual step in the breadcrumb trail. + */ +const BreadcrumbsItem = memo(function BreadcrumbsItem({ + children, + className, + 'data-testid': testId, +}: { + children: React.ReactNode; + className?: string; +} & TestIdProps) { + return ( +
  • + + {children} + +
  • + ); +}); + +/** + * 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: , + }; + + return ( + + ); +}); + +/** + * 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 + * + * Home + * + * Current Page + * + * ``` + */ +export const Breadcrumbs = Object.assign(BreadcrumbsRoot, { + Item: BreadcrumbsItem, + Separator: BreadcrumbsSeparator, +}); diff --git a/packages/ui/src/components/generic/Container/Container.stories.tsx b/packages/ui/src/components/generic/Container/Container.stories.tsx new file mode 100644 index 0000000..4af4123 --- /dev/null +++ b/packages/ui/src/components/generic/Container/Container.stories.tsx @@ -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 = { + 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; + +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: () => ( + + Base Surface + Primary Surface + Secondary Surface + Tertiary Surface + Error Surface + Muted Surface + Transparent Surface + + ), +}; + +export const PaddingScale: Story = { + render: () => ( + + Padding 1 + Padding 2 + Padding 4 + Padding 8 + Padding 12 + + ), +}; + +export const MaxWidths: Story = { + render: () => ( + + Max Width SM (640px) + Max Width MD (768px) + Max Width LG (1024px) + Max Width XL (1280px) + + ), +}; + +export const Rounding: Story = { + render: () => ( +
    + None + Small + Medium + Large + Full +
    + ), +}; + +export const ElevationAndBorders: Story = { + render: () => ( + + + Border Only + + + Shadow Only + + + Border + Shadow + + + ), +}; + +export const Composition: Story = { + render: () => ( + + + Card Header + + + + + 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. + + + Inside a nested muted container + + + + + ), +}; diff --git a/packages/ui/src/components/generic/Container/Container.test.tsx b/packages/ui/src/components/generic/Container/Container.test.tsx new file mode 100644 index 0000000..6603919 --- /dev/null +++ b/packages/ui/src/components/generic/Container/Container.test.tsx @@ -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(Content); + + 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(Content); + let container = screen.getByText('Content'); + expect(container).not.toHaveClass('cursor-pointer'); + + rerender( {}}>Content); + container = screen.getByText('Content'); + expect(container).toHaveClass('cursor-pointer'); + expect(container).toHaveClass('active:opacity-80'); + }); +}); \ No newline at end of file diff --git a/packages/ui/src/components/generic/Container/Container.tsx b/packages/ui/src/components/generic/Container/Container.tsx new file mode 100644 index 0000000..a166a07 --- /dev/null +++ b/packages/ui/src/components/generic/Container/Container.tsx @@ -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(null); + const { pressProps } = usePress({ + onPress, + isDisabled: !onPress, + }); + + const bgClasses: Record = { + 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 = { + 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 = { + none: 'rounded-none', + sm: 'rounded-sm', + md: 'rounded-md', + lg: 'rounded-lg', + full: 'rounded-full', + }; + + const paddingClasses: Record = { + 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 ( +
    + {children} +
    + ); +}); diff --git a/packages/ui/src/components/generic/HStack/HStack.tsx b/packages/ui/src/components/generic/HStack/HStack.tsx index b0694ee..76af1a0 100644 --- a/packages/ui/src/components/generic/HStack/HStack.tsx +++ b/packages/ui/src/components/generic/HStack/HStack.tsx @@ -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 { * * ``` */ -export const HStack = memo(function HStack(props: HStackProps) { - return ; -}); +export const HStack = memo(forwardRef(function HStack(props, ref) { + return ; +})); \ No newline at end of file diff --git a/packages/ui/src/components/generic/Header/Header.stories.tsx b/packages/ui/src/components/generic/Header/Header.stories.tsx new file mode 100644 index 0000000..3169eab --- /dev/null +++ b/packages/ui/src/components/generic/Header/Header.stories.tsx @@ -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 = { + 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; + +export const Default: Story = { + args: { + children: 'Header Level 1', + level: 1, + }, +}; + +export const AllLevels: Story = { + render: () => ( + +
    Header Level 1 (Display L)
    +
    Header Level 2 (Display M)
    +
    Header Level 3 (Headline L)
    +
    Header Level 4 (Headline M)
    +
    Header Level 5 (Title L)
    +
    Header Level 6 (Title M)
    +
    + ), +}; + +export const Overrides: Story = { + render: () => ( + +
    H1 Tag with Headline Small Style
    +
    H3 Tag with Body Large Style and Primary Color
    +
    H6 with Modifiers
    +
    + ), +}; \ No newline at end of file diff --git a/packages/ui/src/components/generic/Header/Header.tsx b/packages/ui/src/components/generic/Header/Header.tsx new file mode 100644 index 0000000..6677860 --- /dev/null +++ b/packages/ui/src/components/generic/Header/Header.tsx @@ -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 { + /** 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 = { + 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 ( + + {children} + + ); +}); diff --git a/packages/ui/src/components/generic/Link/Link.stories.tsx b/packages/ui/src/components/generic/Link/Link.stories.tsx new file mode 100644 index 0000000..3975e30 --- /dev/null +++ b/packages/ui/src/components/generic/Link/Link.stories.tsx @@ -0,0 +1,33 @@ +import { Link } from './Link'; + + +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + title: 'components/generic/Link', + component: Link, + tags: ['autodocs'], + argTypes: { + onPress: { action: 'pressed' }, + }, +}; + +export default meta; +/** + * + */ +type Story = StoryObj; + +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!'), + }, +}; diff --git a/packages/ui/src/components/generic/Link/Link.test.tsx b/packages/ui/src/components/generic/Link/Link.test.tsx new file mode 100644 index 0000000..f63a661 --- /dev/null +++ b/packages/ui/src/components/generic/Link/Link.test.tsx @@ -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(Click me); + 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( {}}>Click me); + expect(screen.getByText('Click me')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/generic/Link/Link.tsx b/packages/ui/src/components/generic/Link/Link.tsx new file mode 100644 index 0000000..f879197 --- /dev/null +++ b/packages/ui/src/components/generic/Link/Link.tsx @@ -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 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(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 ( + + {children} + + ); + } + + return ( + (ref)} + as="span" + className={sharedClasses} + > + {children} + + ); +}); diff --git a/packages/ui/src/components/generic/Stack/Stack.stories.tsx b/packages/ui/src/components/generic/Stack/Stack.stories.tsx index 801c9aa..6d9ea04 100644 --- a/packages/ui/src/components/generic/Stack/Stack.stories.tsx +++ b/packages/ui/src/components/generic/Stack/Stack.stories.tsx @@ -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 = { - title: 'components/shared/Stack', + title: 'components/generic/Stack', component: Stack, parameters: { layout: 'centered', diff --git a/packages/ui/src/components/generic/Stack/Stack.tsx b/packages/ui/src/components/generic/Stack/Stack.tsx index 349e76a..485c3b9 100644 --- a/packages/ui/src/components/generic/Stack/Stack.tsx +++ b/packages/ui/src/components/generic/Stack/Stack.tsx @@ -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 { *
    * ``` */ -export const Stack = memo(function Stack({ +const StackComponent = forwardRef(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 ( -
    (ref)} className={stackClasses} data-testid={testId} > {children} -
    +
    ); }); + +export const Stack = genericMemo(StackComponent); diff --git a/packages/ui/src/components/generic/Text/Text.stories.tsx b/packages/ui/src/components/generic/Text/Text.stories.tsx new file mode 100644 index 0000000..6d55e14 --- /dev/null +++ b/packages/ui/src/components/generic/Text/Text.stories.tsx @@ -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 = { + 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; + +export const Default: Story = { + args: { + children: 'The quick brown fox jumps over the lazy dog.', + type: 'body', + size: 'm', + }, +}; + +export const TypeScale: Story = { + render: () => ( + +
    + Display + + Display Large + Display Medium + Display Small + +
    +
    + Headline + + Headline Large + Headline Medium + Headline Small + +
    +
    + Title + + Title Large + Title Medium + Title Small + +
    +
    + Body + + Body Large + Body Medium + Body Small + +
    +
    + Label + + Label Large + Label Medium + Label Small + +
    +
    + ), +}; + +export const Colors: Story = { + render: () => ( + + Default Text Color + Primary Text Color + Secondary Text Color + Tertiary Text Color + Error Text Color + Muted Text Color + + ), +}; + +export const Modifiers: Story = { + render: () => ( + + Bold Text + Italic Text + Underlined Text + Strikethrough Text + Combined Modifiers + + ), +}; diff --git a/packages/ui/src/components/generic/Text/Text.tsx b/packages/ui/src/components/generic/Text/Text.tsx new file mode 100644 index 0000000..5508f56 --- /dev/null +++ b/packages/ui/src/components/generic/Text/Text.tsx @@ -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, '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(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> = { + 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 = { + 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 ( + (ref)} + className={cn( + typeStyles[type][size], + colorClasses[color], + bold && 'font-bold', + italic && 'italic', + underline && 'underline', + strike && 'line-through', + className + )} + data-testid={testId} + > + {children} + + ); +}); + +export const Text = genericMemo(TextComponent); \ No newline at end of file diff --git a/packages/ui/src/components/generic/VStack/VStack.tsx b/packages/ui/src/components/generic/VStack/VStack.tsx index 84ef1d7..7eacae6 100644 --- a/packages/ui/src/components/generic/VStack/VStack.tsx +++ b/packages/ui/src/components/generic/VStack/VStack.tsx @@ -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 { * * ``` */ -export const VStack = memo(function VStack(props: VStackProps) { - return ; -}); +export const VStack = memo(forwardRef(function VStack(props, ref) { + return ; +})); \ No newline at end of file diff --git a/packages/ui/src/components/generic/index.ts b/packages/ui/src/components/generic/index.ts index b20bde7..6aef11a 100644 --- a/packages/ui/src/components/generic/index.ts +++ b/packages/ui/src/components/generic/index.ts @@ -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'; \ No newline at end of file diff --git a/packages/ui/src/components/paper/PaperArticle/PaperArticle.stories.tsx b/packages/ui/src/components/paper/PaperArticle/PaperArticle.stories.tsx new file mode 100644 index 0000000..b9f49bc --- /dev/null +++ b/packages/ui/src/components/paper/PaperArticle/PaperArticle.stories.tsx @@ -0,0 +1,42 @@ +import { PaperArticle } from './PaperArticle'; + + +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + title: 'Components/Paper/PaperArticle', + component: PaperArticle, + tags: ['autodocs'], + argTypes: { + onPress: { action: 'pressed' }, + }, +}; + +export default meta; +/** + * + */ +type Story = StoryObj; + +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!'), + }, +}; diff --git a/packages/ui/src/components/paper/PaperArticle/PaperArticle.test.tsx b/packages/ui/src/components/paper/PaperArticle/PaperArticle.test.tsx new file mode 100644 index 0000000..19ba7b4 --- /dev/null +++ b/packages/ui/src/components/paper/PaperArticle/PaperArticle.test.tsx @@ -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(Article body content); + + 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(Body); + // Should have 1 separator (between title and body) + expect(screen.getAllByRole('separator')).toHaveLength(1); + + rerender(Body); + // Should have 2 separators (Title-Sub and Sub-Body) + expect(screen.getAllByRole('separator')).toHaveLength(2); + }); +}); diff --git a/packages/ui/src/components/paper/PaperArticle/PaperArticle.tsx b/packages/ui/src/components/paper/PaperArticle/PaperArticle.tsx new file mode 100644 index 0000000..fd37111 --- /dev/null +++ b/packages/ui/src/components/paper/PaperArticle/PaperArticle.tsx @@ -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 ( + + +
    + {headline} +
    + + + {subHeadline ? ( + <> + + + {subHeadline} + + + + ) : } + + + + {children} + + + {additionalText && ( + + + {additionalText} + + + )} +
    +
    + ); +}); diff --git a/packages/ui/src/components/paper/index.ts b/packages/ui/src/components/paper/index.ts index 687d1fa..df8f0d3 100644 --- a/packages/ui/src/components/paper/index.ts +++ b/packages/ui/src/components/paper/index.ts @@ -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'; diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index cfee0b8..0ad1df2 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -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); diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts index 25d6474..40c623a 100644 --- a/packages/ui/vite.config.ts +++ b/packages/ui/vite.config.ts @@ -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()], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6c13ff..bce8b8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/services/www/src/layouts/IndexLayout/IndexLayout.astro b/services/www/src/layouts/IndexLayout/IndexLayout.astro index eda5f49..17e6e95 100644 --- a/services/www/src/layouts/IndexLayout/IndexLayout.astro +++ b/services/www/src/layouts/IndexLayout/IndexLayout.astro @@ -42,13 +42,6 @@ const { title, description } = Astro.props; {title} - - - -