;
const baseArgs = {
title: 'Design System',
year: '2024',
role: 'Lead Frontend Engineer',
stack: ['React', 'TypeScript', 'Tailwind CSS', 'Storybook'],
- description: 'A comprehensive design system built for a large-scale SaaS product, covering components, tokens, and documentation.',
+ description:
+ 'A comprehensive design system built for a large-scale SaaS product, covering components, tokens, and documentation.',
details: [
'Established token system covering color, spacing, and typography.',
'Built 40+ accessible components with full test coverage.',
'Integrated Storybook for visual regression testing and documentation.',
],
-}
+};
export const Default: Story = {
args: baseArgs,
-}
+};
export const WithImage: Story = {
args: {
...baseArgs,
imageUrl: 'https://placehold.co/800x450/3B4A59/D9B48F?text=Project',
},
-}
+};
diff --git a/src/entities/project/ui/DetailedProjectCard.test.tsx b/src/entities/project/ui/DetailedProjectCard.test.tsx
index bc3cbae..4f86de2 100644
--- a/src/entities/project/ui/DetailedProjectCard.test.tsx
+++ b/src/entities/project/ui/DetailedProjectCard.test.tsx
@@ -1,6 +1,6 @@
-import { describe, it, expect } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import { DetailedProjectCard } from './DetailedProjectCard'
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { DetailedProjectCard } from './DetailedProjectCard';
const DEFAULT_PROPS = {
title: 'Big Project',
@@ -9,83 +9,83 @@ const DEFAULT_PROPS = {
stack: ['Vue', 'Go'],
description: 'A detailed project description',
details: ['First detail point', 'Second detail point'],
-}
+};
describe('DetailedProjectCard', () => {
describe('rendering', () => {
it('renders the project title', () => {
- render( )
- expect(screen.getByText('Big Project')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByText('Big Project')).toBeInTheDocument();
+ });
it('renders the description', () => {
- render( )
- expect(screen.getByText('A detailed project description')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByText('A detailed project description')).toBeInTheDocument();
+ });
it('renders each detail item', () => {
- render( )
- expect(screen.getByText('First detail point')).toBeInTheDocument()
- expect(screen.getByText('Second detail point')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByText('First detail point')).toBeInTheDocument();
+ expect(screen.getByText('Second detail point')).toBeInTheDocument();
+ });
it('renders ProjectMetadata with year, role, and stack', () => {
- render( )
- expect(screen.getByText('2023')).toBeInTheDocument()
- expect(screen.getByText('Lead Dev')).toBeInTheDocument()
- expect(screen.getByText('Vue')).toBeInTheDocument()
- expect(screen.getByText('Go')).toBeInTheDocument()
- })
- })
+ render( );
+ expect(screen.getByText('2023')).toBeInTheDocument();
+ expect(screen.getByText('Lead Dev')).toBeInTheDocument();
+ expect(screen.getByText('Vue')).toBeInTheDocument();
+ expect(screen.getByText('Go')).toBeInTheDocument();
+ });
+ });
describe('structure', () => {
it('outer grid has grid-cols-1 and lg:grid-cols-12', () => {
- const { container } = render( )
- expect(container.firstChild).toHaveClass('grid', 'grid-cols-1', 'lg:grid-cols-12')
- })
+ const { container } = render( );
+ expect(container.firstChild).toHaveClass('grid', 'grid-cols-1', 'lg:grid-cols-12');
+ });
it('title is rendered as an h3', () => {
- render( )
- expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Big Project')
- })
+ render( );
+ expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Big Project');
+ });
it('detail items are rendered as tags with text-base', () => {
- render( )
- const detail = screen.getByText('First detail point')
- expect(detail.tagName).toBe('P')
- expect(detail).toHaveClass('text-base')
- })
+ render( );
+ const detail = screen.getByText('First detail point');
+ expect(detail.tagName).toBe('P');
+ expect(detail).toHaveClass('text-base');
+ });
it('details list has brutal-border-top and pt-6', () => {
- render( )
- const detail = screen.getByText('First detail point')
- const detailList = detail.parentElement
- expect(detailList).toHaveClass('brutal-border-top', 'pt-6')
- })
+ render( );
+ const detail = screen.getByText('First detail point');
+ const detailList = detail.parentElement;
+ expect(detailList).toHaveClass('brutal-border-top', 'pt-6');
+ });
it('description has text-lg and mb-6', () => {
- render( )
- const desc = screen.getByText('A detailed project description')
- expect(desc).toHaveClass('text-lg', 'mb-6')
- })
- })
+ render( );
+ const desc = screen.getByText('A detailed project description');
+ expect(desc).toHaveClass('text-lg', 'mb-6');
+ });
+ });
describe('conditional image rendering', () => {
it('does not render image when imageUrl is absent', () => {
- const { container } = render( )
- expect(container.querySelector('img')).toBeNull()
- })
+ const { container } = render( );
+ expect(container.querySelector('img')).toBeNull();
+ });
it('renders image when imageUrl is provided', () => {
- render( )
- const img = screen.getByRole('img')
- expect(img).toHaveAttribute('src', '/detail.jpg')
- })
+ render( );
+ const img = screen.getByRole('img');
+ expect(img).toHaveAttribute('src', '/detail.jpg');
+ });
it('image wrapper has aspect-video and brutal-border when imageUrl is provided', () => {
- const { container } = render( )
- const imgWrapper = container.querySelector('img')!.parentElement
- expect(imgWrapper).toHaveClass('aspect-video', 'brutal-border')
- })
- })
-})
+ const { container } = render( );
+ const imgWrapper = container.querySelector('img')!.parentElement;
+ expect(imgWrapper).toHaveClass('aspect-video', 'brutal-border');
+ });
+ });
+});
diff --git a/src/entities/project/ui/DetailedProjectCard.tsx b/src/entities/project/ui/DetailedProjectCard.tsx
index 53a2663..25c0ed6 100644
--- a/src/entities/project/ui/DetailedProjectCard.tsx
+++ b/src/entities/project/ui/DetailedProjectCard.tsx
@@ -1,54 +1,46 @@
-import { Card } from '$shared/ui'
-import { ProjectMetadata } from './ProjectMetadata'
+import { Card } from '$shared/ui';
+import { ProjectMetadata } from './ProjectMetadata';
type Props = {
/**
* Project name
*/
- title: string
+ title: string;
/**
* Year the project was completed
*/
- year: string
+ year: string;
/**
* Developer role on the project
*/
- role: string
+ role: string;
/**
* Technology stack list
*/
- stack: string[]
+ stack: string[];
/**
* Project description paragraph
*/
- description: string
+ description: string;
/**
* Bullet-style detail points listed below the description
*/
- details: string[]
+ details: string[];
/**
* Optional hero image URL
*/
- imageUrl?: string
+ imageUrl?: string;
/**
* Reverse layout (reserved for future use)
* @default false
*/
- reverse?: boolean
-}
+ reverse?: boolean;
+};
/**
* Full-width detailed project card with metadata sidebar.
*/
-export function DetailedProjectCard({
- title,
- year,
- role,
- stack,
- description,
- details,
- imageUrl,
-}: Props) {
+export function DetailedProjectCard({ title, year, role, stack, description, details, imageUrl }: Props) {
return (
@@ -68,11 +60,13 @@ export function DetailedProjectCard({
{details.map((detail, index) => (
-
{detail}
+
+ {detail}
+
))}
- )
+ );
}
diff --git a/src/entities/project/ui/ProjectCard.stories.tsx b/src/entities/project/ui/ProjectCard.stories.tsx
index f8c3f76..398451f 100644
--- a/src/entities/project/ui/ProjectCard.stories.tsx
+++ b/src/entities/project/ui/ProjectCard.stories.tsx
@@ -1,5 +1,5 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite'
-import { ProjectCard } from './ProjectCard'
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { ProjectCard } from './ProjectCard';
const meta: Meta = {
title: 'Entities/ProjectCard',
@@ -11,11 +11,11 @@ const meta: Meta = {
),
],
-}
+};
-export default meta
+export default meta;
-type Story = StoryObj
+type Story = StoryObj;
export const Default: Story = {
args: {
@@ -24,7 +24,7 @@ export const Default: Story = {
description: 'A brutalist portfolio site built with Next.js and Tailwind CSS.',
tags: ['React', 'TypeScript', 'Next.js'],
},
-}
+};
export const WithImage: Story = {
args: {
@@ -34,4 +34,4 @@ export const WithImage: Story = {
tags: ['React', 'TypeScript', 'Next.js'],
imageUrl: 'https://placehold.co/800x450/3B4A59/D9B48F?text=Project',
},
-}
+};
diff --git a/src/entities/project/ui/ProjectCard.test.tsx b/src/entities/project/ui/ProjectCard.test.tsx
index 42d579a..d757241 100644
--- a/src/entities/project/ui/ProjectCard.test.tsx
+++ b/src/entities/project/ui/ProjectCard.test.tsx
@@ -1,79 +1,86 @@
-import { describe, it, expect } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import { ProjectCard } from './ProjectCard'
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { ProjectCard } from './ProjectCard';
const DEFAULT_PROPS = {
title: 'My Project',
year: '2024',
description: 'A cool project description',
tags: ['React', 'Node'],
-}
+};
describe('ProjectCard', () => {
describe('rendering', () => {
it('renders the project title', () => {
- render( )
- expect(screen.getByText('My Project')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByText('My Project')).toBeInTheDocument();
+ });
it('renders the year badge', () => {
- render( )
- expect(screen.getByText('2024')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByText('2024')).toBeInTheDocument();
+ });
it('renders the description', () => {
- render( )
- expect(screen.getByText('A cool project description')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByText('A cool project description')).toBeInTheDocument();
+ });
it('renders each tag', () => {
- render( )
- expect(screen.getByText('React')).toBeInTheDocument()
- expect(screen.getByText('Node')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByText('React')).toBeInTheDocument();
+ expect(screen.getByText('Node')).toBeInTheDocument();
+ });
it('renders the View Project button', () => {
- render( )
- expect(screen.getByRole('button', { name: /view project/i })).toBeInTheDocument()
- })
- })
+ render( );
+ expect(screen.getByRole('button', { name: /view project/i })).toBeInTheDocument();
+ });
+ });
describe('structure', () => {
it('card has hover transition classes', () => {
- const { container } = render( )
- const card = container.firstChild as HTMLElement
- expect(card).toHaveClass('group', 'transition-all', 'duration-300')
- })
+ const { container } = render( );
+ const card = container.firstChild as HTMLElement;
+ expect(card).toHaveClass('group', 'transition-all', 'duration-300');
+ });
it('year badge has correct classes', () => {
- render( )
- const yearBadge = screen.getByText('2024')
- expect(yearBadge).toHaveClass('brutal-border', 'bg-carbon-black', 'text-ochre-clay', 'text-sm')
- })
+ render( );
+ const yearBadge = screen.getByText('2024');
+ expect(yearBadge).toHaveClass('brutal-border', 'bg-carbon-black', 'text-ochre-clay', 'text-sm');
+ });
it('tags have correct classes', () => {
- render( )
- const tag = screen.getByText('React')
- expect(tag).toHaveClass('brutal-border', 'bg-white', 'text-carbon-black', 'text-sm', 'uppercase', 'tracking-wide')
- })
- })
+ render( );
+ const tag = screen.getByText('React');
+ expect(tag).toHaveClass(
+ 'brutal-border',
+ 'bg-white',
+ 'text-carbon-black',
+ 'text-sm',
+ 'uppercase',
+ 'tracking-wide',
+ );
+ });
+ });
describe('conditional image rendering', () => {
it('does not render image when imageUrl is absent', () => {
- const { container } = render( )
- expect(container.querySelector('img')).toBeNull()
- })
+ const { container } = render( );
+ expect(container.querySelector('img')).toBeNull();
+ });
it('renders image when imageUrl is provided', () => {
- render( )
- const img = screen.getByRole('img')
- expect(img).toHaveAttribute('src', '/project.jpg')
- })
+ render( );
+ const img = screen.getByRole('img');
+ expect(img).toHaveAttribute('src', '/project.jpg');
+ });
it('image wrapper has aspect-video and overflow-hidden when imageUrl is provided', () => {
- const { container } = render( )
- const imgWrapper = container.querySelector('img')!.parentElement
- expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border')
- })
- })
-})
+ const { container } = render( );
+ const imgWrapper = container.querySelector('img')!.parentElement;
+ expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border');
+ });
+ });
+});
diff --git a/src/entities/project/ui/ProjectCard.tsx b/src/entities/project/ui/ProjectCard.tsx
index c718b0f..a41b588 100644
--- a/src/entities/project/ui/ProjectCard.tsx
+++ b/src/entities/project/ui/ProjectCard.tsx
@@ -1,28 +1,28 @@
-import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, Button } from '$shared/ui'
-import { cn } from '$shared/lib'
+import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, Button } from '$shared/ui';
+import { cn } from '$shared/lib';
type Props = {
/**
* Project name
*/
- title: string
+ title: string;
/**
* Year the project was completed
*/
- year: string
+ year: string;
/**
* Short project description
*/
- description: string
+ description: string;
/**
* Technology or category tags
*/
- tags: string[]
+ tags: string[];
/**
* Optional preview image URL
*/
- imageUrl?: string
-}
+ imageUrl?: string;
+};
/**
* Compact project card for grid/list display.
@@ -61,8 +61,10 @@ export function ProjectCard({ title, year, description, tags, imageUrl }: Props)
- View Project
+
+ View Project
+
- )
+ );
}
diff --git a/src/entities/project/ui/ProjectMetadata.stories.tsx b/src/entities/project/ui/ProjectMetadata.stories.tsx
index 2773220..0850c18 100644
--- a/src/entities/project/ui/ProjectMetadata.stories.tsx
+++ b/src/entities/project/ui/ProjectMetadata.stories.tsx
@@ -1,5 +1,5 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite'
-import { ProjectMetadata } from './ProjectMetadata'
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { ProjectMetadata } from './ProjectMetadata';
const meta: Meta = {
title: 'Entities/ProjectMetadata',
@@ -11,11 +11,11 @@ const meta: Meta = {
),
],
-}
+};
-export default meta
+export default meta;
-type Story = StoryObj
+type Story = StoryObj;
export const Default: Story = {
args: {
@@ -23,4 +23,4 @@ export const Default: Story = {
role: 'Lead Frontend Engineer',
stack: ['React', 'TypeScript', 'Next.js', 'Tailwind'],
},
-}
+};
diff --git a/src/entities/project/ui/ProjectMetadata.test.tsx b/src/entities/project/ui/ProjectMetadata.test.tsx
index 2efc055..3f4161f 100644
--- a/src/entities/project/ui/ProjectMetadata.test.tsx
+++ b/src/entities/project/ui/ProjectMetadata.test.tsx
@@ -1,96 +1,96 @@
-import { describe, it, expect } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import { ProjectMetadata } from './ProjectMetadata'
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { ProjectMetadata } from './ProjectMetadata';
const DEFAULT_PROPS = {
year: '2024',
role: 'Frontend Engineer',
stack: ['React', 'TypeScript', 'Tailwind'],
-}
+};
describe('ProjectMetadata', () => {
describe('rendering', () => {
it('renders the year value', () => {
- render( )
- expect(screen.getByText('2024')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByText('2024')).toBeInTheDocument();
+ });
it('renders the YEAR label', () => {
- render( )
- expect(screen.getByText('YEAR')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByText('YEAR')).toBeInTheDocument();
+ });
it('renders the role value', () => {
- render( )
- expect(screen.getByText('Frontend Engineer')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByText('Frontend Engineer')).toBeInTheDocument();
+ });
it('renders the ROLE label', () => {
- render( )
- expect(screen.getByText('ROLE')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByText('ROLE')).toBeInTheDocument();
+ });
it('renders the STACK label', () => {
- render( )
- expect(screen.getByText('STACK')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByText('STACK')).toBeInTheDocument();
+ });
it('renders each stack technology', () => {
- render( )
- expect(screen.getByText('React')).toBeInTheDocument()
- expect(screen.getByText('TypeScript')).toBeInTheDocument()
- expect(screen.getByText('Tailwind')).toBeInTheDocument()
- })
- })
+ render( );
+ expect(screen.getByText('React')).toBeInTheDocument();
+ expect(screen.getByText('TypeScript')).toBeInTheDocument();
+ expect(screen.getByText('Tailwind')).toBeInTheDocument();
+ });
+ });
describe('structure', () => {
it('outer div has space-y-6', () => {
- const { container } = render( )
- expect(container.firstChild).toHaveClass('space-y-6')
- })
+ const { container } = render( );
+ expect(container.firstChild).toHaveClass('space-y-6');
+ });
it('year section has no brutal-border-top (first section)', () => {
- const { container } = render( )
- const sections = container.firstChild!.childNodes
- expect(sections[0]).not.toHaveClass('brutal-border-top')
- })
+ const { container } = render( );
+ const sections = container.firstChild!.childNodes;
+ expect(sections[0]).not.toHaveClass('brutal-border-top');
+ });
it('role section has brutal-border-top and pt-6', () => {
- const { container } = render( )
- const sections = container.firstChild!.childNodes
- expect(sections[1]).toHaveClass('brutal-border-top', 'pt-6')
- })
+ const { container } = render( );
+ const sections = container.firstChild!.childNodes;
+ expect(sections[1]).toHaveClass('brutal-border-top', 'pt-6');
+ });
it('stack section has brutal-border-top and pt-6', () => {
- const { container } = render( )
- const sections = container.firstChild!.childNodes
- expect(sections[2]).toHaveClass('brutal-border-top', 'pt-6')
- })
+ const { container } = render( );
+ const sections = container.firstChild!.childNodes;
+ expect(sections[2]).toHaveClass('brutal-border-top', 'pt-6');
+ });
it('label has text-xs uppercase tracking-wider opacity-60', () => {
- render( )
- const yearLabel = screen.getByText('YEAR')
- expect(yearLabel).toHaveClass('text-xs', 'uppercase', 'tracking-wider', 'opacity-60')
- })
+ render( );
+ const yearLabel = screen.getByText('YEAR');
+ expect(yearLabel).toHaveClass('text-xs', 'uppercase', 'tracking-wider', 'opacity-60');
+ });
it('year value has text-base font-bold', () => {
- render( )
- const yearValue = screen.getByText('2024')
- expect(yearValue).toHaveClass('text-base', 'font-bold')
- })
+ render( );
+ const yearValue = screen.getByText('2024');
+ expect(yearValue).toHaveClass('text-base', 'font-bold');
+ });
it('each stack tech is rendered as a with text-sm', () => {
- render( )
- const techEl = screen.getByText('React')
- expect(techEl.tagName).toBe('P')
- expect(techEl).toHaveClass('text-sm')
- })
- })
+ render( );
+ const techEl = screen.getByText('React');
+ expect(techEl.tagName).toBe('P');
+ expect(techEl).toHaveClass('text-sm');
+ });
+ });
describe('className passthrough', () => {
it('merges custom className onto outer div', () => {
- const { container } = render( )
- expect(container.firstChild).toHaveClass('my-custom')
- })
- })
-})
+ const { container } = render( );
+ expect(container.firstChild).toHaveClass('my-custom');
+ });
+ });
+});
diff --git a/src/entities/project/ui/ProjectMetadata.tsx b/src/entities/project/ui/ProjectMetadata.tsx
index f1b73f5..9722b83 100644
--- a/src/entities/project/ui/ProjectMetadata.tsx
+++ b/src/entities/project/ui/ProjectMetadata.tsx
@@ -1,23 +1,23 @@
-import { cn } from '$shared/lib'
+import { cn } from '$shared/lib';
type Props = {
/**
* Project year
*/
- year: string
+ year: string;
/**
* Developer role on the project
*/
- role: string
+ role: string;
/**
* Technology stack list
*/
- stack: string[]
+ stack: string[];
/**
* Additional CSS classes
*/
- className?: string
-}
+ className?: string;
+};
/**
* Sidebar metadata display for a project: year, role, and stack.
@@ -36,9 +36,11 @@ export function ProjectMetadata({ year, role, stack, className }: Props) {
STACK
{stack.map((tech) => (
-
{tech}
+
+ {tech}
+
))}
- )
+ );
}
diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts
index 1efafbb..b1f988a 100644
--- a/src/shared/api/client.ts
+++ b/src/shared/api/client.ts
@@ -1,9 +1,9 @@
-import type { ListResponse } from './types'
+import type { ListResponse } from './types';
/**
* Native fetch wrapper for PocketBase API requests.
*/
-const PB_URL = process.env.NEXT_PUBLIC_PB_URL || 'http://127.0.0.1:8090'
+const PB_URL = process.env.NEXT_PUBLIC_PB_URL || 'http://127.0.0.1:8090';
/**
* Options for PocketBase collection fetching.
@@ -12,61 +12,55 @@ export type PBFetchOptions = {
/**
* Sorting criteria (e.g., "-created,order")
*/
- sort?: string
+ sort?: string;
/**
* Filter query string
*/
- filter?: string
+ filter?: string;
/**
* Fields to expand (e.g., "stack")
*/
- expand?: string
+ expand?: string;
/**
* Cache revalidation time in seconds
* @default 3600
*/
- revalidate?: number
-}
+ revalidate?: number;
+};
/**
* Fetch a list of records from a PocketBase collection.
*/
-export async function getCollection(
- collection: string,
- options: PBFetchOptions = {}
-): Promise> {
- const { sort, filter, expand, revalidate = 3600 } = options
+export async function getCollection(collection: string, options: PBFetchOptions = {}): Promise> {
+ const { sort, filter, expand, revalidate = 3600 } = options;
- const params = new URLSearchParams()
- if (sort) params.set('sort', sort)
- if (filter) params.set('filter', filter)
- if (expand) params.set('expand', expand)
+ const params = new URLSearchParams();
+ if (sort) params.set('sort', sort);
+ if (filter) params.set('filter', filter);
+ if (expand) params.set('expand', expand);
- const url = `${PB_URL}/api/collections/${collection}/records?${params.toString()}`
+ const url = `${PB_URL}/api/collections/${collection}/records?${params.toString()}`;
const res = await fetch(url, {
next: { revalidate },
- })
+ });
if (!res.ok) {
- throw new Error(`Failed to fetch collection: ${collection}`)
+ throw new Error(`Failed to fetch collection: ${collection}`);
}
- return res.json()
+ return res.json();
}
/**
* Fetch a single record from a PocketBase collection by ID or filter.
*/
-export async function getFirstRecord(
- collection: string,
- options: PBFetchOptions = {}
-): Promise {
+export async function getFirstRecord(collection: string, options: PBFetchOptions = {}): Promise {
const data = await getCollection(collection, {
...options,
// PocketBase convention for "first" or "singleton" patterns
filter: options.filter,
- })
+ });
- return data.items[0] || null
+ return data.items[0] || null;
}
diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts
index 886d07b..70f3c79 100644
--- a/src/shared/api/index.ts
+++ b/src/shared/api/index.ts
@@ -1,2 +1,2 @@
-export * from './types'
-export * from './client'
+export * from './types';
+export * from './client';
diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts
index 204fb2d..a640d9b 100644
--- a/src/shared/api/types.ts
+++ b/src/shared/api/types.ts
@@ -5,60 +5,39 @@ export type BaseRecord = {
/**
* Unique record ID
*/
- id: string
+ id: string;
/**
* ID of the collection this record belongs to
*/
- collectionId: string
+ collectionId: string;
/**
* Name of the collection this record belongs to
*/
- collectionName: string
+ collectionName: string;
/**
* Record creation timestamp (ISO 8601)
*/
- created: string
+ created: string;
/**
* Record last update timestamp (ISO 8601)
*/
- updated: string
-}
+ updated: string;
+ };
-/**
- * PocketBase collection for site sections and routing.
- */
-export type SectionRecord = BaseRecord & {
/**
- * URL-friendly identifier used for routing
+ * PocketBase collection for simple text blocks (Intro, Bio).
*/
- slug: string
- /**
- * Display name of the section
- */
- title: string
- /**
- * Visual numbering prefix (e.g., "01")
- */
- number: string
- /**
- * Sorting weight for section order
- */
- order: number
-}
+ export type PageContentRecord = BaseRecord & {
-/**
- * PocketBase collection for simple text blocks (Intro, Bio).
- */
-export type PageContentRecord = BaseRecord & {
/**
* Slug corresponding to the parent section
*/
- slug: string
+ slug: string;
/**
* HTML or Markdown content string
*/
- content: string
-}
+ content: string;
+};
/**
* PocketBase collection for technology skills.
@@ -67,16 +46,16 @@ export type SkillRecord = BaseRecord & {
/**
* Name of the technology or tool
*/
- name: string
+ name: string;
/**
* Grouping category (e.g., 'Frontend', 'Backend')
*/
- category: 'Frontend' | 'Backend' | 'Tools' | 'Design' | string
+ category: 'Frontend' | 'Backend' | 'Tools' | 'Design' | string;
/**
* Sorting weight within the category
*/
- order: number
-}
+ order: number;
+};
/**
* PocketBase collection for work experience history.
@@ -85,28 +64,28 @@ export type ExperienceRecord = BaseRecord & {
/**
* Name of the organization
*/
- company: string
+ company: string;
/**
* Professional title held
*/
- role: string
+ role: string;
/**
* Start date of the tenure
*/
- start_date: string
+ start_date: string;
/**
* End date of the tenure, or null if currently employed
*/
- end_date: string | null
+ end_date: string | null;
/**
* Rich text description of responsibilities and achievements
*/
- description: string
+ description: string;
/**
* Sorting weight for chronological display
*/
- order: number
-}
+ order: number;
+};
/**
* PocketBase collection for portfolio projects.
@@ -115,36 +94,36 @@ export type ProjectRecord = BaseRecord & {
/**
* Full title of the project
*/
- title: string
+ title: string;
/**
* Completion or duration year (e.g., "2024")
*/
- year: string
+ year: string;
/**
* Role performed on the project
*/
- role: string
+ role: string;
/**
* Short summary of the project
*/
- description: string
+ description: string;
/**
* List of specific feature or achievement points
*/
- details: string[]
+ details: string[];
/**
* List of SkillRecord IDs used in the project
*/
- stack: string[]
+ stack: string[];
/**
* Primary thumbnail or hero image filename
*/
- image: string
+ image: string;
/**
* Sorting weight for the project list
*/
- order: number
-}
+ order: number;
+};
/**
* Generic response for a list of PocketBase records.
@@ -153,21 +132,21 @@ export type ListResponse = {
/**
* Current page index
*/
- page: number
+ page: number;
/**
* Number of items per page
*/
- perPage: number
+ perPage: number;
/**
* Total number of items across all pages
*/
- totalItems: number
+ totalItems: number;
/**
* Total number of pages available
*/
- totalPages: number
+ totalPages: number;
/**
* Array of records for the current page
*/
- items: T[]
-}
+ items: T[];
+};
diff --git a/src/shared/index.ts b/src/shared/index.ts
index f715fb8..bdcbcdb 100644
--- a/src/shared/index.ts
+++ b/src/shared/index.ts
@@ -1,3 +1,3 @@
-export * from './ui'
-export * from './lib'
-export * from './api'
+export * from './ui';
+export * from './lib';
+export * from './api';
diff --git a/src/shared/lib/cn.test.ts b/src/shared/lib/cn.test.ts
index 1615ec5..d6163cf 100644
--- a/src/shared/lib/cn.test.ts
+++ b/src/shared/lib/cn.test.ts
@@ -1,40 +1,40 @@
-import { describe, it, expect } from 'vitest'
-import { cn } from './cn'
+import { describe, it, expect } from 'vitest';
+import { cn } from './cn';
describe('cn', () => {
describe('basic merging', () => {
it('returns single class unchanged', () => {
- expect(cn('foo')).toBe('foo')
- })
+ expect(cn('foo')).toBe('foo');
+ });
it('joins multiple classes', () => {
- expect(cn('foo', 'bar')).toBe('foo bar')
- })
- })
+ expect(cn('foo', 'bar')).toBe('foo bar');
+ });
+ });
describe('conditional classes', () => {
it('includes truthy conditional', () => {
- expect(cn('foo', true && 'bar')).toBe('foo bar')
- })
+ expect(cn('foo', true && 'bar')).toBe('foo bar');
+ });
it('excludes falsy conditional', () => {
- expect(cn('foo', false && 'bar')).toBe('foo')
- })
- })
+ expect(cn('foo', false && 'bar')).toBe('foo');
+ });
+ });
describe('object syntax', () => {
it('includes classes with truthy object values', () => {
- expect(cn({ foo: true, bar: false })).toBe('foo')
- })
- })
+ expect(cn({ foo: true, bar: false })).toBe('foo');
+ });
+ });
describe('tailwind conflict resolution', () => {
it('last padding wins', () => {
- expect(cn('px-2', 'px-4')).toBe('px-4')
- })
+ expect(cn('px-2', 'px-4')).toBe('px-4');
+ });
it('last text color wins', () => {
- expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500')
- })
- })
-})
+ expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500');
+ });
+ });
+});
diff --git a/src/shared/lib/cn.ts b/src/shared/lib/cn.ts
index 269848b..e5aba12 100644
--- a/src/shared/lib/cn.ts
+++ b/src/shared/lib/cn.ts
@@ -1,9 +1,9 @@
-import { clsx, type ClassValue } from 'clsx'
-import { twMerge } from 'tailwind-merge'
+import { clsx, type ClassValue } from 'clsx';
+import { twMerge } from 'tailwind-merge';
/**
* Merges Tailwind classes, resolving conflicts in favor of the last value.
*/
export function cn(...inputs: ClassValue[]): string {
- return twMerge(clsx(inputs))
+ return twMerge(clsx(inputs));
}
diff --git a/src/shared/lib/fonts.ts b/src/shared/lib/fonts.ts
index c73f10f..63d193c 100644
--- a/src/shared/lib/fonts.ts
+++ b/src/shared/lib/fonts.ts
@@ -1,4 +1,4 @@
-import { Fraunces, Public_Sans } from 'next/font/google'
+import { Fraunces, Public_Sans } from 'next/font/google';
/**
* Heading font — variable axes for brutalist variation settings
@@ -7,7 +7,7 @@ export const fraunces = Fraunces({
subsets: ['latin'],
variable: '--font-fraunces',
axes: ['opsz', 'SOFT', 'WONK'],
-})
+});
/**
* Body font
@@ -15,4 +15,4 @@ export const fraunces = Fraunces({
export const publicSans = Public_Sans({
subsets: ['latin'],
variable: '--font-public-sans',
-})
+});
diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts
index 59e9088..a914abd 100644
--- a/src/shared/lib/index.ts
+++ b/src/shared/lib/index.ts
@@ -1,3 +1,3 @@
-export { cn } from './cn'
-export type { ClassValue } from 'clsx'
-export * from './fonts'
+export { cn } from './cn';
+export type { ClassValue } from 'clsx';
+export * from './fonts';
diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css
index b3ce4ba..beeb111 100644
--- a/src/shared/styles/theme.css
+++ b/src/shared/styles/theme.css
@@ -2,7 +2,7 @@
/* === TYPOGRAPHY SCALE (Augmented Fourth 1.414) === */
--font-size: 16px;
--text-xs: 0.707rem;
- --text-sm: 0.840rem;
+ --text-sm: 0.84rem;
--text-base: 1rem;
--text-lg: 1.414rem;
--text-xl: 2rem;
@@ -29,9 +29,9 @@
--fraunces-soft: 0;
/* === COLOR PALETTE === */
- --ochre-clay: #D9B48F;
- --slate-indigo: #3B4A59;
- --burnt-oxide: #A64B35;
+ --ochre-clay: #d9b48f;
+ --slate-indigo: #3b4a59;
+ --burnt-oxide: #a64b35;
--carbon-black: #121212;
/* === SEMANTIC COLORS === */
@@ -126,31 +126,48 @@
/* Paper grain texture */
body::before {
- content: '';
+ content: "";
position: fixed;
inset: 0;
background-image:
- repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.02) 2px, rgba(0,0,0,0.02) 4px),
- repeating-linear-gradient(90deg, transparent, transparent 2px, rgba(0,0,0,0.02) 2px, rgba(0,0,0,0.02) 4px);
+ repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0, 0, 0, 0.02) 2px, rgba(0, 0, 0, 0.02) 4px),
+ repeating-linear-gradient(90deg, transparent, transparent 2px, rgba(0, 0, 0, 0.02) 2px, rgba(0, 0, 0, 0.02) 4px);
opacity: 0.4;
display: block;
pointer-events: none;
z-index: 1;
}
- h1, h2, h3, h4, h5, h6 {
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
font-family: var(--font-heading);
font-weight: var(--font-weight-heading);
line-height: var(--line-height-tight);
- font-variation-settings: 'WONK' var(--fraunces-wonk), 'SOFT' var(--fraunces-soft);
+ font-variation-settings:
+ "WONK" var(--fraunces-wonk),
+ "SOFT" var(--fraunces-soft);
color: var(--carbon-black);
}
- h1 { font-size: var(--text-4xl); }
- h2 { font-size: var(--text-3xl); }
- h3 { font-size: var(--text-2xl); }
- h4 { font-size: var(--text-xl); }
- h5 { font-size: var(--text-lg); }
+ h1 {
+ font-size: var(--text-4xl);
+ }
+ h2 {
+ font-size: var(--text-3xl);
+ }
+ h3 {
+ font-size: var(--text-2xl);
+ }
+ h4 {
+ font-size: var(--text-xl);
+ }
+ h5 {
+ font-size: var(--text-lg);
+ }
p {
font-family: var(--font-body);
@@ -180,18 +197,42 @@
}
/* Brutalist utility classes */
-.brutal-shadow { box-shadow: var(--shadow-brutal); }
-.brutal-shadow-sm { box-shadow: var(--shadow-brutal-sm); }
-.brutal-shadow-lg { box-shadow: var(--shadow-brutal-lg); }
-.brutal-border { border: var(--border-width) solid var(--carbon-black); }
-.brutal-border-top { border-top: var(--border-width) solid var(--carbon-black); }
-.brutal-border-bottom { border-bottom: var(--border-width) solid var(--carbon-black); }
-.brutal-border-left { border-left: var(--border-width) solid var(--carbon-black); }
-.brutal-border-right { border-right: var(--border-width) solid var(--carbon-black); }
+.brutal-shadow {
+ box-shadow: var(--shadow-brutal);
+}
+.brutal-shadow-sm {
+ box-shadow: var(--shadow-brutal-sm);
+}
+.brutal-shadow-lg {
+ box-shadow: var(--shadow-brutal-lg);
+}
+.brutal-border {
+ border: var(--border-width) solid var(--carbon-black);
+}
+.brutal-border-top {
+ border-top: var(--border-width) solid var(--carbon-black);
+}
+.brutal-border-bottom {
+ border-bottom: var(--border-width) solid var(--carbon-black);
+}
+.brutal-border-left {
+ border-left: var(--border-width) solid var(--carbon-black);
+}
+.brutal-border-right {
+ border-right: var(--border-width) solid var(--carbon-black);
+}
/* Animations */
@keyframes fadeIn {
- from { opacity: 0; transform: translateY(10px); }
- to { opacity: 1; transform: translateY(0); }
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+.animate-fadeIn {
+ animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
-.animate-fadeIn { animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1); }
diff --git a/src/shared/ui/Badge/index.ts b/src/shared/ui/Badge/index.ts
index 5c05677..fc21fb9 100644
--- a/src/shared/ui/Badge/index.ts
+++ b/src/shared/ui/Badge/index.ts
@@ -1,2 +1,2 @@
-export { Badge } from './ui/Badge'
-export type { BadgeVariant } from './ui/Badge'
+export { Badge } from './ui/Badge';
+export type { BadgeVariant } from './ui/Badge';
diff --git a/src/shared/ui/Badge/ui/Badge.stories.tsx b/src/shared/ui/Badge/ui/Badge.stories.tsx
index e3150bc..392926e 100644
--- a/src/shared/ui/Badge/ui/Badge.stories.tsx
+++ b/src/shared/ui/Badge/ui/Badge.stories.tsx
@@ -1,14 +1,14 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite'
-import { Badge } from './Badge'
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { Badge } from './Badge';
const meta: Meta = {
title: 'Shared/Badge',
component: Badge,
-}
+};
-export default meta
+export default meta;
-type Story = StoryObj
+type Story = StoryObj;
export const AllVariants: Story = {
render: () => (
@@ -19,4 +19,4 @@ export const AllVariants: Story = {
Outline
),
-}
+};
diff --git a/src/shared/ui/Badge/ui/Badge.test.tsx b/src/shared/ui/Badge/ui/Badge.test.tsx
index 67f130c..1dda3cd 100644
--- a/src/shared/ui/Badge/ui/Badge.test.tsx
+++ b/src/shared/ui/Badge/ui/Badge.test.tsx
@@ -1,52 +1,52 @@
-import { describe, it, expect } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import { Badge } from './Badge'
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { Badge } from './Badge';
describe('Badge', () => {
describe('rendering', () => {
it('renders children', () => {
- render(React )
- expect(screen.getByText('React')).toBeInTheDocument()
- })
+ render(React );
+ expect(screen.getByText('React')).toBeInTheDocument();
+ });
it('renders as inline span', () => {
- render(Tag )
- expect(screen.getByText('Tag').tagName).toBe('SPAN')
- })
- })
+ render(Tag );
+ expect(screen.getByText('Tag').tagName).toBe('SPAN');
+ });
+ });
describe('variants', () => {
it('applies default variant classes', () => {
- render(Tag )
- const el = screen.getByText('Tag')
- expect(el).toHaveClass('bg-carbon-black', 'text-ochre-clay')
- })
+ render(Tag );
+ const el = screen.getByText('Tag');
+ expect(el).toHaveClass('bg-carbon-black', 'text-ochre-clay');
+ });
it('applies primary variant classes', () => {
- render(Tag )
- expect(screen.getByText('Tag')).toHaveClass('bg-burnt-oxide')
- })
+ render(Tag );
+ expect(screen.getByText('Tag')).toHaveClass('bg-burnt-oxide');
+ });
it('applies secondary variant classes', () => {
- render(Tag )
- expect(screen.getByText('Tag')).toHaveClass('bg-slate-indigo')
- })
+ render(Tag );
+ expect(screen.getByText('Tag')).toHaveClass('bg-slate-indigo');
+ });
it('applies outline variant classes', () => {
- render(Tag )
- expect(screen.getByText('Tag')).toHaveClass('bg-transparent')
- })
+ render(Tag );
+ expect(screen.getByText('Tag')).toHaveClass('bg-transparent');
+ });
it('defaults to default variant when unspecified', () => {
- render(Tag )
- expect(screen.getByText('Tag')).toHaveClass('bg-carbon-black')
- })
- })
+ render(Tag );
+ expect(screen.getByText('Tag')).toHaveClass('bg-carbon-black');
+ });
+ });
describe('className passthrough', () => {
it('merges custom className', () => {
- render(Tag )
- expect(screen.getByText('Tag')).toHaveClass('mt-4')
- })
- })
-})
+ render(Tag );
+ expect(screen.getByText('Tag')).toHaveClass('mt-4');
+ });
+ });
+});
diff --git a/src/shared/ui/Badge/ui/Badge.tsx b/src/shared/ui/Badge/ui/Badge.tsx
index 4f5bbd5..5e8e037 100644
--- a/src/shared/ui/Badge/ui/Badge.tsx
+++ b/src/shared/ui/Badge/ui/Badge.tsx
@@ -1,30 +1,30 @@
-import type { ReactNode } from 'react'
-import { cn } from '$shared/lib'
+import type { ReactNode } from 'react';
+import { cn } from '$shared/lib';
-export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'outline'
+export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'outline';
interface Props {
/**
* Badge content
*/
- children: ReactNode
+ children: ReactNode;
/**
* Visual variant
* @default 'default'
*/
- variant?: BadgeVariant
+ variant?: BadgeVariant;
/**
* Additional CSS classes
*/
- className?: string
+ className?: string;
}
const VARIANTS: Record = {
- default: 'brutal-border bg-carbon-black text-ochre-clay',
- primary: 'brutal-border bg-burnt-oxide text-ochre-clay',
+ default: 'brutal-border bg-carbon-black text-ochre-clay',
+ primary: 'brutal-border bg-burnt-oxide text-ochre-clay',
secondary: 'brutal-border bg-slate-indigo text-ochre-clay',
- outline: 'brutal-border bg-transparent text-carbon-black',
-}
+ outline: 'brutal-border bg-transparent text-carbon-black',
+};
/**
* Small label for categorization or status.
@@ -34,5 +34,5 @@ export function Badge({ children, variant = 'default', className }: Props) {
{children}
- )
+ );
}
diff --git a/src/shared/ui/Button/index.ts b/src/shared/ui/Button/index.ts
index ee20067..5ba8d18 100644
--- a/src/shared/ui/Button/index.ts
+++ b/src/shared/ui/Button/index.ts
@@ -1,2 +1,2 @@
-export { Button } from './ui/Button'
-export type { ButtonVariant, ButtonSize } from './ui/Button'
+export { Button } from './ui/Button';
+export type { ButtonVariant, ButtonSize } from './ui/Button';
diff --git a/src/shared/ui/Button/ui/Button.stories.tsx b/src/shared/ui/Button/ui/Button.stories.tsx
index 702c81c..26225f2 100644
--- a/src/shared/ui/Button/ui/Button.stories.tsx
+++ b/src/shared/ui/Button/ui/Button.stories.tsx
@@ -1,35 +1,49 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite'
-import { Button } from './Button'
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { Button } from './Button';
const meta: Meta = {
title: 'Shared/Button',
component: Button,
-}
+};
-export default meta
+export default meta;
-type Story = StoryObj
+type Story = StoryObj;
export const AllVariants: Story = {
render: () => (
- Primary
- Secondary
- Outline
- Ghost
+
+ Primary
+
+
+ Secondary
+
+
+ Outline
+
+
+ Ghost
+
),
-}
+};
export const Sizes: Story = {
render: () => (
- Small
- Medium
- Large
+
+ Small
+
+
+ Medium
+
+
+ Large
+
),
-}
+};
export const Disabled: Story = {
args: {
@@ -44,4 +58,4 @@ export const Disabled: Story = {
),
],
-}
+};
diff --git a/src/shared/ui/Button/ui/Button.test.tsx b/src/shared/ui/Button/ui/Button.test.tsx
index 48410b8..ffa2d81 100644
--- a/src/shared/ui/Button/ui/Button.test.tsx
+++ b/src/shared/ui/Button/ui/Button.test.tsx
@@ -1,67 +1,67 @@
-import { describe, it, expect, vi } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { Button } from './Button'
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Button } from './Button';
describe('Button', () => {
describe('rendering', () => {
it('renders children', () => {
- render(Click me )
- expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
- })
+ render(Click me );
+ expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
+ });
it('renders as button element', () => {
- render(Click )
- expect(screen.getByRole('button')).toBeInTheDocument()
- })
- })
+ render(Click );
+ expect(screen.getByRole('button')).toBeInTheDocument();
+ });
+ });
describe('variants', () => {
it('applies primary variant by default', () => {
- render(Go )
- expect(screen.getByRole('button')).toHaveClass('bg-burnt-oxide')
- })
+ render(Go );
+ expect(screen.getByRole('button')).toHaveClass('bg-burnt-oxide');
+ });
it('applies secondary variant', () => {
- render(Go )
- expect(screen.getByRole('button')).toHaveClass('bg-slate-indigo')
- })
+ render(Go );
+ expect(screen.getByRole('button')).toHaveClass('bg-slate-indigo');
+ });
it('applies outline variant', () => {
- render(Go )
- expect(screen.getByRole('button')).toHaveClass('bg-transparent')
- })
+ render(Go );
+ expect(screen.getByRole('button')).toHaveClass('bg-transparent');
+ });
it('applies ghost variant', () => {
- render(Go )
- expect(screen.getByRole('button')).toHaveClass('bg-ochre-clay')
- })
- })
+ render(Go );
+ expect(screen.getByRole('button')).toHaveClass('bg-ochre-clay');
+ });
+ });
describe('sizes', () => {
it('applies md size by default', () => {
- render(Go )
- expect(screen.getByRole('button')).toHaveClass('px-6', 'py-3')
- })
+ render(Go );
+ expect(screen.getByRole('button')).toHaveClass('px-6', 'py-3');
+ });
it('applies sm size', () => {
- render(Go )
- expect(screen.getByRole('button')).toHaveClass('px-4', 'py-2')
- })
+ render(Go );
+ expect(screen.getByRole('button')).toHaveClass('px-4', 'py-2');
+ });
it('applies lg size', () => {
- render(Go )
- expect(screen.getByRole('button')).toHaveClass('px-8', 'py-4')
- })
- })
+ render(Go );
+ expect(screen.getByRole('button')).toHaveClass('px-8', 'py-4');
+ });
+ });
describe('interactions', () => {
it('calls onClick when clicked', async () => {
- const onClick = vi.fn()
- render(Go )
- await userEvent.click(screen.getByRole('button'))
- expect(onClick).toHaveBeenCalledOnce()
- })
+ const onClick = vi.fn();
+ render(Go );
+ await userEvent.click(screen.getByRole('button'));
+ expect(onClick).toHaveBeenCalledOnce();
+ });
it('is disabled when disabled prop is set', () => {
- render(Go )
- expect(screen.getByRole('button')).toBeDisabled()
- })
- })
+ render(Go );
+ expect(screen.getByRole('button')).toBeDisabled();
+ });
+ });
describe('className passthrough', () => {
it('merges custom className', () => {
- render(Go )
- expect(screen.getByRole('button')).toHaveClass('w-full')
- })
- })
-})
+ render(Go );
+ expect(screen.getByRole('button')).toHaveClass('w-full');
+ });
+ });
+});
diff --git a/src/shared/ui/Button/ui/Button.tsx b/src/shared/ui/Button/ui/Button.tsx
index c1f256c..2fcabae 100644
--- a/src/shared/ui/Button/ui/Button.tsx
+++ b/src/shared/ui/Button/ui/Button.tsx
@@ -1,40 +1,41 @@
-import type { ButtonHTMLAttributes, ReactNode } from 'react'
-import { cn } from '$shared/lib'
+import type { ButtonHTMLAttributes, ReactNode } from 'react';
+import { cn } from '$shared/lib';
-export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost'
-export type ButtonSize = 'sm' | 'md' | 'lg'
+export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
+export type ButtonSize = 'sm' | 'md' | 'lg';
interface Props extends ButtonHTMLAttributes {
/**
* Visual variant
* @default 'primary'
*/
- variant?: ButtonVariant
+ variant?: ButtonVariant;
/**
* Size preset
* @default 'md'
*/
- size?: ButtonSize
+ size?: ButtonSize;
/**
* Button content
*/
- children: ReactNode
+ children: ReactNode;
}
const VARIANTS: Record = {
- primary: 'bg-burnt-oxide text-ochre-clay',
+ primary: 'bg-burnt-oxide text-ochre-clay',
secondary: 'bg-slate-indigo text-ochre-clay',
- outline: 'bg-transparent text-carbon-black border-carbon-black',
- ghost: 'bg-ochre-clay text-carbon-black border-carbon-black',
-}
+ outline: 'bg-transparent text-carbon-black border-carbon-black',
+ ghost: 'bg-ochre-clay text-carbon-black border-carbon-black',
+};
const SIZES: Record = {
sm: 'px-4 py-2 text-sm',
md: 'px-6 py-3 text-base',
lg: 'px-8 py-4 text-lg',
-}
+};
-const BASE = 'brutal-border brutal-shadow transition-all duration-200 hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[6px_6px_0_var(--carbon-black)] active:translate-x-[4px] active:translate-y-[4px] active:shadow-[4px_4px_0_var(--carbon-black)] uppercase tracking-wider'
+const BASE =
+ 'brutal-border brutal-shadow transition-all duration-200 hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[6px_6px_0_var(--carbon-black)] active:translate-x-[4px] active:translate-y-[4px] active:shadow-[4px_4px_0_var(--carbon-black)] uppercase tracking-wider';
/**
* Brutalist button with variants and sizes.
@@ -44,5 +45,5 @@ export function Button({ variant = 'primary', size = 'md', className, children,
{children}
- )
+ );
}
diff --git a/src/shared/ui/Card/index.ts b/src/shared/ui/Card/index.ts
index b5c2138..4c0afdc 100644
--- a/src/shared/ui/Card/index.ts
+++ b/src/shared/ui/Card/index.ts
@@ -1,2 +1,2 @@
-export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './ui/Card'
-export type { CardBackground } from './ui/Card'
+export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './ui/Card';
+export type { CardBackground } from './ui/Card';
diff --git a/src/shared/ui/Card/ui/Card.stories.tsx b/src/shared/ui/Card/ui/Card.stories.tsx
index 345ebe3..6aa8002 100644
--- a/src/shared/ui/Card/ui/Card.stories.tsx
+++ b/src/shared/ui/Card/ui/Card.stories.tsx
@@ -1,14 +1,14 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite'
-import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
const meta: Meta = {
title: 'Shared/Card',
component: Card,
-}
+};
-export default meta
+export default meta;
-type Story = StoryObj
+type Story = StoryObj;
export const AllBackgrounds: Story = {
render: () => (
@@ -36,19 +36,17 @@ export const AllBackgrounds: Story = {
),
-}
+};
export const NoPadding: Story = {
render: () => (
-
- Image placeholder
-
+ Image placeholder
),
-}
+};
export const FullComposition: Story = {
render: () => (
@@ -67,4 +65,4 @@ export const FullComposition: Story = {
),
-}
+};
diff --git a/src/shared/ui/Card/ui/Card.test.tsx b/src/shared/ui/Card/ui/Card.test.tsx
index 1d49f3d..3148804 100644
--- a/src/shared/ui/Card/ui/Card.test.tsx
+++ b/src/shared/ui/Card/ui/Card.test.tsx
@@ -1,79 +1,79 @@
-import { describe, it, expect } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
describe('Card', () => {
describe('rendering', () => {
it('renders children', () => {
- render(Content )
- expect(screen.getByText('Content')).toBeInTheDocument()
- })
+ render(Content );
+ expect(screen.getByText('Content')).toBeInTheDocument();
+ });
it('has brutal-border and brutal-shadow classes', () => {
- const { container } = render(Content )
- expect(container.firstChild).toHaveClass('brutal-border', 'brutal-shadow')
- })
- })
+ const { container } = render(Content );
+ expect(container.firstChild).toHaveClass('brutal-border', 'brutal-shadow');
+ });
+ });
describe('background variants', () => {
it('defaults to ochre background', () => {
- const { container } = render(Content )
- expect(container.firstChild).toHaveClass('bg-ochre-clay')
- })
+ const { container } = render(Content );
+ expect(container.firstChild).toHaveClass('bg-ochre-clay');
+ });
it('applies slate background', () => {
- const { container } = render(Content )
- expect(container.firstChild).toHaveClass('bg-slate-indigo')
- })
+ const { container } = render(Content );
+ expect(container.firstChild).toHaveClass('bg-slate-indigo');
+ });
it('applies white background', () => {
- const { container } = render(Content )
- expect(container.firstChild).toHaveClass('bg-white')
- })
- })
+ const { container } = render(Content );
+ expect(container.firstChild).toHaveClass('bg-white');
+ });
+ });
describe('padding', () => {
it('has default padding', () => {
- const { container } = render(Content )
- expect(container.firstChild).toHaveClass('p-6')
- })
+ const { container } = render(Content );
+ expect(container.firstChild).toHaveClass('p-6');
+ });
it('removes padding when noPadding is true', () => {
- const { container } = render(Content )
- expect(container.firstChild).not.toHaveClass('p-6')
- })
- })
+ const { container } = render(Content );
+ expect(container.firstChild).not.toHaveClass('p-6');
+ });
+ });
describe('className passthrough', () => {
it('merges custom className', () => {
- const { container } = render(Content )
- expect(container.firstChild).toHaveClass('group')
- })
- })
-})
+ const { container } = render(Content );
+ expect(container.firstChild).toHaveClass('group');
+ });
+ });
+});
describe('CardHeader', () => {
it('renders children with bottom margin', () => {
- render(Header )
- expect(screen.getByText('Header')).toHaveClass('mb-4')
- })
-})
+ render(Header );
+ expect(screen.getByText('Header')).toHaveClass('mb-4');
+ });
+});
describe('CardTitle', () => {
it('renders children as h3', () => {
- render(Title )
- expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Title')
- })
-})
+ render(Title );
+ expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Title');
+ });
+});
describe('CardDescription', () => {
it('renders children as paragraph with opacity', () => {
- render(Desc )
- const el = screen.getByText('Desc')
- expect(el.tagName).toBe('P')
- expect(el).toHaveClass('opacity-80')
- })
-})
+ render(Desc );
+ const el = screen.getByText('Desc');
+ expect(el.tagName).toBe('P');
+ expect(el).toHaveClass('opacity-80');
+ });
+});
describe('CardContent', () => {
it('renders children in a div', () => {
- render(Body )
- expect(screen.getByText('Body')).toBeInTheDocument()
- })
-})
+ render(Body );
+ expect(screen.getByText('Body')).toBeInTheDocument();
+ });
+});
describe('CardFooter', () => {
it('renders children with top border', () => {
- render(Footer )
- const el = screen.getByText('Footer')
- expect(el).toHaveClass('brutal-border-top', 'mt-6', 'pt-6')
- })
-})
+ render(Footer );
+ const el = screen.getByText('Footer');
+ expect(el).toHaveClass('brutal-border-top', 'mt-6', 'pt-6');
+ });
+});
diff --git a/src/shared/ui/Card/ui/Card.tsx b/src/shared/ui/Card/ui/Card.tsx
index cadf0d1..a637124 100644
--- a/src/shared/ui/Card/ui/Card.tsx
+++ b/src/shared/ui/Card/ui/Card.tsx
@@ -1,34 +1,34 @@
-import type { ReactNode } from 'react'
-import { cn } from '$shared/lib'
+import type { ReactNode } from 'react';
+import { cn } from '$shared/lib';
-export type CardBackground = 'ochre' | 'slate' | 'white'
+export type CardBackground = 'ochre' | 'slate' | 'white';
interface CardProps {
/**
* Card content
*/
- children: ReactNode
+ children: ReactNode;
/**
* Additional CSS classes
*/
- className?: string
+ className?: string;
/**
* Background color preset
* @default 'ochre'
*/
- background?: CardBackground
+ background?: CardBackground;
/**
* Remove default padding
* @default false
*/
- noPadding?: boolean
+ noPadding?: boolean;
}
const BG: Record = {
ochre: 'bg-ochre-clay',
slate: 'bg-slate-indigo text-ochre-clay',
white: 'bg-white',
-}
+};
/**
* Brutalist card container with background and padding variants.
@@ -38,51 +38,51 @@ export function Card({ children, className, background = 'ochre', noPadding = fa
{children}
- )
+ );
}
interface SlotProps {
/**
* Slot content
*/
- children: ReactNode
+ children: ReactNode;
/**
* Additional CSS classes
*/
- className?: string
+ className?: string;
}
/**
* Card header wrapper — adds bottom margin.
*/
export function CardHeader({ children, className }: SlotProps) {
- return {children}
+ return {children}
;
}
/**
* Card title — renders as h3.
*/
export function CardTitle({ children, className }: SlotProps) {
- return {children}
+ return {children} ;
}
/**
* Card description — muted paragraph below the title.
*/
export function CardDescription({ children, className }: SlotProps) {
- return {children}
+ return {children}
;
}
/**
* Card body content area.
*/
export function CardContent({ children, className }: SlotProps) {
- return {children}
+ return {children}
;
}
/**
* Card footer — separated by a brutal border-top.
*/
export function CardFooter({ children, className }: SlotProps) {
- return {children}
+ return {children}
;
}
diff --git a/src/shared/ui/Input/index.ts b/src/shared/ui/Input/index.ts
index a538399..12065cf 100644
--- a/src/shared/ui/Input/index.ts
+++ b/src/shared/ui/Input/index.ts
@@ -1 +1 @@
-export { Input, Textarea } from './ui/Input'
+export { Input, Textarea } from './ui/Input';
diff --git a/src/shared/ui/Input/ui/Input.stories.tsx b/src/shared/ui/Input/ui/Input.stories.tsx
index e89be1b..4700cde 100644
--- a/src/shared/ui/Input/ui/Input.stories.tsx
+++ b/src/shared/ui/Input/ui/Input.stories.tsx
@@ -1,5 +1,5 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite'
-import { Input, Textarea } from './Input'
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { Input, Textarea } from './Input';
const meta: Meta = {
title: 'Shared/Input',
@@ -11,35 +11,35 @@ const meta: Meta = {
),
],
-}
+};
-export default meta
+export default meta;
-type Story = StoryObj
+type Story = StoryObj;
export const Default: Story = {
args: {},
-}
+};
export const WithLabel: Story = {
args: {
label: 'Email address',
},
-}
+};
export const WithError: Story = {
args: {
label: 'Email',
error: 'This field is required',
},
-}
+};
export const WithPlaceholder: Story = {
args: {
placeholder: 'Enter your email',
type: 'email',
},
-}
+};
export const TextareaStory: Story = {
name: 'Textarea',
@@ -48,7 +48,7 @@ export const TextareaStory: Story = {
),
-}
+};
export const TextareaWithError: Story = {
render: () => (
@@ -56,4 +56,4 @@ export const TextareaWithError: Story = {
),
-}
+};
diff --git a/src/shared/ui/Input/ui/Input.test.tsx b/src/shared/ui/Input/ui/Input.test.tsx
index 2f8bb8e..814650a 100644
--- a/src/shared/ui/Input/ui/Input.test.tsx
+++ b/src/shared/ui/Input/ui/Input.test.tsx
@@ -1,110 +1,110 @@
-import { describe, it, expect } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import { Input, Textarea } from './Input'
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { Input, Textarea } from './Input';
describe('Input', () => {
describe('rendering', () => {
it('renders an input element', () => {
- render( )
- expect(screen.getByRole('textbox')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
+ });
it('renders label when provided', () => {
- render( )
- expect(screen.getByText('Email')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByText('Email')).toBeInTheDocument();
+ });
it('does not render label when omitted', () => {
- const { container } = render( )
- expect(container.querySelector('label')).toBeNull()
- })
+ const { container } = render( );
+ expect(container.querySelector('label')).toBeNull();
+ });
it('renders error message when provided', () => {
- render( )
- expect(screen.getByText('Required')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByText('Required')).toBeInTheDocument();
+ });
it('does not render error when omitted', () => {
- render( )
- expect(screen.queryByText('Required')).toBeNull()
- })
- })
+ render( );
+ expect(screen.queryByText('Required')).toBeNull();
+ });
+ });
describe('accessibility', () => {
it('label is associated with input via htmlFor/id', () => {
- render( )
- expect(screen.getByLabelText('Email')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByLabelText('Email')).toBeInTheDocument();
+ });
it('error span is referenced by aria-describedby', () => {
- render( )
- const input = screen.getByRole('textbox')
- const errorId = input.getAttribute('aria-describedby')
- expect(errorId).toBeTruthy()
- expect(document.getElementById(errorId!)).toHaveTextContent('Required')
- })
+ render( );
+ const input = screen.getByRole('textbox');
+ const errorId = input.getAttribute('aria-describedby');
+ expect(errorId).toBeTruthy();
+ expect(document.getElementById(errorId!)).toHaveTextContent('Required');
+ });
it('no aria-describedby when no error', () => {
- render( )
- expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby')
- })
+ render( );
+ expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby');
+ });
it('uses provided id prop', () => {
- render( )
- expect(screen.getByLabelText('Email')).toHaveAttribute('id', 'my-input')
- })
- })
+ render( );
+ expect(screen.getByLabelText('Email')).toHaveAttribute('id', 'my-input');
+ });
+ });
describe('styling', () => {
it('has brutal-border class', () => {
- render( )
- expect(screen.getByRole('textbox')).toHaveClass('brutal-border')
- })
+ render( );
+ expect(screen.getByRole('textbox')).toHaveClass('brutal-border');
+ });
it('applies custom className', () => {
- render( )
- expect(screen.getByRole('textbox')).toHaveClass('w-full')
- })
- })
+ render( );
+ expect(screen.getByRole('textbox')).toHaveClass('w-full');
+ });
+ });
describe('forwarded props', () => {
it('passes placeholder to input', () => {
- render( )
- expect(screen.getByPlaceholderText('Enter email')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByPlaceholderText('Enter email')).toBeInTheDocument();
+ });
it('passes type to input', () => {
- render( )
- expect(screen.getByRole('textbox')).toHaveAttribute('type', 'email')
- })
- })
-})
+ render( );
+ expect(screen.getByRole('textbox')).toHaveAttribute('type', 'email');
+ });
+ });
+});
describe('Textarea', () => {
describe('rendering', () => {
it('renders a textarea element', () => {
- render()
- expect(screen.getByRole('textbox')).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
+ });
it('renders label when provided', () => {
- render()
- expect(screen.getByText('Message')).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByText('Message')).toBeInTheDocument();
+ });
it('renders error when provided', () => {
- render()
- expect(screen.getByText('Too short')).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByText('Too short')).toBeInTheDocument();
+ });
it('defaults to 4 rows', () => {
- render()
- expect(screen.getByRole('textbox')).toHaveAttribute('rows', '4')
- })
+ render();
+ expect(screen.getByRole('textbox')).toHaveAttribute('rows', '4');
+ });
it('accepts custom rows', () => {
- render()
- expect(screen.getByRole('textbox')).toHaveAttribute('rows', '8')
- })
- })
+ render();
+ expect(screen.getByRole('textbox')).toHaveAttribute('rows', '8');
+ });
+ });
describe('accessibility', () => {
it('label is associated with textarea via htmlFor/id', () => {
- render()
- expect(screen.getByLabelText('Message')).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByLabelText('Message')).toBeInTheDocument();
+ });
it('error span is referenced by aria-describedby', () => {
- render()
- const textarea = screen.getByRole('textbox')
- const errorId = textarea.getAttribute('aria-describedby')
- expect(errorId).toBeTruthy()
- expect(document.getElementById(errorId!)).toHaveTextContent('Too short')
- })
+ render();
+ const textarea = screen.getByRole('textbox');
+ const errorId = textarea.getAttribute('aria-describedby');
+ expect(errorId).toBeTruthy();
+ expect(document.getElementById(errorId!)).toHaveTextContent('Too short');
+ });
it('no aria-describedby when no error', () => {
- render()
- expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby')
- })
- })
-})
+ render();
+ expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby');
+ });
+ });
+});
diff --git a/src/shared/ui/Input/ui/Input.tsx b/src/shared/ui/Input/ui/Input.tsx
index cba7023..bbd9591 100644
--- a/src/shared/ui/Input/ui/Input.tsx
+++ b/src/shared/ui/Input/ui/Input.tsx
@@ -1,68 +1,81 @@
-import { useId, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react'
-import { cn } from '$shared/lib'
+import { useId, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react';
+import { cn } from '$shared/lib';
interface InputProps extends InputHTMLAttributes {
/**
* Visible label rendered above the input
*/
- label?: string
+ label?: string;
/**
* Validation error shown below the input
*/
- error?: string
+ error?: string;
}
-const INPUT_BASE = 'brutal-border bg-white px-4 py-3 text-carbon-black focus:outline-none focus:ring-2 focus:ring-burnt-oxide focus:ring-offset-2 focus:ring-offset-ochre-clay transition-all'
+const INPUT_BASE =
+ 'brutal-border bg-white px-4 py-3 text-carbon-black focus:outline-none focus:ring-2 focus:ring-burnt-oxide focus:ring-offset-2 focus:ring-offset-ochre-clay transition-all';
/**
* Text input with optional label and error state.
*/
export function Input({ label, error, className, id, ...props }: InputProps) {
- const generatedId = useId()
- const inputId = id ?? generatedId
- const errorId = `${inputId}-error`
+ const generatedId = useId();
+ const inputId = id ?? generatedId;
+ const errorId = `${inputId}-error`;
return (
- {label && {label} }
+ {label && (
+
+ {label}
+
+ )}
- {error && {error} }
+ {error && (
+
+ {error}
+
+ )}
- )
+ );
}
interface TextareaProps extends TextareaHTMLAttributes {
/**
* Visible label rendered above the textarea
*/
- label?: string
+ label?: string;
/**
* Validation error shown below the textarea
*/
- error?: string
+ error?: string;
/**
* Number of visible rows
* @default 4
*/
- rows?: number
+ rows?: number;
}
/**
* Multiline textarea with optional label and error state.
*/
export function Textarea({ label, error, rows = 4, className, id, ...props }: TextareaProps) {
- const generatedId = useId()
- const textareaId = id ?? generatedId
- const errorId = `${textareaId}-error`
+ const generatedId = useId();
+ const textareaId = id ?? generatedId;
+ const errorId = `${textareaId}-error`;
return (
- {label && {label} }
+ {label && (
+
+ {label}
+
+ )}
- {error && {error} }
+ {error && (
+
+ {error}
+
+ )}
- )
+ );
}
diff --git a/src/shared/ui/Section/index.ts b/src/shared/ui/Section/index.ts
index 8f6b80e..8cce92c 100644
--- a/src/shared/ui/Section/index.ts
+++ b/src/shared/ui/Section/index.ts
@@ -1,2 +1,2 @@
-export { Section, Container } from './ui/Section'
-export type { SectionBackground, ContainerSize } from './ui/Section'
+export { Section, Container } from './ui/Section';
+export type { SectionBackground, ContainerSize } from './ui/Section';
diff --git a/src/shared/ui/Section/ui/Section.stories.tsx b/src/shared/ui/Section/ui/Section.stories.tsx
index 8fdf423..575daf7 100644
--- a/src/shared/ui/Section/ui/Section.stories.tsx
+++ b/src/shared/ui/Section/ui/Section.stories.tsx
@@ -1,14 +1,14 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite'
-import { Section, Container } from './Section'
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { Section, Container } from './Section';
const meta: Meta = {
title: 'Shared/Section',
component: Section,
-}
+};
-export default meta
+export default meta;
-type Story = StoryObj
+type Story = StoryObj;
export const AllBackgrounds: Story = {
render: () => (
@@ -16,32 +16,44 @@ export const AllBackgrounds: Story = {
Ochre Section
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
+ dolore magna aliqua.
+
Slate Section
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
+ dolore magna aliqua.
+
White Section
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
+ dolore magna aliqua.
+
),
-}
+};
export const Bordered: Story = {
render: () => (
Bordered Section
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore
+ magna aliqua.
+
),
-}
+};
diff --git a/src/shared/ui/Section/ui/Section.test.tsx b/src/shared/ui/Section/ui/Section.test.tsx
index 849cafe..b22acbb 100644
--- a/src/shared/ui/Section/ui/Section.test.tsx
+++ b/src/shared/ui/Section/ui/Section.test.tsx
@@ -1,95 +1,103 @@
-import { describe, it, expect } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import { Section, Container } from './Section'
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { Section, Container } from './Section';
describe('Section', () => {
describe('rendering', () => {
it('renders a section element', () => {
- const { container } = render()
- expect(container.querySelector('section')).toBeInTheDocument()
- })
+ const { container } = render();
+ expect(container.querySelector('section')).toBeInTheDocument();
+ });
it('renders children', () => {
- render()
- expect(screen.getByText('hello')).toBeInTheDocument()
- })
- })
+ render(
+ ,
+ );
+ expect(screen.getByText('hello')).toBeInTheDocument();
+ });
+ });
describe('background variants', () => {
it('defaults to ochre background', () => {
- const { container } = render()
- expect(container.querySelector('section')).toHaveClass('bg-ochre-clay', 'text-carbon-black')
- })
+ const { container } = render();
+ expect(container.querySelector('section')).toHaveClass('bg-ochre-clay', 'text-carbon-black');
+ });
it('applies slate background', () => {
- const { container } = render()
- expect(container.querySelector('section')).toHaveClass('bg-slate-indigo', 'text-ochre-clay')
- })
+ const { container } = render();
+ expect(container.querySelector('section')).toHaveClass('bg-slate-indigo', 'text-ochre-clay');
+ });
it('applies white background', () => {
- const { container } = render()
- expect(container.querySelector('section')).toHaveClass('bg-white', 'text-carbon-black')
- })
- })
+ const { container } = render();
+ expect(container.querySelector('section')).toHaveClass('bg-white', 'text-carbon-black');
+ });
+ });
describe('bordered', () => {
it('no border classes by default', () => {
- const { container } = render()
- const el = container.querySelector('section')!
- expect(el).not.toHaveClass('brutal-border-top')
- expect(el).not.toHaveClass('brutal-border-bottom')
- })
+ const { container } = render();
+ const el = container.querySelector('section')!;
+ expect(el).not.toHaveClass('brutal-border-top');
+ expect(el).not.toHaveClass('brutal-border-bottom');
+ });
it('adds top and bottom borders when bordered=true', () => {
- const { container } = render()
- const el = container.querySelector('section')!
- expect(el).toHaveClass('brutal-border-top')
- expect(el).toHaveClass('brutal-border-bottom')
- })
- })
+ const { container } = render();
+ const el = container.querySelector('section')!;
+ expect(el).toHaveClass('brutal-border-top');
+ expect(el).toHaveClass('brutal-border-bottom');
+ });
+ });
describe('className', () => {
it('applies custom className', () => {
- const { container } = render()
- expect(container.querySelector('section')).toHaveClass('py-16')
- })
- })
-})
+ const { container } = render();
+ expect(container.querySelector('section')).toHaveClass('py-16');
+ });
+ });
+});
describe('Container', () => {
describe('rendering', () => {
it('renders a div with children', () => {
- render(inner )
- expect(screen.getByText('inner')).toBeInTheDocument()
- })
- })
+ render(
+
+ inner
+ ,
+ );
+ expect(screen.getByText('inner')).toBeInTheDocument();
+ });
+ });
describe('size variants', () => {
it('defaults to max-w-7xl', () => {
- const { container } = render(x )
- expect(container.firstChild).toHaveClass('max-w-7xl')
- })
+ const { container } = render(x );
+ expect(container.firstChild).toHaveClass('max-w-7xl');
+ });
it('wide applies max-w-[1920px]', () => {
- const { container } = render(x )
- expect(container.firstChild).toHaveClass('max-w-[1920px]')
- })
+ const { container } = render(x );
+ expect(container.firstChild).toHaveClass('max-w-[1920px]');
+ });
it('ultra-wide applies max-w-[2560px]', () => {
- const { container } = render(x )
- expect(container.firstChild).toHaveClass('max-w-[2560px]')
- })
- })
+ const { container } = render(x );
+ expect(container.firstChild).toHaveClass('max-w-[2560px]');
+ });
+ });
describe('layout', () => {
it('centers content horizontally', () => {
- const { container } = render(x )
- expect(container.firstChild).toHaveClass('mx-auto')
- })
+ const { container } = render(x );
+ expect(container.firstChild).toHaveClass('mx-auto');
+ });
it('applies horizontal padding', () => {
- const { container } = render(x )
- expect(container.firstChild).toHaveClass('px-6')
- })
- })
+ const { container } = render(x );
+ expect(container.firstChild).toHaveClass('px-6');
+ });
+ });
describe('className', () => {
it('applies custom className', () => {
- const { container } = render(x )
- expect(container.firstChild).toHaveClass('my-custom')
- })
- })
-})
+ const { container } = render(x );
+ expect(container.firstChild).toHaveClass('my-custom');
+ });
+ });
+});
diff --git a/src/shared/ui/Section/ui/Section.tsx b/src/shared/ui/Section/ui/Section.tsx
index ea2ffbb..5edf379 100644
--- a/src/shared/ui/Section/ui/Section.tsx
+++ b/src/shared/ui/Section/ui/Section.tsx
@@ -1,82 +1,72 @@
-import type { ReactNode } from 'react'
-import { cn } from '$shared/lib'
+import type { ReactNode } from 'react';
+import { cn } from '$shared/lib';
-export type SectionBackground = 'ochre' | 'slate' | 'white'
-export type ContainerSize = 'default' | 'wide' | 'ultra-wide'
+export type SectionBackground = 'ochre' | 'slate' | 'white';
+export type ContainerSize = 'default' | 'wide' | 'ultra-wide';
interface SectionProps {
/**
* Section content
*/
- children: ReactNode
+ children: ReactNode;
/**
* Background color variant
* @default 'ochre'
*/
- background?: SectionBackground
+ background?: SectionBackground;
/**
* Adds top and bottom brutal borders
* @default false
*/
- bordered?: boolean
+ bordered?: boolean;
/**
* CSS classes
*/
- className?: string
+ className?: string;
}
const BACKGROUNDS: Record = {
ochre: 'bg-ochre-clay text-carbon-black',
slate: 'bg-slate-indigo text-ochre-clay',
white: 'bg-white text-carbon-black',
-}
+};
/**
* Full-width page section with background and optional borders.
*/
export function Section({ children, background = 'ochre', bordered = false, className }: SectionProps) {
return (
-
+
- )
+ );
}
interface ContainerProps {
/**
* Container content
*/
- children: ReactNode
+ children: ReactNode;
/**
* Max-width constraint
* @default 'default'
*/
- size?: ContainerSize
+ size?: ContainerSize;
/**
* CSS classes
*/
- className?: string
+ className?: string;
}
const SIZES: Record = {
- 'default': 'max-w-7xl',
- 'wide': 'max-w-[1920px]',
+ default: 'max-w-7xl',
+ wide: 'max-w-[1920px]',
'ultra-wide': 'max-w-[2560px]',
-}
+};
/**
* Centered content container with responsive horizontal padding.
*/
export function Container({ children, size = 'default', className }: ContainerProps) {
- return (
-
- {children}
-
- )
+ return {children}
;
}
diff --git a/src/shared/ui/SectionAccordion/index.ts b/src/shared/ui/SectionAccordion/index.ts
deleted file mode 100644
index 5af53e2..0000000
--- a/src/shared/ui/SectionAccordion/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { SectionAccordion } from './ui/SectionAccordion'
diff --git a/src/shared/ui/TechStack/index.ts b/src/shared/ui/TechStack/index.ts
index 7b8d6da..57f6946 100644
--- a/src/shared/ui/TechStack/index.ts
+++ b/src/shared/ui/TechStack/index.ts
@@ -1 +1 @@
-export { TechStackBrick, TechStackGrid } from './ui/TechStack'
+export { TechStackBrick, TechStackGrid } from './ui/TechStack';
diff --git a/src/shared/ui/TechStack/ui/TechStack.stories.tsx b/src/shared/ui/TechStack/ui/TechStack.stories.tsx
index ee2fc6c..2b3aa86 100644
--- a/src/shared/ui/TechStack/ui/TechStack.stories.tsx
+++ b/src/shared/ui/TechStack/ui/TechStack.stories.tsx
@@ -1,5 +1,5 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite'
-import { TechStackGrid, TechStackBrick } from './TechStack'
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { TechStackGrid, TechStackBrick } from './TechStack';
const meta: Meta = {
title: 'Shared/TechStack',
@@ -11,11 +11,11 @@ const meta: Meta = {
),
],
-}
+};
-export default meta
+export default meta;
-type Story = StoryObj
+type Story = StoryObj;
export const Grid: Story = {
args: {
@@ -34,7 +34,7 @@ export const Grid: Story = {
'Rust',
],
},
-}
+};
export const SingleBrick: Story = {
render: () => (
@@ -42,4 +42,4 @@ export const SingleBrick: Story = {
),
-}
+};
diff --git a/src/shared/ui/TechStack/ui/TechStack.test.tsx b/src/shared/ui/TechStack/ui/TechStack.test.tsx
index 21cd240..76426ba 100644
--- a/src/shared/ui/TechStack/ui/TechStack.test.tsx
+++ b/src/shared/ui/TechStack/ui/TechStack.test.tsx
@@ -1,62 +1,62 @@
-import { describe, it, expect } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import { TechStackBrick, TechStackGrid } from './TechStack'
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { TechStackBrick, TechStackGrid } from './TechStack';
describe('TechStackBrick', () => {
describe('rendering', () => {
it('renders the technology name', () => {
- render( )
- expect(screen.getByText('TypeScript')).toBeInTheDocument()
- })
- })
+ render( );
+ expect(screen.getByText('TypeScript')).toBeInTheDocument();
+ });
+ });
describe('styling', () => {
it('has brutal-border class', () => {
- const { container } = render( )
- expect(container.firstChild).toHaveClass('brutal-border')
- })
+ const { container } = render( );
+ expect(container.firstChild).toHaveClass('brutal-border');
+ });
it('has brutal-shadow class', () => {
- const { container } = render( )
- expect(container.firstChild).toHaveClass('brutal-shadow')
- })
+ const { container } = render( );
+ expect(container.firstChild).toHaveClass('brutal-shadow');
+ });
it('name span has uppercase and tracking-wide', () => {
- render( )
- const span = screen.getByText('Go')
- expect(span).toHaveClass('uppercase', 'tracking-wide')
- })
+ render( );
+ const span = screen.getByText('Go');
+ expect(span).toHaveClass('uppercase', 'tracking-wide');
+ });
it('applies custom className', () => {
- const { container } = render( )
- expect(container.firstChild).toHaveClass('w-full')
- })
- })
-})
+ const { container } = render( );
+ expect(container.firstChild).toHaveClass('w-full');
+ });
+ });
+});
describe('TechStackGrid', () => {
describe('rendering', () => {
it('renders all skill names', () => {
- render( )
- expect(screen.getByText('React')).toBeInTheDocument()
- expect(screen.getByText('TypeScript')).toBeInTheDocument()
- expect(screen.getByText('Go')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByText('React')).toBeInTheDocument();
+ expect(screen.getByText('TypeScript')).toBeInTheDocument();
+ expect(screen.getByText('Go')).toBeInTheDocument();
+ });
it('renders correct number of bricks', () => {
- const { container } = render( )
- expect(container.firstChild!.childNodes).toHaveLength(3)
- })
+ const { container } = render( );
+ expect(container.firstChild!.childNodes).toHaveLength(3);
+ });
it('renders empty grid with no skills', () => {
- const { container } = render( )
- expect(container.firstChild!.childNodes).toHaveLength(0)
- })
- })
+ const { container } = render( );
+ expect(container.firstChild!.childNodes).toHaveLength(0);
+ });
+ });
describe('layout', () => {
it('has grid class', () => {
- const { container } = render( )
- expect(container.firstChild).toHaveClass('grid')
- })
+ const { container } = render( );
+ expect(container.firstChild).toHaveClass('grid');
+ });
it('applies custom className', () => {
- const { container } = render( )
- expect(container.firstChild).toHaveClass('my-custom')
- })
- })
-})
+ const { container } = render( );
+ expect(container.firstChild).toHaveClass('my-custom');
+ });
+ });
+});
diff --git a/src/shared/ui/TechStack/ui/TechStack.tsx b/src/shared/ui/TechStack/ui/TechStack.tsx
index f7fe5de..86292c1 100644
--- a/src/shared/ui/TechStack/ui/TechStack.tsx
+++ b/src/shared/ui/TechStack/ui/TechStack.tsx
@@ -1,14 +1,14 @@
-import { cn } from '$shared/lib'
+import { cn } from '$shared/lib';
interface TechStackBrickProps {
/**
* Technology name displayed in the brick
*/
- name: string
+ name: string;
/**
* CSS classes
*/
- className?: string
+ className?: string;
}
/**
@@ -25,18 +25,18 @@ export function TechStackBrick({ name, className }: TechStackBrickProps) {
>
{name}
- )
+ );
}
interface TechStackGridProps {
/**
* List of technology names to render as bricks
*/
- skills: string[]
+ skills: string[];
/**
* CSS classes
*/
- className?: string
+ className?: string;
}
/**
@@ -45,14 +45,11 @@ interface TechStackGridProps {
export function TechStackGrid({ skills, className }: TechStackGridProps) {
return (
{skills.map((skill, index) => (
))}
- )
+ );
}
diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts
index 45e7d09..2c4ac10 100644
--- a/src/shared/ui/index.ts
+++ b/src/shared/ui/index.ts
@@ -1,17 +1,17 @@
-export { Badge } from './Badge'
-export type { BadgeVariant } from './Badge'
+export { Badge } from './Badge';
+export type { BadgeVariant } from './Badge';
-export { Button } from './Button'
-export type { ButtonVariant, ButtonSize } from './Button'
+export { Button } from './Button';
+export type { ButtonVariant, ButtonSize } from './Button';
-export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'
-export type { CardBackground } from './Card'
+export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
+export type { CardBackground } from './Card';
-export { Input, Textarea } from './Input'
+export { Input, Textarea } from './Input';
-export { Section, Container } from './Section'
-export type { SectionBackground, ContainerSize } from './Section'
+export { Section, Container } from './Section';
+export type { SectionBackground, ContainerSize } from './Section';
-export { SectionAccordion } from './SectionAccordion'
+export { SectionAccordion } from './SectionAccordion';
-export { TechStackBrick, TechStackGrid } from './TechStack'
+export { TechStackBrick, TechStackGrid } from './TechStack';
diff --git a/src/test/setup.ts b/src/test/setup.ts
index c44951a..7b0828b 100644
--- a/src/test/setup.ts
+++ b/src/test/setup.ts
@@ -1 +1 @@
-import '@testing-library/jest-dom'
+import '@testing-library/jest-dom';
diff --git a/src/widgets/Navigation/index.ts b/src/widgets/Navigation/index.ts
index 2c117b2..3745917 100644
--- a/src/widgets/Navigation/index.ts
+++ b/src/widgets/Navigation/index.ts
@@ -1,4 +1,4 @@
-export { MobileNav } from './ui/MobileNav'
-export { SidebarNav } from './ui/SidebarNav'
-export { UtilityBar } from './ui/UtilityBar'
-export type { NavItem } from './model/types'
+export { MobileNav } from './ui/MobileNav';
+export { SidebarNav } from './ui/SidebarNav';
+export { UtilityBar } from './ui/UtilityBar';
+export type { NavItem } from './model/types';
diff --git a/src/widgets/Navigation/model/types.ts b/src/widgets/Navigation/model/types.ts
index e23d3a7..c8e0334 100644
--- a/src/widgets/Navigation/model/types.ts
+++ b/src/widgets/Navigation/model/types.ts
@@ -2,13 +2,13 @@ export type NavItem = {
/**
* Section HTML id for anchor scrolling
*/
- id: string
+ id: string;
/**
* Display label
*/
- label: string
+ label: string;
/**
* Display number prefix (e.g. "01")
*/
- number: string
-}
+ number: string;
+};
diff --git a/src/widgets/Navigation/ui/MobileNav.stories.tsx b/src/widgets/Navigation/ui/MobileNav.stories.tsx
index 93fa697..8b44421 100644
--- a/src/widgets/Navigation/ui/MobileNav.stories.tsx
+++ b/src/widgets/Navigation/ui/MobileNav.stories.tsx
@@ -1,5 +1,5 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite'
-import { MobileNav } from './MobileNav'
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { MobileNav } from './MobileNav';
// MobileNav is lg:hidden — it renders only on mobile viewports.
// Use the viewport toolbar in Storybook to switch to a mobile size to see it.
@@ -11,11 +11,11 @@ const meta: Meta = {
defaultViewport: 'mobile1',
},
},
-}
+};
-export default meta
+export default meta;
-type Story = StoryObj
+type Story = StoryObj;
export const Default: Story = {
args: {
@@ -25,4 +25,4 @@ export const Default: Story = {
{ id: 'contact', label: 'Contact', number: '03' },
],
},
-}
+};
diff --git a/src/widgets/Navigation/ui/MobileNav.test.tsx b/src/widgets/Navigation/ui/MobileNav.test.tsx
index 2addc9d..478e964 100644
--- a/src/widgets/Navigation/ui/MobileNav.test.tsx
+++ b/src/widgets/Navigation/ui/MobileNav.test.tsx
@@ -1,46 +1,46 @@
-import { describe, it, expect, vi } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { MobileNav } from './MobileNav'
-import type { NavItem } from '../model/types'
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MobileNav } from './MobileNav';
+import type { NavItem } from '../model/types';
-const ITEMS: NavItem[] = [{ id: 'about', label: 'About', number: '01' }]
+const ITEMS: NavItem[] = [{ id: 'about', label: 'About', number: '01' }];
describe('MobileNav', () => {
describe('rendering', () => {
it('renders title "allmy.work"', () => {
- render( )
- expect(screen.getByText('allmy.work')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByText('allmy.work')).toBeInTheDocument();
+ });
it('renders toggle button with text "Menu" initially', () => {
- render( )
- expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
+ });
it('menu items are hidden initially', () => {
- render( )
- expect(screen.queryByRole('button', { name: /about/i })).not.toBeInTheDocument()
- })
- })
+ render( );
+ expect(screen.queryByRole('button', { name: /about/i })).not.toBeInTheDocument();
+ });
+ });
describe('interactions', () => {
it('click toggle shows item buttons and changes label to "Close"', async () => {
- render( )
- await userEvent.click(screen.getByRole('button', { name: 'Menu' }))
- expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
- expect(screen.getByText('About')).toBeInTheDocument()
- })
+ render( );
+ await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
+ expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
+ expect(screen.getByText('About')).toBeInTheDocument();
+ });
it('click item button closes the menu', async () => {
- render( )
- await userEvent.click(screen.getByRole('button', { name: 'Menu' }))
+ render( );
+ await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
// item button label contains number + label text; find by accessible name fragment
- const itemBtn = screen.getAllByRole('button').find(b => b.textContent?.includes('About'))
- expect(itemBtn).toBeDefined()
- await userEvent.click(itemBtn!)
- expect(screen.queryByText('Close')).not.toBeInTheDocument()
- expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument()
- })
- })
-})
+ const itemBtn = screen.getAllByRole('button').find((b) => b.textContent?.includes('About'));
+ expect(itemBtn).toBeDefined();
+ await userEvent.click(itemBtn!);
+ expect(screen.queryByText('Close')).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/widgets/Navigation/ui/MobileNav.tsx b/src/widgets/Navigation/ui/MobileNav.tsx
index 0940ed8..dccc0b0 100644
--- a/src/widgets/Navigation/ui/MobileNav.tsx
+++ b/src/widgets/Navigation/ui/MobileNav.tsx
@@ -1,32 +1,32 @@
-'use client'
+'use client';
-import { useState } from 'react'
-import { cn } from '$shared/lib'
-import type { NavItem } from '../model/types'
+import { useState } from 'react';
+import { cn } from '$shared/lib';
+import type { NavItem } from '../model/types';
interface Props {
/**
* Navigation items to render
*/
- items: NavItem[]
+ items: NavItem[];
}
/**
* Mobile navigation overlay, hidden on lg+ screens.
*/
export function MobileNav({ items }: Props) {
- const [isOpen, setIsOpen] = useState(false)
+ const [isOpen, setIsOpen] = useState(false);
/**
* Scrolls to the section by id with a 100px offset, then closes the menu.
*/
function scrollToSection(id: string) {
- const el = document.getElementById(id)
+ const el = document.getElementById(id);
if (el) {
- const top = el.getBoundingClientRect().top + window.scrollY - 100
- window.scrollTo({ top, behavior: 'smooth' })
+ const top = el.getBoundingClientRect().top + window.scrollY - 100;
+ window.scrollTo({ top, behavior: 'smooth' });
}
- setIsOpen(false)
+ setIsOpen(false);
}
return (
@@ -34,7 +34,7 @@ export function MobileNav({ items }: Props) {
allmy.work
setIsOpen(prev => !prev)}
+ onClick={() => setIsOpen((prev) => !prev)}
className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay"
>
{isOpen ? 'Close' : 'Menu'}
@@ -42,7 +42,7 @@ export function MobileNav({ items }: Props) {
{isOpen && (
- {items.map(item => (
+ {items.map((item) => (
scrollToSection(item.id)}
@@ -62,5 +62,5 @@ export function MobileNav({ items }: Props) {
)}
- )
+ );
}
diff --git a/src/widgets/Navigation/ui/SidebarNav.stories.tsx b/src/widgets/Navigation/ui/SidebarNav.stories.tsx
index f4b3917..2c98381 100644
--- a/src/widgets/Navigation/ui/SidebarNav.stories.tsx
+++ b/src/widgets/Navigation/ui/SidebarNav.stories.tsx
@@ -1,5 +1,5 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite'
-import { SidebarNav } from './SidebarNav'
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { SidebarNav } from './SidebarNav';
// SidebarNav is hidden lg:block — it renders only on desktop viewports.
// Use the viewport toolbar in Storybook to switch to a desktop size to see it.
@@ -12,11 +12,11 @@ const meta: Meta = {
defaultViewport: 'desktop',
},
},
-}
+};
-export default meta
+export default meta;
-type Story = StoryObj
+type Story = StoryObj;
export const Default: Story = {
args: {
@@ -26,4 +26,4 @@ export const Default: Story = {
{ id: 'contact', label: 'Contact', number: '03' },
],
},
-}
+};
diff --git a/src/widgets/Navigation/ui/SidebarNav.test.tsx b/src/widgets/Navigation/ui/SidebarNav.test.tsx
index d9f9784..75a7355 100644
--- a/src/widgets/Navigation/ui/SidebarNav.test.tsx
+++ b/src/widgets/Navigation/ui/SidebarNav.test.tsx
@@ -1,12 +1,12 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import { SidebarNav } from './SidebarNav'
-import type { NavItem } from '../model/types'
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { SidebarNav } from './SidebarNav';
+import type { NavItem } from '../model/types';
const ITEMS: NavItem[] = [
{ id: 'bio', label: 'Bio', number: '01' },
{ id: 'work', label: 'Work', number: '02' },
-]
+];
beforeEach(() => {
global.IntersectionObserver = vi.fn(function () {
@@ -14,49 +14,49 @@ beforeEach(() => {
observe: vi.fn(),
disconnect: vi.fn(),
unobserve: vi.fn(),
- }
- }) as unknown as typeof IntersectionObserver
-})
+ };
+ }) as unknown as typeof IntersectionObserver;
+});
describe('SidebarNav', () => {
describe('rendering', () => {
it('renders a nav element', () => {
- render( )
- expect(screen.getByRole('navigation')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByRole('navigation')).toBeInTheDocument();
+ });
it('renders "Index" heading', () => {
- render( )
- expect(screen.getByText('Index')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByText('Index')).toBeInTheDocument();
+ });
it('renders "Digital Monograph" subtitle', () => {
- render( )
- expect(screen.getByText('Digital Monograph')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByText('Digital Monograph')).toBeInTheDocument();
+ });
it('renders each item label and number', () => {
- render( )
- expect(screen.getByText('Bio')).toBeInTheDocument()
- expect(screen.getByText('01')).toBeInTheDocument()
- expect(screen.getByText('Work')).toBeInTheDocument()
- expect(screen.getByText('02')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByText('Bio')).toBeInTheDocument();
+ expect(screen.getByText('01')).toBeInTheDocument();
+ expect(screen.getByText('Work')).toBeInTheDocument();
+ expect(screen.getByText('02')).toBeInTheDocument();
+ });
it('renders "Quick Links" section', () => {
- render( )
- expect(screen.getByText('Quick Links')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByText('Quick Links')).toBeInTheDocument();
+ });
it('renders Email quick link', () => {
- render( )
- expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument();
+ });
it('renders a button for each item', () => {
- render( )
- const buttons = screen.getAllByRole('button')
- expect(buttons.length).toBeGreaterThanOrEqual(ITEMS.length)
- })
- })
-})
+ render( );
+ const buttons = screen.getAllByRole('button');
+ expect(buttons.length).toBeGreaterThanOrEqual(ITEMS.length);
+ });
+ });
+});
diff --git a/src/widgets/Navigation/ui/SidebarNav.tsx b/src/widgets/Navigation/ui/SidebarNav.tsx
index 9962b03..852b17a 100644
--- a/src/widgets/Navigation/ui/SidebarNav.tsx
+++ b/src/widgets/Navigation/ui/SidebarNav.tsx
@@ -1,50 +1,50 @@
-'use client'
+'use client';
-import { useState, useEffect } from 'react'
-import { cn } from '$shared/lib'
-import type { NavItem } from '../model/types'
+import { useState, useEffect } from 'react';
+import { cn } from '$shared/lib';
+import type { NavItem } from '../model/types';
interface Props {
/**
* Navigation items to render
*/
- items: NavItem[]
+ items: NavItem[];
}
/**
* Fixed sidebar navigation, visible on lg+ screens.
*/
export function SidebarNav({ items }: Props) {
- const [activeSection, setActiveSection] = useState('bio')
+ const [activeSection, setActiveSection] = useState('bio');
useEffect(() => {
const observer = new IntersectionObserver(
- entries => {
- entries.forEach(entry => {
+ (entries) => {
+ entries.forEach((entry) => {
if (entry.isIntersecting) {
- setActiveSection(entry.target.id)
+ setActiveSection(entry.target.id);
}
- })
+ });
},
{ rootMargin: '-20% 0px -70% 0px', threshold: 0 },
- )
+ );
- items.forEach(item => {
- const el = document.getElementById(item.id)
- if (el) observer.observe(el)
- })
+ items.forEach((item) => {
+ const el = document.getElementById(item.id);
+ if (el) observer.observe(el);
+ });
- return () => observer.disconnect()
- }, [items])
+ return () => observer.disconnect();
+ }, [items]);
/**
* Scrolls to the section by id with a 40px offset.
*/
function scrollToSection(id: string) {
- const el = document.getElementById(id)
+ const el = document.getElementById(id);
if (el) {
- const top = el.getBoundingClientRect().top + window.scrollY - 40
- window.scrollTo({ top, behavior: 'smooth' })
+ const top = el.getBoundingClientRect().top + window.scrollY - 40;
+ window.scrollTo({ top, behavior: 'smooth' });
}
}
@@ -58,8 +58,8 @@ export function SidebarNav({ items }: Props) {
- {items.map(item => {
- const isActive = activeSection === item.id
+ {items.map((item) => {
+ const isActive = activeSection === item.id;
return (
{item.label}
- )
+ );
})}
- )
+ );
}
diff --git a/src/widgets/Navigation/ui/UtilityBar.stories.tsx b/src/widgets/Navigation/ui/UtilityBar.stories.tsx
index 997a3d5..df7cf2b 100644
--- a/src/widgets/Navigation/ui/UtilityBar.stories.tsx
+++ b/src/widgets/Navigation/ui/UtilityBar.stories.tsx
@@ -1,5 +1,5 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite'
-import { UtilityBar } from './UtilityBar'
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { UtilityBar } from './UtilityBar';
const meta: Meta = {
title: 'Widgets/UtilityBar',
@@ -11,10 +11,10 @@ const meta: Meta = {
),
],
-}
+};
-export default meta
+export default meta;
-type Story = StoryObj
+type Story = StoryObj;
-export const Default: Story = {}
+export const Default: Story = {};
diff --git a/src/widgets/Navigation/ui/UtilityBar.test.tsx b/src/widgets/Navigation/ui/UtilityBar.test.tsx
index 5e4b99f..3ee4c26 100644
--- a/src/widgets/Navigation/ui/UtilityBar.test.tsx
+++ b/src/widgets/Navigation/ui/UtilityBar.test.tsx
@@ -1,30 +1,30 @@
-import { describe, it, expect } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import { UtilityBar } from './UtilityBar'
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { UtilityBar } from './UtilityBar';
describe('UtilityBar', () => {
describe('rendering', () => {
it('renders "Contact" label', () => {
- render( )
- expect(screen.getByText('Contact')).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByText('Contact')).toBeInTheDocument();
+ });
it('renders email link with correct href', () => {
- render( )
- const link = screen.getByRole('link', { name: 'hello@allmy.work' })
- expect(link).toBeInTheDocument()
- expect(link).toHaveAttribute('href', 'mailto:hello@allmy.work')
- })
+ render( );
+ const link = screen.getByRole('link', { name: 'hello@allmy.work' });
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute('href', 'mailto:hello@allmy.work');
+ });
it('renders "Download CV" button', () => {
- render( )
- expect(screen.getByRole('button', { name: /download cv/i })).toBeInTheDocument()
- })
+ render( );
+ expect(screen.getByRole('button', { name: /download cv/i })).toBeInTheDocument();
+ });
it('Download CV button has primary variant class', () => {
- render( )
- const btn = screen.getByRole('button', { name: /download cv/i })
- expect(btn).toHaveClass('bg-burnt-oxide')
- })
- })
-})
+ render( );
+ const btn = screen.getByRole('button', { name: /download cv/i });
+ expect(btn).toHaveClass('bg-burnt-oxide');
+ });
+ });
+});
diff --git a/src/widgets/Navigation/ui/UtilityBar.tsx b/src/widgets/Navigation/ui/UtilityBar.tsx
index a66bb40..04de163 100644
--- a/src/widgets/Navigation/ui/UtilityBar.tsx
+++ b/src/widgets/Navigation/ui/UtilityBar.tsx
@@ -1,6 +1,6 @@
-'use client'
+'use client';
-import { Button } from '$shared/ui'
+import { Button } from '$shared/ui';
/**
* Fixed bottom utility bar with contact info and CV download.
@@ -10,7 +10,7 @@ export function UtilityBar() {
* Handles CV download action.
*/
function handleDownloadCV() {
- console.log('Downloading CV...')
+ console.log('Downloading CV...');
}
return (
@@ -18,10 +18,7 @@ export function UtilityBar() {
@@ -30,5 +27,5 @@ export function UtilityBar() {
- )
+ );
}
diff --git a/src/widgets/index.ts b/src/widgets/index.ts
index f25c564..95e14a9 100644
--- a/src/widgets/index.ts
+++ b/src/widgets/index.ts
@@ -1 +1 @@
-export * from './Navigation'
+export * from './Navigation';