fix: storybook font rendering and shared fonts module #1

Merged
ilia merged 74 commits from feat/portfolio-setup into main 2026-05-18 18:45:22 +00:00
73 changed files with 1201 additions and 1153 deletions
Showing only changes of commit 1d333fd945 - Show all commits
-21
View File
@@ -1,21 +0,0 @@
import type { Preview } from '@storybook/nextjs-vite'
import '../app/globals.css'
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: 'todo',
},
},
}
export default preview
+6 -8
View File
@@ -1,11 +1,11 @@
import type { Metadata } from 'next' import type { Metadata } from 'next';
import { fraunces, publicSans } from '$shared/lib' import { fraunces, publicSans } from '$shared/lib';
import './globals.css' import './globals.css';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Portfolio', title: 'Portfolio',
description: 'Portfolio', description: 'Portfolio',
} };
/** /**
* Root layout — injects font CSS variables used by theme.css * Root layout — injects font CSS variables used by theme.css
@@ -13,9 +13,7 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en"> <html lang="en">
<body className={`${fraunces.variable} ${publicSans.variable}`}> <body className={`${fraunces.variable} ${publicSans.variable}`}>{children}</body>
{children}
</body>
</html> </html>
) );
} }
+7 -20
View File
@@ -1,36 +1,29 @@
import Image from "next/image"; import Image from 'next/image';
export default function Home() { export default function Home() {
return ( return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> <div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start"> <main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image <Image className="dark:invert" src="/next.svg" alt="Next.js logo" width={100} height={20} priority />
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left"> <div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50"> <h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file. To get started, edit the page.tsx file.
</h1> </h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400"> <p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "} Looking for a starting point or more instructions? Head over to{' '}
<a <a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50" className="font-medium text-zinc-950 dark:text-zinc-50"
> >
Templates Templates
</a>{" "} </a>{' '}
or the{" "} or the{' '}
<a <a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50" className="font-medium text-zinc-950 dark:text-zinc-50"
> >
Learning Learning
</a>{" "} </a>{' '}
center. center.
</p> </p>
</div> </div>
@@ -41,13 +34,7 @@ export default function Home() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<Image <Image className="dark:invert" src="/vercel.svg" alt="Vercel logomark" width={16} height={16} />
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now Deploy Now
</a> </a>
<a <a
+2
View File
@@ -0,0 +1,2 @@
export * from './model/types';
export * from './ui';
+23
View File
@@ -0,0 +1,23 @@
import type { BaseRecord } from '$shared/api';
/**
* PocketBase collection for site sections and routing.
*/
export type SectionRecord = BaseRecord & {
/**
* URL-friendly identifier used for routing
*/
slug: string;
/**
* Display name of the section
*/
title: string;
/**
* Visual numbering prefix (e.g., "01")
*/
number: string;
/**
* Sorting weight for section order
*/
order: number;
};
@@ -0,0 +1 @@
export * from './SectionAccordion'
+1
View File
@@ -0,0 +1 @@
export * from './SectionAccordion'
+1 -1
View File
@@ -1 +1 @@
export { ExperienceCard } from './ui/ExperienceCard' export { ExperienceCard } from './ui/ExperienceCard';
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite' import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { ExperienceCard } from './ExperienceCard' import { ExperienceCard } from './ExperienceCard';
const meta: Meta<typeof ExperienceCard> = { const meta: Meta<typeof ExperienceCard> = {
title: 'Entities/ExperienceCard', title: 'Entities/ExperienceCard',
@@ -11,30 +11,28 @@ const meta: Meta<typeof ExperienceCard> = {
</div> </div>
), ),
], ],
} };
export default meta export default meta;
type Story = StoryObj<typeof ExperienceCard> type Story = StoryObj<typeof ExperienceCard>;
const baseArgs = { const baseArgs = {
title: 'Senior Frontend Engineer', title: 'Senior Frontend Engineer',
company: 'Acme Corp', company: 'Acme Corp',
period: '2021 2024', period: '2021 2024',
description: 'Led frontend development for the core product, established design system practices, and mentored junior engineers across two distributed teams.', description:
} 'Led frontend development for the core product, established design system practices, and mentored junior engineers across two distributed teams.',
};
export const Default: Story = { export const Default: Story = {
args: baseArgs, args: baseArgs,
} };
export const SlateBackground: Story = { export const SlateBackground: Story = {
render: () => ( render: () => (
<div className="bg-slate-indigo p-8 max-w-2xl"> <div className="bg-slate-indigo p-8 max-w-2xl">
<ExperienceCard <ExperienceCard {...baseArgs} className="border-ochre-clay" />
{...baseArgs}
className="border-ochre-clay"
/>
</div> </div>
), ),
} };
@@ -1,72 +1,72 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react';
import { ExperienceCard } from './ExperienceCard' import { ExperienceCard } from './ExperienceCard';
const DEFAULT_PROPS = { const DEFAULT_PROPS = {
title: 'Senior Developer', title: 'Senior Developer',
company: 'Acme Corp', company: 'Acme Corp',
period: '2021 2024', period: '2021 2024',
description: 'Built scalable frontend systems.', description: 'Built scalable frontend systems.',
} };
describe('ExperienceCard', () => { describe('ExperienceCard', () => {
describe('rendering', () => { describe('rendering', () => {
it('renders the job title', () => { it('renders the job title', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />) render(<ExperienceCard {...DEFAULT_PROPS} />);
expect(screen.getByText('Senior Developer')).toBeInTheDocument() expect(screen.getByText('Senior Developer')).toBeInTheDocument();
}) });
it('renders the company name', () => { it('renders the company name', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />) render(<ExperienceCard {...DEFAULT_PROPS} />);
expect(screen.getByText('Acme Corp')).toBeInTheDocument() expect(screen.getByText('Acme Corp')).toBeInTheDocument();
}) });
it('renders the period badge', () => { it('renders the period badge', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />) render(<ExperienceCard {...DEFAULT_PROPS} />);
expect(screen.getByText('2021 2024')).toBeInTheDocument() expect(screen.getByText('2021 2024')).toBeInTheDocument();
}) });
it('renders the description', () => { it('renders the description', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />) render(<ExperienceCard {...DEFAULT_PROPS} />);
expect(screen.getByText('Built scalable frontend systems.')).toBeInTheDocument() expect(screen.getByText('Built scalable frontend systems.')).toBeInTheDocument();
}) });
}) });
describe('structure', () => { describe('structure', () => {
it('title is rendered as an h4', () => { it('title is rendered as an h4', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />) render(<ExperienceCard {...DEFAULT_PROPS} />);
expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent('Senior Developer') expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent('Senior Developer');
}) });
it('period badge has brutal-border, bg-carbon-black, text-ochre-clay, text-sm', () => { it('period badge has brutal-border, bg-carbon-black, text-ochre-clay, text-sm', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />) render(<ExperienceCard {...DEFAULT_PROPS} />);
const badge = screen.getByText('2021 2024') const badge = screen.getByText('2021 2024');
expect(badge).toHaveClass('brutal-border', 'bg-carbon-black', 'text-ochre-clay', 'text-sm') expect(badge).toHaveClass('brutal-border', 'bg-carbon-black', 'text-ochre-clay', 'text-sm');
}) });
it('company paragraph has opacity-80', () => { it('company paragraph has opacity-80', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />) render(<ExperienceCard {...DEFAULT_PROPS} />);
const company = screen.getByText('Acme Corp') const company = screen.getByText('Acme Corp');
expect(company.tagName).toBe('P') expect(company.tagName).toBe('P');
expect(company).toHaveClass('opacity-80') expect(company).toHaveClass('opacity-80');
}) });
it('description paragraph has text-base and max-w-[700px]', () => { it('description paragraph has text-base and max-w-[700px]', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />) render(<ExperienceCard {...DEFAULT_PROPS} />);
const desc = screen.getByText('Built scalable frontend systems.') const desc = screen.getByText('Built scalable frontend systems.');
expect(desc).toHaveClass('text-base', 'max-w-[700px]') expect(desc).toHaveClass('text-base', 'max-w-[700px]');
}) });
it('card has brutal-border class (from Card component)', () => { it('card has brutal-border class (from Card component)', () => {
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} />) const { container } = render(<ExperienceCard {...DEFAULT_PROPS} />);
expect(container.firstChild).toHaveClass('brutal-border') expect(container.firstChild).toHaveClass('brutal-border');
}) });
}) });
describe('className passthrough', () => { describe('className passthrough', () => {
it('forwards className to the card', () => { it('forwards className to the card', () => {
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} className="custom-class" />) const { container } = render(<ExperienceCard {...DEFAULT_PROPS} className="custom-class" />);
expect(container.firstChild).toHaveClass('custom-class') expect(container.firstChild).toHaveClass('custom-class');
}) });
}) });
}) });
+9 -11
View File
@@ -1,27 +1,27 @@
import { Card } from '$shared/ui' import { Card } from '$shared/ui';
type Props = { type Props = {
/** /**
* Job title * Job title
*/ */
title: string title: string;
/** /**
* Company name * Company name
*/ */
company: string company: string;
/** /**
* Employment period (e.g. "2021 2024") * Employment period (e.g. "2021 2024")
*/ */
period: string period: string;
/** /**
* Description of responsibilities and achievements * Description of responsibilities and achievements
*/ */
description: string description: string;
/** /**
* Additional CSS classes forwarded to the card * Additional CSS classes forwarded to the card
*/ */
className?: string className?: string;
} };
/** /**
* Work experience card with title, company, period, and description. * Work experience card with title, company, period, and description.
@@ -34,11 +34,9 @@ export function ExperienceCard({ title, company, period, description, className
<h4>{title}</h4> <h4>{title}</h4>
<p className="text-base opacity-80">{company}</p> <p className="text-base opacity-80">{company}</p>
</div> </div>
<span className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay text-sm self-start"> <span className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay text-sm self-start">{period}</span>
{period}
</span>
</div> </div>
<p className="text-base max-w-[700px]">{description}</p> <p className="text-base max-w-[700px]">{description}</p>
</Card> </Card>
) );
} }
+2 -2
View File
@@ -1,2 +1,2 @@
export * from './project' export * from './project';
export * from './experience' export * from './experience';
+3 -3
View File
@@ -1,3 +1,3 @@
export { ProjectMetadata } from './ui/ProjectMetadata' export { ProjectMetadata } from './ui/ProjectMetadata';
export { ProjectCard } from './ui/ProjectCard' export { ProjectCard } from './ui/ProjectCard';
export { DetailedProjectCard } from './ui/DetailedProjectCard' export { DetailedProjectCard } from './ui/DetailedProjectCard';
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite' import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { DetailedProjectCard } from './DetailedProjectCard' import { DetailedProjectCard } from './DetailedProjectCard';
const meta: Meta<typeof DetailedProjectCard> = { const meta: Meta<typeof DetailedProjectCard> = {
title: 'Entities/DetailedProjectCard', title: 'Entities/DetailedProjectCard',
@@ -11,32 +11,33 @@ const meta: Meta<typeof DetailedProjectCard> = {
</div> </div>
), ),
], ],
} };
export default meta export default meta;
type Story = StoryObj<typeof DetailedProjectCard> type Story = StoryObj<typeof DetailedProjectCard>;
const baseArgs = { const baseArgs = {
title: 'Design System', title: 'Design System',
year: '2024', year: '2024',
role: 'Lead Frontend Engineer', role: 'Lead Frontend Engineer',
stack: ['React', 'TypeScript', 'Tailwind CSS', 'Storybook'], 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: [ details: [
'Established token system covering color, spacing, and typography.', 'Established token system covering color, spacing, and typography.',
'Built 40+ accessible components with full test coverage.', 'Built 40+ accessible components with full test coverage.',
'Integrated Storybook for visual regression testing and documentation.', 'Integrated Storybook for visual regression testing and documentation.',
], ],
} };
export const Default: Story = { export const Default: Story = {
args: baseArgs, args: baseArgs,
} };
export const WithImage: Story = { export const WithImage: Story = {
args: { args: {
...baseArgs, ...baseArgs,
imageUrl: 'https://placehold.co/800x450/3B4A59/D9B48F?text=Project', imageUrl: 'https://placehold.co/800x450/3B4A59/D9B48F?text=Project',
}, },
} };
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react';
import { DetailedProjectCard } from './DetailedProjectCard' import { DetailedProjectCard } from './DetailedProjectCard';
const DEFAULT_PROPS = { const DEFAULT_PROPS = {
title: 'Big Project', title: 'Big Project',
@@ -9,83 +9,83 @@ const DEFAULT_PROPS = {
stack: ['Vue', 'Go'], stack: ['Vue', 'Go'],
description: 'A detailed project description', description: 'A detailed project description',
details: ['First detail point', 'Second detail point'], details: ['First detail point', 'Second detail point'],
} };
describe('DetailedProjectCard', () => { describe('DetailedProjectCard', () => {
describe('rendering', () => { describe('rendering', () => {
it('renders the project title', () => { it('renders the project title', () => {
render(<DetailedProjectCard {...DEFAULT_PROPS} />) render(<DetailedProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByText('Big Project')).toBeInTheDocument() expect(screen.getByText('Big Project')).toBeInTheDocument();
}) });
it('renders the description', () => { it('renders the description', () => {
render(<DetailedProjectCard {...DEFAULT_PROPS} />) render(<DetailedProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByText('A detailed project description')).toBeInTheDocument() expect(screen.getByText('A detailed project description')).toBeInTheDocument();
}) });
it('renders each detail item', () => { it('renders each detail item', () => {
render(<DetailedProjectCard {...DEFAULT_PROPS} />) render(<DetailedProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByText('First detail point')).toBeInTheDocument() expect(screen.getByText('First detail point')).toBeInTheDocument();
expect(screen.getByText('Second detail point')).toBeInTheDocument() expect(screen.getByText('Second detail point')).toBeInTheDocument();
}) });
it('renders ProjectMetadata with year, role, and stack', () => { it('renders ProjectMetadata with year, role, and stack', () => {
render(<DetailedProjectCard {...DEFAULT_PROPS} />) render(<DetailedProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByText('2023')).toBeInTheDocument() expect(screen.getByText('2023')).toBeInTheDocument();
expect(screen.getByText('Lead Dev')).toBeInTheDocument() expect(screen.getByText('Lead Dev')).toBeInTheDocument();
expect(screen.getByText('Vue')).toBeInTheDocument() expect(screen.getByText('Vue')).toBeInTheDocument();
expect(screen.getByText('Go')).toBeInTheDocument() expect(screen.getByText('Go')).toBeInTheDocument();
}) });
}) });
describe('structure', () => { describe('structure', () => {
it('outer grid has grid-cols-1 and lg:grid-cols-12', () => { it('outer grid has grid-cols-1 and lg:grid-cols-12', () => {
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} />) const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} />);
expect(container.firstChild).toHaveClass('grid', 'grid-cols-1', 'lg:grid-cols-12') expect(container.firstChild).toHaveClass('grid', 'grid-cols-1', 'lg:grid-cols-12');
}) });
it('title is rendered as an h3', () => { it('title is rendered as an h3', () => {
render(<DetailedProjectCard {...DEFAULT_PROPS} />) render(<DetailedProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Big Project') expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Big Project');
}) });
it('detail items are rendered as <p> tags with text-base', () => { it('detail items are rendered as <p> tags with text-base', () => {
render(<DetailedProjectCard {...DEFAULT_PROPS} />) render(<DetailedProjectCard {...DEFAULT_PROPS} />);
const detail = screen.getByText('First detail point') const detail = screen.getByText('First detail point');
expect(detail.tagName).toBe('P') expect(detail.tagName).toBe('P');
expect(detail).toHaveClass('text-base') expect(detail).toHaveClass('text-base');
}) });
it('details list has brutal-border-top and pt-6', () => { it('details list has brutal-border-top and pt-6', () => {
render(<DetailedProjectCard {...DEFAULT_PROPS} />) render(<DetailedProjectCard {...DEFAULT_PROPS} />);
const detail = screen.getByText('First detail point') const detail = screen.getByText('First detail point');
const detailList = detail.parentElement const detailList = detail.parentElement;
expect(detailList).toHaveClass('brutal-border-top', 'pt-6') expect(detailList).toHaveClass('brutal-border-top', 'pt-6');
}) });
it('description has text-lg and mb-6', () => { it('description has text-lg and mb-6', () => {
render(<DetailedProjectCard {...DEFAULT_PROPS} />) render(<DetailedProjectCard {...DEFAULT_PROPS} />);
const desc = screen.getByText('A detailed project description') const desc = screen.getByText('A detailed project description');
expect(desc).toHaveClass('text-lg', 'mb-6') expect(desc).toHaveClass('text-lg', 'mb-6');
}) });
}) });
describe('conditional image rendering', () => { describe('conditional image rendering', () => {
it('does not render image when imageUrl is absent', () => { it('does not render image when imageUrl is absent', () => {
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} />) const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} />);
expect(container.querySelector('img')).toBeNull() expect(container.querySelector('img')).toBeNull();
}) });
it('renders image when imageUrl is provided', () => { it('renders image when imageUrl is provided', () => {
render(<DetailedProjectCard {...DEFAULT_PROPS} imageUrl="/detail.jpg" />) render(<DetailedProjectCard {...DEFAULT_PROPS} imageUrl="/detail.jpg" />);
const img = screen.getByRole('img') const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', '/detail.jpg') expect(img).toHaveAttribute('src', '/detail.jpg');
}) });
it('image wrapper has aspect-video and brutal-border when imageUrl is provided', () => { it('image wrapper has aspect-video and brutal-border when imageUrl is provided', () => {
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} imageUrl="/detail.jpg" />) const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} imageUrl="/detail.jpg" />);
const imgWrapper = container.querySelector('img')!.parentElement const imgWrapper = container.querySelector('img')!.parentElement;
expect(imgWrapper).toHaveClass('aspect-video', 'brutal-border') expect(imgWrapper).toHaveClass('aspect-video', 'brutal-border');
}) });
}) });
}) });
+16 -22
View File
@@ -1,54 +1,46 @@
import { Card } from '$shared/ui' import { Card } from '$shared/ui';
import { ProjectMetadata } from './ProjectMetadata' import { ProjectMetadata } from './ProjectMetadata';
type Props = { type Props = {
/** /**
* Project name * Project name
*/ */
title: string title: string;
/** /**
* Year the project was completed * Year the project was completed
*/ */
year: string year: string;
/** /**
* Developer role on the project * Developer role on the project
*/ */
role: string role: string;
/** /**
* Technology stack list * Technology stack list
*/ */
stack: string[] stack: string[];
/** /**
* Project description paragraph * Project description paragraph
*/ */
description: string description: string;
/** /**
* Bullet-style detail points listed below the description * Bullet-style detail points listed below the description
*/ */
details: string[] details: string[];
/** /**
* Optional hero image URL * Optional hero image URL
*/ */
imageUrl?: string imageUrl?: string;
/** /**
* Reverse layout (reserved for future use) * Reverse layout (reserved for future use)
* @default false * @default false
*/ */
reverse?: boolean reverse?: boolean;
} };
/** /**
* Full-width detailed project card with metadata sidebar. * Full-width detailed project card with metadata sidebar.
*/ */
export function DetailedProjectCard({ export function DetailedProjectCard({ title, year, role, stack, description, details, imageUrl }: Props) {
title,
year,
role,
stack,
description,
details,
imageUrl,
}: Props) {
return ( return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 mb-16"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-8 mb-16">
<div className="lg:col-span-2 order-2 lg:order-1"> <div className="lg:col-span-2 order-2 lg:order-1">
@@ -68,11 +60,13 @@ export function DetailedProjectCard({
<div className="max-w-[700px] space-y-4 brutal-border-top pt-6"> <div className="max-w-[700px] space-y-4 brutal-border-top pt-6">
{details.map((detail, index) => ( {details.map((detail, index) => (
<p key={index} className="text-base">{detail}</p> <p key={index} className="text-base">
{detail}
</p>
))} ))}
</div> </div>
</Card> </Card>
</div> </div>
</div> </div>
) );
} }
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite' import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { ProjectCard } from './ProjectCard' import { ProjectCard } from './ProjectCard';
const meta: Meta<typeof ProjectCard> = { const meta: Meta<typeof ProjectCard> = {
title: 'Entities/ProjectCard', title: 'Entities/ProjectCard',
@@ -11,11 +11,11 @@ const meta: Meta<typeof ProjectCard> = {
</div> </div>
), ),
], ],
} };
export default meta export default meta;
type Story = StoryObj<typeof ProjectCard> type Story = StoryObj<typeof ProjectCard>;
export const Default: Story = { export const Default: Story = {
args: { args: {
@@ -24,7 +24,7 @@ export const Default: Story = {
description: 'A brutalist portfolio site built with Next.js and Tailwind CSS.', description: 'A brutalist portfolio site built with Next.js and Tailwind CSS.',
tags: ['React', 'TypeScript', 'Next.js'], tags: ['React', 'TypeScript', 'Next.js'],
}, },
} };
export const WithImage: Story = { export const WithImage: Story = {
args: { args: {
@@ -34,4 +34,4 @@ export const WithImage: Story = {
tags: ['React', 'TypeScript', 'Next.js'], tags: ['React', 'TypeScript', 'Next.js'],
imageUrl: 'https://placehold.co/800x450/3B4A59/D9B48F?text=Project', imageUrl: 'https://placehold.co/800x450/3B4A59/D9B48F?text=Project',
}, },
} };
+54 -47
View File
@@ -1,79 +1,86 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react';
import { ProjectCard } from './ProjectCard' import { ProjectCard } from './ProjectCard';
const DEFAULT_PROPS = { const DEFAULT_PROPS = {
title: 'My Project', title: 'My Project',
year: '2024', year: '2024',
description: 'A cool project description', description: 'A cool project description',
tags: ['React', 'Node'], tags: ['React', 'Node'],
} };
describe('ProjectCard', () => { describe('ProjectCard', () => {
describe('rendering', () => { describe('rendering', () => {
it('renders the project title', () => { it('renders the project title', () => {
render(<ProjectCard {...DEFAULT_PROPS} />) render(<ProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByText('My Project')).toBeInTheDocument() expect(screen.getByText('My Project')).toBeInTheDocument();
}) });
it('renders the year badge', () => { it('renders the year badge', () => {
render(<ProjectCard {...DEFAULT_PROPS} />) render(<ProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByText('2024')).toBeInTheDocument() expect(screen.getByText('2024')).toBeInTheDocument();
}) });
it('renders the description', () => { it('renders the description', () => {
render(<ProjectCard {...DEFAULT_PROPS} />) render(<ProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByText('A cool project description')).toBeInTheDocument() expect(screen.getByText('A cool project description')).toBeInTheDocument();
}) });
it('renders each tag', () => { it('renders each tag', () => {
render(<ProjectCard {...DEFAULT_PROPS} />) render(<ProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByText('React')).toBeInTheDocument() expect(screen.getByText('React')).toBeInTheDocument();
expect(screen.getByText('Node')).toBeInTheDocument() expect(screen.getByText('Node')).toBeInTheDocument();
}) });
it('renders the View Project button', () => { it('renders the View Project button', () => {
render(<ProjectCard {...DEFAULT_PROPS} />) render(<ProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByRole('button', { name: /view project/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /view project/i })).toBeInTheDocument();
}) });
}) });
describe('structure', () => { describe('structure', () => {
it('card has hover transition classes', () => { it('card has hover transition classes', () => {
const { container } = render(<ProjectCard {...DEFAULT_PROPS} />) const { container } = render(<ProjectCard {...DEFAULT_PROPS} />);
const card = container.firstChild as HTMLElement const card = container.firstChild as HTMLElement;
expect(card).toHaveClass('group', 'transition-all', 'duration-300') expect(card).toHaveClass('group', 'transition-all', 'duration-300');
}) });
it('year badge has correct classes', () => { it('year badge has correct classes', () => {
render(<ProjectCard {...DEFAULT_PROPS} />) render(<ProjectCard {...DEFAULT_PROPS} />);
const yearBadge = screen.getByText('2024') const yearBadge = screen.getByText('2024');
expect(yearBadge).toHaveClass('brutal-border', 'bg-carbon-black', 'text-ochre-clay', 'text-sm') expect(yearBadge).toHaveClass('brutal-border', 'bg-carbon-black', 'text-ochre-clay', 'text-sm');
}) });
it('tags have correct classes', () => { it('tags have correct classes', () => {
render(<ProjectCard {...DEFAULT_PROPS} />) render(<ProjectCard {...DEFAULT_PROPS} />);
const tag = screen.getByText('React') const tag = screen.getByText('React');
expect(tag).toHaveClass('brutal-border', 'bg-white', 'text-carbon-black', 'text-sm', 'uppercase', 'tracking-wide') expect(tag).toHaveClass(
}) 'brutal-border',
}) 'bg-white',
'text-carbon-black',
'text-sm',
'uppercase',
'tracking-wide',
);
});
});
describe('conditional image rendering', () => { describe('conditional image rendering', () => {
it('does not render image when imageUrl is absent', () => { it('does not render image when imageUrl is absent', () => {
const { container } = render(<ProjectCard {...DEFAULT_PROPS} />) const { container } = render(<ProjectCard {...DEFAULT_PROPS} />);
expect(container.querySelector('img')).toBeNull() expect(container.querySelector('img')).toBeNull();
}) });
it('renders image when imageUrl is provided', () => { it('renders image when imageUrl is provided', () => {
render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />) render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />);
const img = screen.getByRole('img') const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', '/project.jpg') expect(img).toHaveAttribute('src', '/project.jpg');
}) });
it('image wrapper has aspect-video and overflow-hidden when imageUrl is provided', () => { it('image wrapper has aspect-video and overflow-hidden when imageUrl is provided', () => {
const { container } = render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />) const { container } = render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />);
const imgWrapper = container.querySelector('img')!.parentElement const imgWrapper = container.querySelector('img')!.parentElement;
expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border') expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border');
}) });
}) });
}) });
+12 -10
View File
@@ -1,28 +1,28 @@
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, Button } from '$shared/ui' import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, Button } from '$shared/ui';
import { cn } from '$shared/lib' import { cn } from '$shared/lib';
type Props = { type Props = {
/** /**
* Project name * Project name
*/ */
title: string title: string;
/** /**
* Year the project was completed * Year the project was completed
*/ */
year: string year: string;
/** /**
* Short project description * Short project description
*/ */
description: string description: string;
/** /**
* Technology or category tags * Technology or category tags
*/ */
tags: string[] tags: string[];
/** /**
* Optional preview image URL * Optional preview image URL
*/ */
imageUrl?: string imageUrl?: string;
} };
/** /**
* Compact project card for grid/list display. * Compact project card for grid/list display.
@@ -61,8 +61,10 @@ export function ProjectCard({ title, year, description, tags, imageUrl }: Props)
</CardContent> </CardContent>
<CardFooter> <CardFooter>
<Button variant="primary" className="w-full">View Project</Button> <Button variant="primary" className="w-full">
View Project
</Button>
</CardFooter> </CardFooter>
</Card> </Card>
) );
} }
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite' import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { ProjectMetadata } from './ProjectMetadata' import { ProjectMetadata } from './ProjectMetadata';
const meta: Meta<typeof ProjectMetadata> = { const meta: Meta<typeof ProjectMetadata> = {
title: 'Entities/ProjectMetadata', title: 'Entities/ProjectMetadata',
@@ -11,11 +11,11 @@ const meta: Meta<typeof ProjectMetadata> = {
</div> </div>
), ),
], ],
} };
export default meta export default meta;
type Story = StoryObj<typeof ProjectMetadata> type Story = StoryObj<typeof ProjectMetadata>;
export const Default: Story = { export const Default: Story = {
args: { args: {
@@ -23,4 +23,4 @@ export const Default: Story = {
role: 'Lead Frontend Engineer', role: 'Lead Frontend Engineer',
stack: ['React', 'TypeScript', 'Next.js', 'Tailwind'], stack: ['React', 'TypeScript', 'Next.js', 'Tailwind'],
}, },
} };
@@ -1,96 +1,96 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react';
import { ProjectMetadata } from './ProjectMetadata' import { ProjectMetadata } from './ProjectMetadata';
const DEFAULT_PROPS = { const DEFAULT_PROPS = {
year: '2024', year: '2024',
role: 'Frontend Engineer', role: 'Frontend Engineer',
stack: ['React', 'TypeScript', 'Tailwind'], stack: ['React', 'TypeScript', 'Tailwind'],
} };
describe('ProjectMetadata', () => { describe('ProjectMetadata', () => {
describe('rendering', () => { describe('rendering', () => {
it('renders the year value', () => { it('renders the year value', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />) render(<ProjectMetadata {...DEFAULT_PROPS} />);
expect(screen.getByText('2024')).toBeInTheDocument() expect(screen.getByText('2024')).toBeInTheDocument();
}) });
it('renders the YEAR label', () => { it('renders the YEAR label', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />) render(<ProjectMetadata {...DEFAULT_PROPS} />);
expect(screen.getByText('YEAR')).toBeInTheDocument() expect(screen.getByText('YEAR')).toBeInTheDocument();
}) });
it('renders the role value', () => { it('renders the role value', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />) render(<ProjectMetadata {...DEFAULT_PROPS} />);
expect(screen.getByText('Frontend Engineer')).toBeInTheDocument() expect(screen.getByText('Frontend Engineer')).toBeInTheDocument();
}) });
it('renders the ROLE label', () => { it('renders the ROLE label', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />) render(<ProjectMetadata {...DEFAULT_PROPS} />);
expect(screen.getByText('ROLE')).toBeInTheDocument() expect(screen.getByText('ROLE')).toBeInTheDocument();
}) });
it('renders the STACK label', () => { it('renders the STACK label', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />) render(<ProjectMetadata {...DEFAULT_PROPS} />);
expect(screen.getByText('STACK')).toBeInTheDocument() expect(screen.getByText('STACK')).toBeInTheDocument();
}) });
it('renders each stack technology', () => { it('renders each stack technology', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />) render(<ProjectMetadata {...DEFAULT_PROPS} />);
expect(screen.getByText('React')).toBeInTheDocument() expect(screen.getByText('React')).toBeInTheDocument();
expect(screen.getByText('TypeScript')).toBeInTheDocument() expect(screen.getByText('TypeScript')).toBeInTheDocument();
expect(screen.getByText('Tailwind')).toBeInTheDocument() expect(screen.getByText('Tailwind')).toBeInTheDocument();
}) });
}) });
describe('structure', () => { describe('structure', () => {
it('outer div has space-y-6', () => { it('outer div has space-y-6', () => {
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />) const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />);
expect(container.firstChild).toHaveClass('space-y-6') expect(container.firstChild).toHaveClass('space-y-6');
}) });
it('year section has no brutal-border-top (first section)', () => { it('year section has no brutal-border-top (first section)', () => {
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />) const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />);
const sections = container.firstChild!.childNodes const sections = container.firstChild!.childNodes;
expect(sections[0]).not.toHaveClass('brutal-border-top') expect(sections[0]).not.toHaveClass('brutal-border-top');
}) });
it('role section has brutal-border-top and pt-6', () => { it('role section has brutal-border-top and pt-6', () => {
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />) const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />);
const sections = container.firstChild!.childNodes const sections = container.firstChild!.childNodes;
expect(sections[1]).toHaveClass('brutal-border-top', 'pt-6') expect(sections[1]).toHaveClass('brutal-border-top', 'pt-6');
}) });
it('stack section has brutal-border-top and pt-6', () => { it('stack section has brutal-border-top and pt-6', () => {
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />) const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />);
const sections = container.firstChild!.childNodes const sections = container.firstChild!.childNodes;
expect(sections[2]).toHaveClass('brutal-border-top', 'pt-6') expect(sections[2]).toHaveClass('brutal-border-top', 'pt-6');
}) });
it('label has text-xs uppercase tracking-wider opacity-60', () => { it('label has text-xs uppercase tracking-wider opacity-60', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />) render(<ProjectMetadata {...DEFAULT_PROPS} />);
const yearLabel = screen.getByText('YEAR') const yearLabel = screen.getByText('YEAR');
expect(yearLabel).toHaveClass('text-xs', 'uppercase', 'tracking-wider', 'opacity-60') expect(yearLabel).toHaveClass('text-xs', 'uppercase', 'tracking-wider', 'opacity-60');
}) });
it('year value has text-base font-bold', () => { it('year value has text-base font-bold', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />) render(<ProjectMetadata {...DEFAULT_PROPS} />);
const yearValue = screen.getByText('2024') const yearValue = screen.getByText('2024');
expect(yearValue).toHaveClass('text-base', 'font-bold') expect(yearValue).toHaveClass('text-base', 'font-bold');
}) });
it('each stack tech is rendered as a <p> with text-sm', () => { it('each stack tech is rendered as a <p> with text-sm', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />) render(<ProjectMetadata {...DEFAULT_PROPS} />);
const techEl = screen.getByText('React') const techEl = screen.getByText('React');
expect(techEl.tagName).toBe('P') expect(techEl.tagName).toBe('P');
expect(techEl).toHaveClass('text-sm') expect(techEl).toHaveClass('text-sm');
}) });
}) });
describe('className passthrough', () => { describe('className passthrough', () => {
it('merges custom className onto outer div', () => { it('merges custom className onto outer div', () => {
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} className="my-custom" />) const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} className="my-custom" />);
expect(container.firstChild).toHaveClass('my-custom') expect(container.firstChild).toHaveClass('my-custom');
}) });
}) });
}) });
+10 -8
View File
@@ -1,23 +1,23 @@
import { cn } from '$shared/lib' import { cn } from '$shared/lib';
type Props = { type Props = {
/** /**
* Project year * Project year
*/ */
year: string year: string;
/** /**
* Developer role on the project * Developer role on the project
*/ */
role: string role: string;
/** /**
* Technology stack list * Technology stack list
*/ */
stack: string[] stack: string[];
/** /**
* Additional CSS classes * Additional CSS classes
*/ */
className?: string className?: string;
} };
/** /**
* Sidebar metadata display for a project: year, role, and stack. * Sidebar metadata display for a project: year, role, and stack.
@@ -36,9 +36,11 @@ export function ProjectMetadata({ year, role, stack, className }: Props) {
<div className="brutal-border-top pt-6"> <div className="brutal-border-top pt-6">
<p className="text-xs uppercase tracking-wider opacity-60">STACK</p> <p className="text-xs uppercase tracking-wider opacity-60">STACK</p>
{stack.map((tech) => ( {stack.map((tech) => (
<p key={tech} className="text-sm">{tech}</p> <p key={tech} className="text-sm">
{tech}
</p>
))} ))}
</div> </div>
</div> </div>
) );
} }
+20 -26
View File
@@ -1,9 +1,9 @@
import type { ListResponse } from './types' import type { ListResponse } from './types';
/** /**
* Native fetch wrapper for PocketBase API requests. * 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. * Options for PocketBase collection fetching.
@@ -12,61 +12,55 @@ export type PBFetchOptions = {
/** /**
* Sorting criteria (e.g., "-created,order") * Sorting criteria (e.g., "-created,order")
*/ */
sort?: string sort?: string;
/** /**
* Filter query string * Filter query string
*/ */
filter?: string filter?: string;
/** /**
* Fields to expand (e.g., "stack") * Fields to expand (e.g., "stack")
*/ */
expand?: string expand?: string;
/** /**
* Cache revalidation time in seconds * Cache revalidation time in seconds
* @default 3600 * @default 3600
*/ */
revalidate?: number revalidate?: number;
} };
/** /**
* Fetch a list of records from a PocketBase collection. * Fetch a list of records from a PocketBase collection.
*/ */
export async function getCollection<T>( export async function getCollection<T>(collection: string, options: PBFetchOptions = {}): Promise<ListResponse<T>> {
collection: string, const { sort, filter, expand, revalidate = 3600 } = options;
options: PBFetchOptions = {}
): Promise<ListResponse<T>> {
const { sort, filter, expand, revalidate = 3600 } = options
const params = new URLSearchParams() const params = new URLSearchParams();
if (sort) params.set('sort', sort) if (sort) params.set('sort', sort);
if (filter) params.set('filter', filter) if (filter) params.set('filter', filter);
if (expand) params.set('expand', expand) 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, { const res = await fetch(url, {
next: { revalidate }, next: { revalidate },
}) });
if (!res.ok) { 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. * Fetch a single record from a PocketBase collection by ID or filter.
*/ */
export async function getFirstRecord<T>( export async function getFirstRecord<T>(collection: string, options: PBFetchOptions = {}): Promise<T | null> {
collection: string,
options: PBFetchOptions = {}
): Promise<T | null> {
const data = await getCollection<T>(collection, { const data = await getCollection<T>(collection, {
...options, ...options,
// PocketBase convention for "first" or "singleton" patterns // PocketBase convention for "first" or "singleton" patterns
filter: options.filter, filter: options.filter,
}) });
return data.items[0] || null return data.items[0] || null;
} }
+2 -2
View File
@@ -1,2 +1,2 @@
export * from './types' export * from './types';
export * from './client' export * from './client';
+36 -57
View File
@@ -5,60 +5,39 @@ export type BaseRecord = {
/** /**
* Unique record ID * Unique record ID
*/ */
id: string id: string;
/** /**
* ID of the collection this record belongs to * ID of the collection this record belongs to
*/ */
collectionId: string collectionId: string;
/** /**
* Name of the collection this record belongs to * Name of the collection this record belongs to
*/ */
collectionName: string collectionName: string;
/** /**
* Record creation timestamp (ISO 8601) * Record creation timestamp (ISO 8601)
*/ */
created: string created: string;
/** /**
* Record last update timestamp (ISO 8601) * 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
*/
slug: string
/**
* Display name of the section
*/
title: string
/**
* Visual numbering prefix (e.g., "01")
*/
number: string
/**
* Sorting weight for section order
*/
order: number
}
/** /**
* PocketBase collection for simple text blocks (Intro, Bio). * PocketBase collection for simple text blocks (Intro, Bio).
*/ */
export type PageContentRecord = BaseRecord & { export type PageContentRecord = BaseRecord & {
/** /**
* Slug corresponding to the parent section * Slug corresponding to the parent section
*/ */
slug: string slug: string;
/** /**
* HTML or Markdown content string * HTML or Markdown content string
*/ */
content: string content: string;
} };
/** /**
* PocketBase collection for technology skills. * PocketBase collection for technology skills.
@@ -67,16 +46,16 @@ export type SkillRecord = BaseRecord & {
/** /**
* Name of the technology or tool * Name of the technology or tool
*/ */
name: string name: string;
/** /**
* Grouping category (e.g., 'Frontend', 'Backend') * Grouping category (e.g., 'Frontend', 'Backend')
*/ */
category: 'Frontend' | 'Backend' | 'Tools' | 'Design' | string category: 'Frontend' | 'Backend' | 'Tools' | 'Design' | string;
/** /**
* Sorting weight within the category * Sorting weight within the category
*/ */
order: number order: number;
} };
/** /**
* PocketBase collection for work experience history. * PocketBase collection for work experience history.
@@ -85,28 +64,28 @@ export type ExperienceRecord = BaseRecord & {
/** /**
* Name of the organization * Name of the organization
*/ */
company: string company: string;
/** /**
* Professional title held * Professional title held
*/ */
role: string role: string;
/** /**
* Start date of the tenure * Start date of the tenure
*/ */
start_date: string start_date: string;
/** /**
* End date of the tenure, or null if currently employed * 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 * Rich text description of responsibilities and achievements
*/ */
description: string description: string;
/** /**
* Sorting weight for chronological display * Sorting weight for chronological display
*/ */
order: number order: number;
} };
/** /**
* PocketBase collection for portfolio projects. * PocketBase collection for portfolio projects.
@@ -115,36 +94,36 @@ export type ProjectRecord = BaseRecord & {
/** /**
* Full title of the project * Full title of the project
*/ */
title: string title: string;
/** /**
* Completion or duration year (e.g., "2024") * Completion or duration year (e.g., "2024")
*/ */
year: string year: string;
/** /**
* Role performed on the project * Role performed on the project
*/ */
role: string role: string;
/** /**
* Short summary of the project * Short summary of the project
*/ */
description: string description: string;
/** /**
* List of specific feature or achievement points * List of specific feature or achievement points
*/ */
details: string[] details: string[];
/** /**
* List of SkillRecord IDs used in the project * List of SkillRecord IDs used in the project
*/ */
stack: string[] stack: string[];
/** /**
* Primary thumbnail or hero image filename * Primary thumbnail or hero image filename
*/ */
image: string image: string;
/** /**
* Sorting weight for the project list * Sorting weight for the project list
*/ */
order: number order: number;
} };
/** /**
* Generic response for a list of PocketBase records. * Generic response for a list of PocketBase records.
@@ -153,21 +132,21 @@ export type ListResponse<T> = {
/** /**
* Current page index * Current page index
*/ */
page: number page: number;
/** /**
* Number of items per page * Number of items per page
*/ */
perPage: number perPage: number;
/** /**
* Total number of items across all pages * Total number of items across all pages
*/ */
totalItems: number totalItems: number;
/** /**
* Total number of pages available * Total number of pages available
*/ */
totalPages: number totalPages: number;
/** /**
* Array of records for the current page * Array of records for the current page
*/ */
items: T[] items: T[];
} };
+3 -3
View File
@@ -1,3 +1,3 @@
export * from './ui' export * from './ui';
export * from './lib' export * from './lib';
export * from './api' export * from './api';
+21 -21
View File
@@ -1,40 +1,40 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest';
import { cn } from './cn' import { cn } from './cn';
describe('cn', () => { describe('cn', () => {
describe('basic merging', () => { describe('basic merging', () => {
it('returns single class unchanged', () => { it('returns single class unchanged', () => {
expect(cn('foo')).toBe('foo') expect(cn('foo')).toBe('foo');
}) });
it('joins multiple classes', () => { it('joins multiple classes', () => {
expect(cn('foo', 'bar')).toBe('foo bar') expect(cn('foo', 'bar')).toBe('foo bar');
}) });
}) });
describe('conditional classes', () => { describe('conditional classes', () => {
it('includes truthy conditional', () => { it('includes truthy conditional', () => {
expect(cn('foo', true && 'bar')).toBe('foo bar') expect(cn('foo', true && 'bar')).toBe('foo bar');
}) });
it('excludes falsy conditional', () => { it('excludes falsy conditional', () => {
expect(cn('foo', false && 'bar')).toBe('foo') expect(cn('foo', false && 'bar')).toBe('foo');
}) });
}) });
describe('object syntax', () => { describe('object syntax', () => {
it('includes classes with truthy object values', () => { 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', () => { describe('tailwind conflict resolution', () => {
it('last padding wins', () => { 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', () => { 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');
}) });
}) });
}) });
+3 -3
View File
@@ -1,9 +1,9 @@
import { clsx, type ClassValue } from 'clsx' import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge';
/** /**
* Merges Tailwind classes, resolving conflicts in favor of the last value. * Merges Tailwind classes, resolving conflicts in favor of the last value.
*/ */
export function cn(...inputs: ClassValue[]): string { export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }
+3 -3
View File
@@ -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 * Heading font — variable axes for brutalist variation settings
@@ -7,7 +7,7 @@ export const fraunces = Fraunces({
subsets: ['latin'], subsets: ['latin'],
variable: '--font-fraunces', variable: '--font-fraunces',
axes: ['opsz', 'SOFT', 'WONK'], axes: ['opsz', 'SOFT', 'WONK'],
}) });
/** /**
* Body font * Body font
@@ -15,4 +15,4 @@ export const fraunces = Fraunces({
export const publicSans = Public_Sans({ export const publicSans = Public_Sans({
subsets: ['latin'], subsets: ['latin'],
variable: '--font-public-sans', variable: '--font-public-sans',
}) });
+3 -3
View File
@@ -1,3 +1,3 @@
export { cn } from './cn' export { cn } from './cn';
export type { ClassValue } from 'clsx' export type { ClassValue } from 'clsx';
export * from './fonts' export * from './fonts';
+64 -23
View File
@@ -2,7 +2,7 @@
/* === TYPOGRAPHY SCALE (Augmented Fourth 1.414) === */ /* === TYPOGRAPHY SCALE (Augmented Fourth 1.414) === */
--font-size: 16px; --font-size: 16px;
--text-xs: 0.707rem; --text-xs: 0.707rem;
--text-sm: 0.840rem; --text-sm: 0.84rem;
--text-base: 1rem; --text-base: 1rem;
--text-lg: 1.414rem; --text-lg: 1.414rem;
--text-xl: 2rem; --text-xl: 2rem;
@@ -29,9 +29,9 @@
--fraunces-soft: 0; --fraunces-soft: 0;
/* === COLOR PALETTE === */ /* === COLOR PALETTE === */
--ochre-clay: #D9B48F; --ochre-clay: #d9b48f;
--slate-indigo: #3B4A59; --slate-indigo: #3b4a59;
--burnt-oxide: #A64B35; --burnt-oxide: #a64b35;
--carbon-black: #121212; --carbon-black: #121212;
/* === SEMANTIC COLORS === */ /* === SEMANTIC COLORS === */
@@ -126,7 +126,7 @@
/* Paper grain texture */ /* Paper grain texture */
body::before { body::before {
content: ''; content: "";
position: fixed; position: fixed;
inset: 0; inset: 0;
background-image: background-image:
@@ -138,19 +138,36 @@
z-index: 1; z-index: 1;
} }
h1, h2, h3, h4, h5, h6 { h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-heading); font-family: var(--font-heading);
font-weight: var(--font-weight-heading); font-weight: var(--font-weight-heading);
line-height: var(--line-height-tight); 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); color: var(--carbon-black);
} }
h1 { font-size: var(--text-4xl); } h1 {
h2 { font-size: var(--text-3xl); } font-size: var(--text-4xl);
h3 { font-size: var(--text-2xl); } }
h4 { font-size: var(--text-xl); } h2 {
h5 { font-size: var(--text-lg); } font-size: var(--text-3xl);
}
h3 {
font-size: var(--text-2xl);
}
h4 {
font-size: var(--text-xl);
}
h5 {
font-size: var(--text-lg);
}
p { p {
font-family: var(--font-body); font-family: var(--font-body);
@@ -180,18 +197,42 @@
} }
/* Brutalist utility classes */ /* Brutalist utility classes */
.brutal-shadow { box-shadow: var(--shadow-brutal); } .brutal-shadow {
.brutal-shadow-sm { box-shadow: var(--shadow-brutal-sm); } box-shadow: var(--shadow-brutal);
.brutal-shadow-lg { box-shadow: var(--shadow-brutal-lg); } }
.brutal-border { border: var(--border-width) solid var(--carbon-black); } .brutal-shadow-sm {
.brutal-border-top { border-top: var(--border-width) solid var(--carbon-black); } box-shadow: var(--shadow-brutal-sm);
.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-shadow-lg {
.brutal-border-right { border-right: var(--border-width) solid var(--carbon-black); } 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 */ /* Animations */
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); } from {
to { opacity: 1; transform: translateY(0); } 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); }
+2 -2
View File
@@ -1,2 +1,2 @@
export { Badge } from './ui/Badge' export { Badge } from './ui/Badge';
export type { BadgeVariant } from './ui/Badge' export type { BadgeVariant } from './ui/Badge';
+6 -6
View File
@@ -1,14 +1,14 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite' import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { Badge } from './Badge' import { Badge } from './Badge';
const meta: Meta<typeof Badge> = { const meta: Meta<typeof Badge> = {
title: 'Shared/Badge', title: 'Shared/Badge',
component: Badge, component: Badge,
} };
export default meta export default meta;
type Story = StoryObj<typeof Badge> type Story = StoryObj<typeof Badge>;
export const AllVariants: Story = { export const AllVariants: Story = {
render: () => ( render: () => (
@@ -19,4 +19,4 @@ export const AllVariants: Story = {
<Badge variant="outline">Outline</Badge> <Badge variant="outline">Outline</Badge>
</div> </div>
), ),
} };
+32 -32
View File
@@ -1,52 +1,52 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react';
import { Badge } from './Badge' import { Badge } from './Badge';
describe('Badge', () => { describe('Badge', () => {
describe('rendering', () => { describe('rendering', () => {
it('renders children', () => { it('renders children', () => {
render(<Badge>React</Badge>) render(<Badge>React</Badge>);
expect(screen.getByText('React')).toBeInTheDocument() expect(screen.getByText('React')).toBeInTheDocument();
}) });
it('renders as inline span', () => { it('renders as inline span', () => {
render(<Badge>Tag</Badge>) render(<Badge>Tag</Badge>);
expect(screen.getByText('Tag').tagName).toBe('SPAN') expect(screen.getByText('Tag').tagName).toBe('SPAN');
}) });
}) });
describe('variants', () => { describe('variants', () => {
it('applies default variant classes', () => { it('applies default variant classes', () => {
render(<Badge variant="default">Tag</Badge>) render(<Badge variant="default">Tag</Badge>);
const el = screen.getByText('Tag') const el = screen.getByText('Tag');
expect(el).toHaveClass('bg-carbon-black', 'text-ochre-clay') expect(el).toHaveClass('bg-carbon-black', 'text-ochre-clay');
}) });
it('applies primary variant classes', () => { it('applies primary variant classes', () => {
render(<Badge variant="primary">Tag</Badge>) render(<Badge variant="primary">Tag</Badge>);
expect(screen.getByText('Tag')).toHaveClass('bg-burnt-oxide') expect(screen.getByText('Tag')).toHaveClass('bg-burnt-oxide');
}) });
it('applies secondary variant classes', () => { it('applies secondary variant classes', () => {
render(<Badge variant="secondary">Tag</Badge>) render(<Badge variant="secondary">Tag</Badge>);
expect(screen.getByText('Tag')).toHaveClass('bg-slate-indigo') expect(screen.getByText('Tag')).toHaveClass('bg-slate-indigo');
}) });
it('applies outline variant classes', () => { it('applies outline variant classes', () => {
render(<Badge variant="outline">Tag</Badge>) render(<Badge variant="outline">Tag</Badge>);
expect(screen.getByText('Tag')).toHaveClass('bg-transparent') expect(screen.getByText('Tag')).toHaveClass('bg-transparent');
}) });
it('defaults to default variant when unspecified', () => { it('defaults to default variant when unspecified', () => {
render(<Badge>Tag</Badge>) render(<Badge>Tag</Badge>);
expect(screen.getByText('Tag')).toHaveClass('bg-carbon-black') expect(screen.getByText('Tag')).toHaveClass('bg-carbon-black');
}) });
}) });
describe('className passthrough', () => { describe('className passthrough', () => {
it('merges custom className', () => { it('merges custom className', () => {
render(<Badge className="mt-4">Tag</Badge>) render(<Badge className="mt-4">Tag</Badge>);
expect(screen.getByText('Tag')).toHaveClass('mt-4') expect(screen.getByText('Tag')).toHaveClass('mt-4');
}) });
}) });
}) });
+8 -8
View File
@@ -1,22 +1,22 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react';
import { cn } from '$shared/lib' import { cn } from '$shared/lib';
export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'outline' export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'outline';
interface Props { interface Props {
/** /**
* Badge content * Badge content
*/ */
children: ReactNode children: ReactNode;
/** /**
* Visual variant * Visual variant
* @default 'default' * @default 'default'
*/ */
variant?: BadgeVariant variant?: BadgeVariant;
/** /**
* Additional CSS classes * Additional CSS classes
*/ */
className?: string className?: string;
} }
const VARIANTS: Record<BadgeVariant, string> = { const VARIANTS: Record<BadgeVariant, string> = {
@@ -24,7 +24,7 @@ const VARIANTS: Record<BadgeVariant, string> = {
primary: 'brutal-border bg-burnt-oxide text-ochre-clay', primary: 'brutal-border bg-burnt-oxide text-ochre-clay',
secondary: 'brutal-border bg-slate-indigo 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. * Small label for categorization or status.
@@ -34,5 +34,5 @@ export function Badge({ children, variant = 'default', className }: Props) {
<span className={cn('inline-block px-3 py-1 text-xs uppercase tracking-wider', VARIANTS[variant], className)}> <span className={cn('inline-block px-3 py-1 text-xs uppercase tracking-wider', VARIANTS[variant], className)}>
{children} {children}
</span> </span>
) );
} }
+2 -2
View File
@@ -1,2 +1,2 @@
export { Button } from './ui/Button' export { Button } from './ui/Button';
export type { ButtonVariant, ButtonSize } from './ui/Button' export type { ButtonVariant, ButtonSize } from './ui/Button';
+29 -15
View File
@@ -1,35 +1,49 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite' import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { Button } from './Button' import { Button } from './Button';
const meta: Meta<typeof Button> = { const meta: Meta<typeof Button> = {
title: 'Shared/Button', title: 'Shared/Button',
component: Button, component: Button,
} };
export default meta export default meta;
type Story = StoryObj<typeof Button> type Story = StoryObj<typeof Button>;
export const AllVariants: Story = { export const AllVariants: Story = {
render: () => ( render: () => (
<div className="flex gap-4 flex-wrap p-8 bg-ochre-clay"> <div className="flex gap-4 flex-wrap p-8 bg-ochre-clay">
<Button variant="primary" size="md">Primary</Button> <Button variant="primary" size="md">
<Button variant="secondary" size="md">Secondary</Button> Primary
<Button variant="outline" size="md">Outline</Button> </Button>
<Button variant="ghost" size="md">Ghost</Button> <Button variant="secondary" size="md">
Secondary
</Button>
<Button variant="outline" size="md">
Outline
</Button>
<Button variant="ghost" size="md">
Ghost
</Button>
</div> </div>
), ),
} };
export const Sizes: Story = { export const Sizes: Story = {
render: () => ( render: () => (
<div className="flex gap-4 items-center flex-wrap p-8 bg-ochre-clay"> <div className="flex gap-4 items-center flex-wrap p-8 bg-ochre-clay">
<Button variant="primary" size="sm">Small</Button> <Button variant="primary" size="sm">
<Button variant="primary" size="md">Medium</Button> Small
<Button variant="primary" size="lg">Large</Button> </Button>
<Button variant="primary" size="md">
Medium
</Button>
<Button variant="primary" size="lg">
Large
</Button>
</div> </div>
), ),
} };
export const Disabled: Story = { export const Disabled: Story = {
args: { args: {
@@ -44,4 +58,4 @@ export const Disabled: Story = {
</div> </div>
), ),
], ],
} };
+48 -48
View File
@@ -1,67 +1,67 @@
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event';
import { Button } from './Button' import { Button } from './Button';
describe('Button', () => { describe('Button', () => {
describe('rendering', () => { describe('rendering', () => {
it('renders children', () => { it('renders children', () => {
render(<Button>Click me</Button>) render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
}) });
it('renders as button element', () => { it('renders as button element', () => {
render(<Button>Click</Button>) render(<Button>Click</Button>);
expect(screen.getByRole('button')).toBeInTheDocument() expect(screen.getByRole('button')).toBeInTheDocument();
}) });
}) });
describe('variants', () => { describe('variants', () => {
it('applies primary variant by default', () => { it('applies primary variant by default', () => {
render(<Button>Go</Button>) render(<Button>Go</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-burnt-oxide') expect(screen.getByRole('button')).toHaveClass('bg-burnt-oxide');
}) });
it('applies secondary variant', () => { it('applies secondary variant', () => {
render(<Button variant="secondary">Go</Button>) render(<Button variant="secondary">Go</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-slate-indigo') expect(screen.getByRole('button')).toHaveClass('bg-slate-indigo');
}) });
it('applies outline variant', () => { it('applies outline variant', () => {
render(<Button variant="outline">Go</Button>) render(<Button variant="outline">Go</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-transparent') expect(screen.getByRole('button')).toHaveClass('bg-transparent');
}) });
it('applies ghost variant', () => { it('applies ghost variant', () => {
render(<Button variant="ghost">Go</Button>) render(<Button variant="ghost">Go</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-ochre-clay') expect(screen.getByRole('button')).toHaveClass('bg-ochre-clay');
}) });
}) });
describe('sizes', () => { describe('sizes', () => {
it('applies md size by default', () => { it('applies md size by default', () => {
render(<Button>Go</Button>) render(<Button>Go</Button>);
expect(screen.getByRole('button')).toHaveClass('px-6', 'py-3') expect(screen.getByRole('button')).toHaveClass('px-6', 'py-3');
}) });
it('applies sm size', () => { it('applies sm size', () => {
render(<Button size="sm">Go</Button>) render(<Button size="sm">Go</Button>);
expect(screen.getByRole('button')).toHaveClass('px-4', 'py-2') expect(screen.getByRole('button')).toHaveClass('px-4', 'py-2');
}) });
it('applies lg size', () => { it('applies lg size', () => {
render(<Button size="lg">Go</Button>) render(<Button size="lg">Go</Button>);
expect(screen.getByRole('button')).toHaveClass('px-8', 'py-4') expect(screen.getByRole('button')).toHaveClass('px-8', 'py-4');
}) });
}) });
describe('interactions', () => { describe('interactions', () => {
it('calls onClick when clicked', async () => { it('calls onClick when clicked', async () => {
const onClick = vi.fn() const onClick = vi.fn();
render(<Button onClick={onClick}>Go</Button>) render(<Button onClick={onClick}>Go</Button>);
await userEvent.click(screen.getByRole('button')) await userEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledOnce() expect(onClick).toHaveBeenCalledOnce();
}) });
it('is disabled when disabled prop is set', () => { it('is disabled when disabled prop is set', () => {
render(<Button disabled>Go</Button>) render(<Button disabled>Go</Button>);
expect(screen.getByRole('button')).toBeDisabled() expect(screen.getByRole('button')).toBeDisabled();
}) });
}) });
describe('className passthrough', () => { describe('className passthrough', () => {
it('merges custom className', () => { it('merges custom className', () => {
render(<Button className="w-full">Go</Button>) render(<Button className="w-full">Go</Button>);
expect(screen.getByRole('button')).toHaveClass('w-full') expect(screen.getByRole('button')).toHaveClass('w-full');
}) });
}) });
}) });
+12 -11
View File
@@ -1,24 +1,24 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react' import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from '$shared/lib' import { cn } from '$shared/lib';
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
export type ButtonSize = 'sm' | 'md' | 'lg' export type ButtonSize = 'sm' | 'md' | 'lg';
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> { interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
/** /**
* Visual variant * Visual variant
* @default 'primary' * @default 'primary'
*/ */
variant?: ButtonVariant variant?: ButtonVariant;
/** /**
* Size preset * Size preset
* @default 'md' * @default 'md'
*/ */
size?: ButtonSize size?: ButtonSize;
/** /**
* Button content * Button content
*/ */
children: ReactNode children: ReactNode;
} }
const VARIANTS: Record<ButtonVariant, string> = { const VARIANTS: Record<ButtonVariant, string> = {
@@ -26,15 +26,16 @@ const VARIANTS: Record<ButtonVariant, string> = {
secondary: 'bg-slate-indigo text-ochre-clay', secondary: 'bg-slate-indigo text-ochre-clay',
outline: 'bg-transparent 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', ghost: 'bg-ochre-clay text-carbon-black border-carbon-black',
} };
const SIZES: Record<ButtonSize, string> = { const SIZES: Record<ButtonSize, string> = {
sm: 'px-4 py-2 text-sm', sm: 'px-4 py-2 text-sm',
md: 'px-6 py-3 text-base', md: 'px-6 py-3 text-base',
lg: 'px-8 py-4 text-lg', 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. * Brutalist button with variants and sizes.
@@ -44,5 +45,5 @@ export function Button({ variant = 'primary', size = 'md', className, children,
<button className={cn(BASE, VARIANTS[variant], SIZES[size], className)} {...props}> <button className={cn(BASE, VARIANTS[variant], SIZES[size], className)} {...props}>
{children} {children}
</button> </button>
) );
} }
+2 -2
View File
@@ -1,2 +1,2 @@
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './ui/Card' export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './ui/Card';
export type { CardBackground } from './ui/Card' export type { CardBackground } from './ui/Card';
+9 -11
View File
@@ -1,14 +1,14 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite' import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card' import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
const meta: Meta<typeof Card> = { const meta: Meta<typeof Card> = {
title: 'Shared/Card', title: 'Shared/Card',
component: Card, component: Card,
} };
export default meta export default meta;
type Story = StoryObj<typeof Card> type Story = StoryObj<typeof Card>;
export const AllBackgrounds: Story = { export const AllBackgrounds: Story = {
render: () => ( render: () => (
@@ -36,19 +36,17 @@ export const AllBackgrounds: Story = {
</Card> </Card>
</div> </div>
), ),
} };
export const NoPadding: Story = { export const NoPadding: Story = {
render: () => ( render: () => (
<div className="p-8 bg-ochre-clay"> <div className="p-8 bg-ochre-clay">
<Card noPadding className="w-64 overflow-hidden"> <Card noPadding className="w-64 overflow-hidden">
<div className="h-40 bg-slate-indigo flex items-center justify-center text-ochre-clay"> <div className="h-40 bg-slate-indigo flex items-center justify-center text-ochre-clay">Image placeholder</div>
Image placeholder
</div>
</Card> </Card>
</div> </div>
), ),
} };
export const FullComposition: Story = { export const FullComposition: Story = {
render: () => ( render: () => (
@@ -67,4 +65,4 @@ export const FullComposition: Story = {
</Card> </Card>
</div> </div>
), ),
} };
+55 -55
View File
@@ -1,79 +1,79 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react';
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card' import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
describe('Card', () => { describe('Card', () => {
describe('rendering', () => { describe('rendering', () => {
it('renders children', () => { it('renders children', () => {
render(<Card>Content</Card>) render(<Card>Content</Card>);
expect(screen.getByText('Content')).toBeInTheDocument() expect(screen.getByText('Content')).toBeInTheDocument();
}) });
it('has brutal-border and brutal-shadow classes', () => { it('has brutal-border and brutal-shadow classes', () => {
const { container } = render(<Card>Content</Card>) const { container } = render(<Card>Content</Card>);
expect(container.firstChild).toHaveClass('brutal-border', 'brutal-shadow') expect(container.firstChild).toHaveClass('brutal-border', 'brutal-shadow');
}) });
}) });
describe('background variants', () => { describe('background variants', () => {
it('defaults to ochre background', () => { it('defaults to ochre background', () => {
const { container } = render(<Card>Content</Card>) const { container } = render(<Card>Content</Card>);
expect(container.firstChild).toHaveClass('bg-ochre-clay') expect(container.firstChild).toHaveClass('bg-ochre-clay');
}) });
it('applies slate background', () => { it('applies slate background', () => {
const { container } = render(<Card background="slate">Content</Card>) const { container } = render(<Card background="slate">Content</Card>);
expect(container.firstChild).toHaveClass('bg-slate-indigo') expect(container.firstChild).toHaveClass('bg-slate-indigo');
}) });
it('applies white background', () => { it('applies white background', () => {
const { container } = render(<Card background="white">Content</Card>) const { container } = render(<Card background="white">Content</Card>);
expect(container.firstChild).toHaveClass('bg-white') expect(container.firstChild).toHaveClass('bg-white');
}) });
}) });
describe('padding', () => { describe('padding', () => {
it('has default padding', () => { it('has default padding', () => {
const { container } = render(<Card>Content</Card>) const { container } = render(<Card>Content</Card>);
expect(container.firstChild).toHaveClass('p-6') expect(container.firstChild).toHaveClass('p-6');
}) });
it('removes padding when noPadding is true', () => { it('removes padding when noPadding is true', () => {
const { container } = render(<Card noPadding>Content</Card>) const { container } = render(<Card noPadding>Content</Card>);
expect(container.firstChild).not.toHaveClass('p-6') expect(container.firstChild).not.toHaveClass('p-6');
}) });
}) });
describe('className passthrough', () => { describe('className passthrough', () => {
it('merges custom className', () => { it('merges custom className', () => {
const { container } = render(<Card className="group">Content</Card>) const { container } = render(<Card className="group">Content</Card>);
expect(container.firstChild).toHaveClass('group') expect(container.firstChild).toHaveClass('group');
}) });
}) });
}) });
describe('CardHeader', () => { describe('CardHeader', () => {
it('renders children with bottom margin', () => { it('renders children with bottom margin', () => {
render(<CardHeader>Header</CardHeader>) render(<CardHeader>Header</CardHeader>);
expect(screen.getByText('Header')).toHaveClass('mb-4') expect(screen.getByText('Header')).toHaveClass('mb-4');
}) });
}) });
describe('CardTitle', () => { describe('CardTitle', () => {
it('renders children as h3', () => { it('renders children as h3', () => {
render(<CardTitle>Title</CardTitle>) render(<CardTitle>Title</CardTitle>);
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Title') expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Title');
}) });
}) });
describe('CardDescription', () => { describe('CardDescription', () => {
it('renders children as paragraph with opacity', () => { it('renders children as paragraph with opacity', () => {
render(<CardDescription>Desc</CardDescription>) render(<CardDescription>Desc</CardDescription>);
const el = screen.getByText('Desc') const el = screen.getByText('Desc');
expect(el.tagName).toBe('P') expect(el.tagName).toBe('P');
expect(el).toHaveClass('opacity-80') expect(el).toHaveClass('opacity-80');
}) });
}) });
describe('CardContent', () => { describe('CardContent', () => {
it('renders children in a div', () => { it('renders children in a div', () => {
render(<CardContent>Body</CardContent>) render(<CardContent>Body</CardContent>);
expect(screen.getByText('Body')).toBeInTheDocument() expect(screen.getByText('Body')).toBeInTheDocument();
}) });
}) });
describe('CardFooter', () => { describe('CardFooter', () => {
it('renders children with top border', () => { it('renders children with top border', () => {
render(<CardFooter>Footer</CardFooter>) render(<CardFooter>Footer</CardFooter>);
const el = screen.getByText('Footer') const el = screen.getByText('Footer');
expect(el).toHaveClass('brutal-border-top', 'mt-6', 'pt-6') expect(el).toHaveClass('brutal-border-top', 'mt-6', 'pt-6');
}) });
}) });
+16 -16
View File
@@ -1,34 +1,34 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react';
import { cn } from '$shared/lib' import { cn } from '$shared/lib';
export type CardBackground = 'ochre' | 'slate' | 'white' export type CardBackground = 'ochre' | 'slate' | 'white';
interface CardProps { interface CardProps {
/** /**
* Card content * Card content
*/ */
children: ReactNode children: ReactNode;
/** /**
* Additional CSS classes * Additional CSS classes
*/ */
className?: string className?: string;
/** /**
* Background color preset * Background color preset
* @default 'ochre' * @default 'ochre'
*/ */
background?: CardBackground background?: CardBackground;
/** /**
* Remove default padding * Remove default padding
* @default false * @default false
*/ */
noPadding?: boolean noPadding?: boolean;
} }
const BG: Record<CardBackground, string> = { const BG: Record<CardBackground, string> = {
ochre: 'bg-ochre-clay', ochre: 'bg-ochre-clay',
slate: 'bg-slate-indigo text-ochre-clay', slate: 'bg-slate-indigo text-ochre-clay',
white: 'bg-white', white: 'bg-white',
} };
/** /**
* Brutalist card container with background and padding variants. * Brutalist card container with background and padding variants.
@@ -38,51 +38,51 @@ export function Card({ children, className, background = 'ochre', noPadding = fa
<div className={cn('brutal-border brutal-shadow', BG[background], !noPadding && 'p-6 md:p-8', className)}> <div className={cn('brutal-border brutal-shadow', BG[background], !noPadding && 'p-6 md:p-8', className)}>
{children} {children}
</div> </div>
) );
} }
interface SlotProps { interface SlotProps {
/** /**
* Slot content * Slot content
*/ */
children: ReactNode children: ReactNode;
/** /**
* Additional CSS classes * Additional CSS classes
*/ */
className?: string className?: string;
} }
/** /**
* Card header wrapper adds bottom margin. * Card header wrapper adds bottom margin.
*/ */
export function CardHeader({ children, className }: SlotProps) { export function CardHeader({ children, className }: SlotProps) {
return <div className={cn('mb-4', className)}>{children}</div> return <div className={cn('mb-4', className)}>{children}</div>;
} }
/** /**
* Card title renders as h3. * Card title renders as h3.
*/ */
export function CardTitle({ children, className }: SlotProps) { export function CardTitle({ children, className }: SlotProps) {
return <h3 className={className}>{children}</h3> return <h3 className={className}>{children}</h3>;
} }
/** /**
* Card description muted paragraph below the title. * Card description muted paragraph below the title.
*/ */
export function CardDescription({ children, className }: SlotProps) { export function CardDescription({ children, className }: SlotProps) {
return <p className={cn('mt-2 opacity-80', className)}>{children}</p> return <p className={cn('mt-2 opacity-80', className)}>{children}</p>;
} }
/** /**
* Card body content area. * Card body content area.
*/ */
export function CardContent({ children, className }: SlotProps) { export function CardContent({ children, className }: SlotProps) {
return <div className={className}>{children}</div> return <div className={className}>{children}</div>;
} }
/** /**
* Card footer separated by a brutal border-top. * Card footer separated by a brutal border-top.
*/ */
export function CardFooter({ children, className }: SlotProps) { export function CardFooter({ children, className }: SlotProps) {
return <div className={cn('mt-6 pt-6 brutal-border-top', className)}>{children}</div> return <div className={cn('mt-6 pt-6 brutal-border-top', className)}>{children}</div>;
} }
+1 -1
View File
@@ -1 +1 @@
export { Input, Textarea } from './ui/Input' export { Input, Textarea } from './ui/Input';
+11 -11
View File
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite' import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { Input, Textarea } from './Input' import { Input, Textarea } from './Input';
const meta: Meta<typeof Input> = { const meta: Meta<typeof Input> = {
title: 'Shared/Input', title: 'Shared/Input',
@@ -11,35 +11,35 @@ const meta: Meta<typeof Input> = {
</div> </div>
), ),
], ],
} };
export default meta export default meta;
type Story = StoryObj<typeof Input> type Story = StoryObj<typeof Input>;
export const Default: Story = { export const Default: Story = {
args: {}, args: {},
} };
export const WithLabel: Story = { export const WithLabel: Story = {
args: { args: {
label: 'Email address', label: 'Email address',
}, },
} };
export const WithError: Story = { export const WithError: Story = {
args: { args: {
label: 'Email', label: 'Email',
error: 'This field is required', error: 'This field is required',
}, },
} };
export const WithPlaceholder: Story = { export const WithPlaceholder: Story = {
args: { args: {
placeholder: 'Enter your email', placeholder: 'Enter your email',
type: 'email', type: 'email',
}, },
} };
export const TextareaStory: Story = { export const TextareaStory: Story = {
name: 'Textarea', name: 'Textarea',
@@ -48,7 +48,7 @@ export const TextareaStory: Story = {
<Textarea label="Message" rows={4} /> <Textarea label="Message" rows={4} />
</div> </div>
), ),
} };
export const TextareaWithError: Story = { export const TextareaWithError: Story = {
render: () => ( render: () => (
@@ -56,4 +56,4 @@ export const TextareaWithError: Story = {
<Textarea label="Message" error="Too short" rows={4} /> <Textarea label="Message" error="Too short" rows={4} />
</div> </div>
), ),
} };
+80 -80
View File
@@ -1,110 +1,110 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react';
import { Input, Textarea } from './Input' import { Input, Textarea } from './Input';
describe('Input', () => { describe('Input', () => {
describe('rendering', () => { describe('rendering', () => {
it('renders an input element', () => { it('renders an input element', () => {
render(<Input />) render(<Input />);
expect(screen.getByRole('textbox')).toBeInTheDocument() expect(screen.getByRole('textbox')).toBeInTheDocument();
}) });
it('renders label when provided', () => { it('renders label when provided', () => {
render(<Input label="Email" />) render(<Input label="Email" />);
expect(screen.getByText('Email')).toBeInTheDocument() expect(screen.getByText('Email')).toBeInTheDocument();
}) });
it('does not render label when omitted', () => { it('does not render label when omitted', () => {
const { container } = render(<Input />) const { container } = render(<Input />);
expect(container.querySelector('label')).toBeNull() expect(container.querySelector('label')).toBeNull();
}) });
it('renders error message when provided', () => { it('renders error message when provided', () => {
render(<Input error="Required" />) render(<Input error="Required" />);
expect(screen.getByText('Required')).toBeInTheDocument() expect(screen.getByText('Required')).toBeInTheDocument();
}) });
it('does not render error when omitted', () => { it('does not render error when omitted', () => {
render(<Input />) render(<Input />);
expect(screen.queryByText('Required')).toBeNull() expect(screen.queryByText('Required')).toBeNull();
}) });
}) });
describe('accessibility', () => { describe('accessibility', () => {
it('label is associated with input via htmlFor/id', () => { it('label is associated with input via htmlFor/id', () => {
render(<Input label="Email" />) render(<Input label="Email" />);
expect(screen.getByLabelText('Email')).toBeInTheDocument() expect(screen.getByLabelText('Email')).toBeInTheDocument();
}) });
it('error span is referenced by aria-describedby', () => { it('error span is referenced by aria-describedby', () => {
render(<Input error="Required" />) render(<Input error="Required" />);
const input = screen.getByRole('textbox') const input = screen.getByRole('textbox');
const errorId = input.getAttribute('aria-describedby') const errorId = input.getAttribute('aria-describedby');
expect(errorId).toBeTruthy() expect(errorId).toBeTruthy();
expect(document.getElementById(errorId!)).toHaveTextContent('Required') expect(document.getElementById(errorId!)).toHaveTextContent('Required');
}) });
it('no aria-describedby when no error', () => { it('no aria-describedby when no error', () => {
render(<Input />) render(<Input />);
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby') expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby');
}) });
it('uses provided id prop', () => { it('uses provided id prop', () => {
render(<Input id="my-input" label="Email" />) render(<Input id="my-input" label="Email" />);
expect(screen.getByLabelText('Email')).toHaveAttribute('id', 'my-input') expect(screen.getByLabelText('Email')).toHaveAttribute('id', 'my-input');
}) });
}) });
describe('styling', () => { describe('styling', () => {
it('has brutal-border class', () => { it('has brutal-border class', () => {
render(<Input />) render(<Input />);
expect(screen.getByRole('textbox')).toHaveClass('brutal-border') expect(screen.getByRole('textbox')).toHaveClass('brutal-border');
}) });
it('applies custom className', () => { it('applies custom className', () => {
render(<Input className="w-full" />) render(<Input className="w-full" />);
expect(screen.getByRole('textbox')).toHaveClass('w-full') expect(screen.getByRole('textbox')).toHaveClass('w-full');
}) });
}) });
describe('forwarded props', () => { describe('forwarded props', () => {
it('passes placeholder to input', () => { it('passes placeholder to input', () => {
render(<Input placeholder="Enter email" />) render(<Input placeholder="Enter email" />);
expect(screen.getByPlaceholderText('Enter email')).toBeInTheDocument() expect(screen.getByPlaceholderText('Enter email')).toBeInTheDocument();
}) });
it('passes type to input', () => { it('passes type to input', () => {
render(<Input type="email" />) render(<Input type="email" />);
expect(screen.getByRole('textbox')).toHaveAttribute('type', 'email') expect(screen.getByRole('textbox')).toHaveAttribute('type', 'email');
}) });
}) });
}) });
describe('Textarea', () => { describe('Textarea', () => {
describe('rendering', () => { describe('rendering', () => {
it('renders a textarea element', () => { it('renders a textarea element', () => {
render(<Textarea />) render(<Textarea />);
expect(screen.getByRole('textbox')).toBeInTheDocument() expect(screen.getByRole('textbox')).toBeInTheDocument();
}) });
it('renders label when provided', () => { it('renders label when provided', () => {
render(<Textarea label="Message" />) render(<Textarea label="Message" />);
expect(screen.getByText('Message')).toBeInTheDocument() expect(screen.getByText('Message')).toBeInTheDocument();
}) });
it('renders error when provided', () => { it('renders error when provided', () => {
render(<Textarea error="Too short" />) render(<Textarea error="Too short" />);
expect(screen.getByText('Too short')).toBeInTheDocument() expect(screen.getByText('Too short')).toBeInTheDocument();
}) });
it('defaults to 4 rows', () => { it('defaults to 4 rows', () => {
render(<Textarea />) render(<Textarea />);
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '4') expect(screen.getByRole('textbox')).toHaveAttribute('rows', '4');
}) });
it('accepts custom rows', () => { it('accepts custom rows', () => {
render(<Textarea rows={8} />) render(<Textarea rows={8} />);
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '8') expect(screen.getByRole('textbox')).toHaveAttribute('rows', '8');
}) });
}) });
describe('accessibility', () => { describe('accessibility', () => {
it('label is associated with textarea via htmlFor/id', () => { it('label is associated with textarea via htmlFor/id', () => {
render(<Textarea label="Message" />) render(<Textarea label="Message" />);
expect(screen.getByLabelText('Message')).toBeInTheDocument() expect(screen.getByLabelText('Message')).toBeInTheDocument();
}) });
it('error span is referenced by aria-describedby', () => { it('error span is referenced by aria-describedby', () => {
render(<Textarea error="Too short" />) render(<Textarea error="Too short" />);
const textarea = screen.getByRole('textbox') const textarea = screen.getByRole('textbox');
const errorId = textarea.getAttribute('aria-describedby') const errorId = textarea.getAttribute('aria-describedby');
expect(errorId).toBeTruthy() expect(errorId).toBeTruthy();
expect(document.getElementById(errorId!)).toHaveTextContent('Too short') expect(document.getElementById(errorId!)).toHaveTextContent('Too short');
}) });
it('no aria-describedby when no error', () => { it('no aria-describedby when no error', () => {
render(<Textarea />) render(<Textarea />);
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby') expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby');
}) });
}) });
}) });
+37 -20
View File
@@ -1,68 +1,81 @@
import { useId, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react' import { useId, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react';
import { cn } from '$shared/lib' import { cn } from '$shared/lib';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> { interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
/** /**
* Visible label rendered above the input * Visible label rendered above the input
*/ */
label?: string label?: string;
/** /**
* Validation error shown below the input * 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. * Text input with optional label and error state.
*/ */
export function Input({ label, error, className, id, ...props }: InputProps) { export function Input({ label, error, className, id, ...props }: InputProps) {
const generatedId = useId() const generatedId = useId();
const inputId = id ?? generatedId const inputId = id ?? generatedId;
const errorId = `${inputId}-error` const errorId = `${inputId}-error`;
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{label && <label htmlFor={inputId} className="text-carbon-black">{label}</label>} {label && (
<label htmlFor={inputId} className="text-carbon-black">
{label}
</label>
)}
<input <input
id={inputId} id={inputId}
className={cn(INPUT_BASE, className)} className={cn(INPUT_BASE, className)}
aria-describedby={error ? errorId : undefined} aria-describedby={error ? errorId : undefined}
{...props} {...props}
/> />
{error && <span id={errorId} className="text-sm text-burnt-oxide">{error}</span>} {error && (
<span id={errorId} className="text-sm text-burnt-oxide">
{error}
</span>
)}
</div> </div>
) );
} }
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> { interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
/** /**
* Visible label rendered above the textarea * Visible label rendered above the textarea
*/ */
label?: string label?: string;
/** /**
* Validation error shown below the textarea * Validation error shown below the textarea
*/ */
error?: string error?: string;
/** /**
* Number of visible rows * Number of visible rows
* @default 4 * @default 4
*/ */
rows?: number rows?: number;
} }
/** /**
* Multiline textarea with optional label and error state. * Multiline textarea with optional label and error state.
*/ */
export function Textarea({ label, error, rows = 4, className, id, ...props }: TextareaProps) { export function Textarea({ label, error, rows = 4, className, id, ...props }: TextareaProps) {
const generatedId = useId() const generatedId = useId();
const textareaId = id ?? generatedId const textareaId = id ?? generatedId;
const errorId = `${textareaId}-error` const errorId = `${textareaId}-error`;
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{label && <label htmlFor={textareaId} className="text-carbon-black">{label}</label>} {label && (
<label htmlFor={textareaId} className="text-carbon-black">
{label}
</label>
)}
<textarea <textarea
id={textareaId} id={textareaId}
rows={rows} rows={rows}
@@ -70,7 +83,11 @@ export function Textarea({ label, error, rows = 4, className, id, ...props }: Te
aria-describedby={error ? errorId : undefined} aria-describedby={error ? errorId : undefined}
{...props} {...props}
/> />
{error && <span id={errorId} className="text-sm text-burnt-oxide">{error}</span>} {error && (
<span id={errorId} className="text-sm text-burnt-oxide">
{error}
</span>
)}
</div> </div>
) );
} }
+2 -2
View File
@@ -1,2 +1,2 @@
export { Section, Container } from './ui/Section' export { Section, Container } from './ui/Section';
export type { SectionBackground, ContainerSize } from './ui/Section' export type { SectionBackground, ContainerSize } from './ui/Section';
+23 -11
View File
@@ -1,14 +1,14 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite' import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { Section, Container } from './Section' import { Section, Container } from './Section';
const meta: Meta<typeof Section> = { const meta: Meta<typeof Section> = {
title: 'Shared/Section', title: 'Shared/Section',
component: Section, component: Section,
} };
export default meta export default meta;
type Story = StoryObj<typeof Section> type Story = StoryObj<typeof Section>;
export const AllBackgrounds: Story = { export const AllBackgrounds: Story = {
render: () => ( render: () => (
@@ -16,32 +16,44 @@ export const AllBackgrounds: Story = {
<Section background="ochre" className="py-12"> <Section background="ochre" className="py-12">
<Container> <Container>
<h2>Ochre Section</h2> <h2>Ochre Section</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p> <p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua.
</p>
</Container> </Container>
</Section> </Section>
<Section background="slate" className="py-12"> <Section background="slate" className="py-12">
<Container> <Container>
<h2>Slate Section</h2> <h2>Slate Section</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p> <p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua.
</p>
</Container> </Container>
</Section> </Section>
<Section background="white" className="py-12"> <Section background="white" className="py-12">
<Container> <Container>
<h2>White Section</h2> <h2>White Section</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p> <p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua.
</p>
</Container> </Container>
</Section> </Section>
</div> </div>
), ),
} };
export const Bordered: Story = { export const Bordered: Story = {
render: () => ( render: () => (
<Section background="ochre" bordered className="py-12"> <Section background="ochre" bordered className="py-12">
<Container> <Container>
<h2>Bordered Section</h2> <h2>Bordered Section</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p> <p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore
magna aliqua.
</p>
</Container> </Container>
</Section> </Section>
), ),
} };
+70 -62
View File
@@ -1,95 +1,103 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react';
import { Section, Container } from './Section' import { Section, Container } from './Section';
describe('Section', () => { describe('Section', () => {
describe('rendering', () => { describe('rendering', () => {
it('renders a section element', () => { it('renders a section element', () => {
const { container } = render(<Section>content</Section>) const { container } = render(<Section>content</Section>);
expect(container.querySelector('section')).toBeInTheDocument() expect(container.querySelector('section')).toBeInTheDocument();
}) });
it('renders children', () => { it('renders children', () => {
render(<Section><span>hello</span></Section>) render(
expect(screen.getByText('hello')).toBeInTheDocument() <Section>
}) <span>hello</span>
}) </Section>,
);
expect(screen.getByText('hello')).toBeInTheDocument();
});
});
describe('background variants', () => { describe('background variants', () => {
it('defaults to ochre background', () => { it('defaults to ochre background', () => {
const { container } = render(<Section>x</Section>) const { container } = render(<Section>x</Section>);
expect(container.querySelector('section')).toHaveClass('bg-ochre-clay', 'text-carbon-black') expect(container.querySelector('section')).toHaveClass('bg-ochre-clay', 'text-carbon-black');
}) });
it('applies slate background', () => { it('applies slate background', () => {
const { container } = render(<Section background="slate">x</Section>) const { container } = render(<Section background="slate">x</Section>);
expect(container.querySelector('section')).toHaveClass('bg-slate-indigo', 'text-ochre-clay') expect(container.querySelector('section')).toHaveClass('bg-slate-indigo', 'text-ochre-clay');
}) });
it('applies white background', () => { it('applies white background', () => {
const { container } = render(<Section background="white">x</Section>) const { container } = render(<Section background="white">x</Section>);
expect(container.querySelector('section')).toHaveClass('bg-white', 'text-carbon-black') expect(container.querySelector('section')).toHaveClass('bg-white', 'text-carbon-black');
}) });
}) });
describe('bordered', () => { describe('bordered', () => {
it('no border classes by default', () => { it('no border classes by default', () => {
const { container } = render(<Section>x</Section>) const { container } = render(<Section>x</Section>);
const el = container.querySelector('section')! const el = container.querySelector('section')!;
expect(el).not.toHaveClass('brutal-border-top') expect(el).not.toHaveClass('brutal-border-top');
expect(el).not.toHaveClass('brutal-border-bottom') expect(el).not.toHaveClass('brutal-border-bottom');
}) });
it('adds top and bottom borders when bordered=true', () => { it('adds top and bottom borders when bordered=true', () => {
const { container } = render(<Section bordered>x</Section>) const { container } = render(<Section bordered>x</Section>);
const el = container.querySelector('section')! const el = container.querySelector('section')!;
expect(el).toHaveClass('brutal-border-top') expect(el).toHaveClass('brutal-border-top');
expect(el).toHaveClass('brutal-border-bottom') expect(el).toHaveClass('brutal-border-bottom');
}) });
}) });
describe('className', () => { describe('className', () => {
it('applies custom className', () => { it('applies custom className', () => {
const { container } = render(<Section className="py-16">x</Section>) const { container } = render(<Section className="py-16">x</Section>);
expect(container.querySelector('section')).toHaveClass('py-16') expect(container.querySelector('section')).toHaveClass('py-16');
}) });
}) });
}) });
describe('Container', () => { describe('Container', () => {
describe('rendering', () => { describe('rendering', () => {
it('renders a div with children', () => { it('renders a div with children', () => {
render(<Container><span>inner</span></Container>) render(
expect(screen.getByText('inner')).toBeInTheDocument() <Container>
}) <span>inner</span>
}) </Container>,
);
expect(screen.getByText('inner')).toBeInTheDocument();
});
});
describe('size variants', () => { describe('size variants', () => {
it('defaults to max-w-7xl', () => { it('defaults to max-w-7xl', () => {
const { container } = render(<Container>x</Container>) const { container } = render(<Container>x</Container>);
expect(container.firstChild).toHaveClass('max-w-7xl') expect(container.firstChild).toHaveClass('max-w-7xl');
}) });
it('wide applies max-w-[1920px]', () => { it('wide applies max-w-[1920px]', () => {
const { container } = render(<Container size="wide">x</Container>) const { container } = render(<Container size="wide">x</Container>);
expect(container.firstChild).toHaveClass('max-w-[1920px]') expect(container.firstChild).toHaveClass('max-w-[1920px]');
}) });
it('ultra-wide applies max-w-[2560px]', () => { it('ultra-wide applies max-w-[2560px]', () => {
const { container } = render(<Container size="ultra-wide">x</Container>) const { container } = render(<Container size="ultra-wide">x</Container>);
expect(container.firstChild).toHaveClass('max-w-[2560px]') expect(container.firstChild).toHaveClass('max-w-[2560px]');
}) });
}) });
describe('layout', () => { describe('layout', () => {
it('centers content horizontally', () => { it('centers content horizontally', () => {
const { container } = render(<Container>x</Container>) const { container } = render(<Container>x</Container>);
expect(container.firstChild).toHaveClass('mx-auto') expect(container.firstChild).toHaveClass('mx-auto');
}) });
it('applies horizontal padding', () => { it('applies horizontal padding', () => {
const { container } = render(<Container>x</Container>) const { container } = render(<Container>x</Container>);
expect(container.firstChild).toHaveClass('px-6') expect(container.firstChild).toHaveClass('px-6');
}) });
}) });
describe('className', () => { describe('className', () => {
it('applies custom className', () => { it('applies custom className', () => {
const { container } = render(<Container className="my-custom">x</Container>) const { container } = render(<Container className="my-custom">x</Container>);
expect(container.firstChild).toHaveClass('my-custom') expect(container.firstChild).toHaveClass('my-custom');
}) });
}) });
}) });
+18 -28
View File
@@ -1,82 +1,72 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react';
import { cn } from '$shared/lib' import { cn } from '$shared/lib';
export type SectionBackground = 'ochre' | 'slate' | 'white' export type SectionBackground = 'ochre' | 'slate' | 'white';
export type ContainerSize = 'default' | 'wide' | 'ultra-wide' export type ContainerSize = 'default' | 'wide' | 'ultra-wide';
interface SectionProps { interface SectionProps {
/** /**
* Section content * Section content
*/ */
children: ReactNode children: ReactNode;
/** /**
* Background color variant * Background color variant
* @default 'ochre' * @default 'ochre'
*/ */
background?: SectionBackground background?: SectionBackground;
/** /**
* Adds top and bottom brutal borders * Adds top and bottom brutal borders
* @default false * @default false
*/ */
bordered?: boolean bordered?: boolean;
/** /**
* CSS classes * CSS classes
*/ */
className?: string className?: string;
} }
const BACKGROUNDS: Record<SectionBackground, string> = { const BACKGROUNDS: Record<SectionBackground, string> = {
ochre: 'bg-ochre-clay text-carbon-black', ochre: 'bg-ochre-clay text-carbon-black',
slate: 'bg-slate-indigo text-ochre-clay', slate: 'bg-slate-indigo text-ochre-clay',
white: 'bg-white text-carbon-black', white: 'bg-white text-carbon-black',
} };
/** /**
* Full-width page section with background and optional borders. * Full-width page section with background and optional borders.
*/ */
export function Section({ children, background = 'ochre', bordered = false, className }: SectionProps) { export function Section({ children, background = 'ochre', bordered = false, className }: SectionProps) {
return ( return (
<section <section className={cn(BACKGROUNDS[background], bordered && 'brutal-border-top brutal-border-bottom', className)}>
className={cn(
BACKGROUNDS[background],
bordered && 'brutal-border-top brutal-border-bottom',
className,
)}
>
{children} {children}
</section> </section>
) );
} }
interface ContainerProps { interface ContainerProps {
/** /**
* Container content * Container content
*/ */
children: ReactNode children: ReactNode;
/** /**
* Max-width constraint * Max-width constraint
* @default 'default' * @default 'default'
*/ */
size?: ContainerSize size?: ContainerSize;
/** /**
* CSS classes * CSS classes
*/ */
className?: string className?: string;
} }
const SIZES: Record<ContainerSize, string> = { const SIZES: Record<ContainerSize, string> = {
'default': 'max-w-7xl', default: 'max-w-7xl',
'wide': 'max-w-[1920px]', wide: 'max-w-[1920px]',
'ultra-wide': 'max-w-[2560px]', 'ultra-wide': 'max-w-[2560px]',
} };
/** /**
* Centered content container with responsive horizontal padding. * Centered content container with responsive horizontal padding.
*/ */
export function Container({ children, size = 'default', className }: ContainerProps) { export function Container({ children, size = 'default', className }: ContainerProps) {
return ( return <div className={cn(SIZES[size], 'mx-auto px-6 md:px-12 lg:px-16', className)}>{children}</div>;
<div className={cn(SIZES[size], 'mx-auto px-6 md:px-12 lg:px-16', className)}>
{children}
</div>
)
} }
-1
View File
@@ -1 +0,0 @@
export { SectionAccordion } from './ui/SectionAccordion'
+1 -1
View File
@@ -1 +1 @@
export { TechStackBrick, TechStackGrid } from './ui/TechStack' export { TechStackBrick, TechStackGrid } from './ui/TechStack';
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite' import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { TechStackGrid, TechStackBrick } from './TechStack' import { TechStackGrid, TechStackBrick } from './TechStack';
const meta: Meta<typeof TechStackGrid> = { const meta: Meta<typeof TechStackGrid> = {
title: 'Shared/TechStack', title: 'Shared/TechStack',
@@ -11,11 +11,11 @@ const meta: Meta<typeof TechStackGrid> = {
</div> </div>
), ),
], ],
} };
export default meta export default meta;
type Story = StoryObj<typeof TechStackGrid> type Story = StoryObj<typeof TechStackGrid>;
export const Grid: Story = { export const Grid: Story = {
args: { args: {
@@ -34,7 +34,7 @@ export const Grid: Story = {
'Rust', 'Rust',
], ],
}, },
} };
export const SingleBrick: Story = { export const SingleBrick: Story = {
render: () => ( render: () => (
@@ -42,4 +42,4 @@ export const SingleBrick: Story = {
<TechStackBrick name="TypeScript" /> <TechStackBrick name="TypeScript" />
</div> </div>
), ),
} };
+42 -42
View File
@@ -1,62 +1,62 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react';
import { TechStackBrick, TechStackGrid } from './TechStack' import { TechStackBrick, TechStackGrid } from './TechStack';
describe('TechStackBrick', () => { describe('TechStackBrick', () => {
describe('rendering', () => { describe('rendering', () => {
it('renders the technology name', () => { it('renders the technology name', () => {
render(<TechStackBrick name="TypeScript" />) render(<TechStackBrick name="TypeScript" />);
expect(screen.getByText('TypeScript')).toBeInTheDocument() expect(screen.getByText('TypeScript')).toBeInTheDocument();
}) });
}) });
describe('styling', () => { describe('styling', () => {
it('has brutal-border class', () => { it('has brutal-border class', () => {
const { container } = render(<TechStackBrick name="React" />) const { container } = render(<TechStackBrick name="React" />);
expect(container.firstChild).toHaveClass('brutal-border') expect(container.firstChild).toHaveClass('brutal-border');
}) });
it('has brutal-shadow class', () => { it('has brutal-shadow class', () => {
const { container } = render(<TechStackBrick name="React" />) const { container } = render(<TechStackBrick name="React" />);
expect(container.firstChild).toHaveClass('brutal-shadow') expect(container.firstChild).toHaveClass('brutal-shadow');
}) });
it('name span has uppercase and tracking-wide', () => { it('name span has uppercase and tracking-wide', () => {
render(<TechStackBrick name="Go" />) render(<TechStackBrick name="Go" />);
const span = screen.getByText('Go') const span = screen.getByText('Go');
expect(span).toHaveClass('uppercase', 'tracking-wide') expect(span).toHaveClass('uppercase', 'tracking-wide');
}) });
it('applies custom className', () => { it('applies custom className', () => {
const { container } = render(<TechStackBrick name="Go" className="w-full" />) const { container } = render(<TechStackBrick name="Go" className="w-full" />);
expect(container.firstChild).toHaveClass('w-full') expect(container.firstChild).toHaveClass('w-full');
}) });
}) });
}) });
describe('TechStackGrid', () => { describe('TechStackGrid', () => {
describe('rendering', () => { describe('rendering', () => {
it('renders all skill names', () => { it('renders all skill names', () => {
render(<TechStackGrid skills={['React', 'TypeScript', 'Go']} />) render(<TechStackGrid skills={['React', 'TypeScript', 'Go']} />);
expect(screen.getByText('React')).toBeInTheDocument() expect(screen.getByText('React')).toBeInTheDocument();
expect(screen.getByText('TypeScript')).toBeInTheDocument() expect(screen.getByText('TypeScript')).toBeInTheDocument();
expect(screen.getByText('Go')).toBeInTheDocument() expect(screen.getByText('Go')).toBeInTheDocument();
}) });
it('renders correct number of bricks', () => { it('renders correct number of bricks', () => {
const { container } = render(<TechStackGrid skills={['A', 'B', 'C']} />) const { container } = render(<TechStackGrid skills={['A', 'B', 'C']} />);
expect(container.firstChild!.childNodes).toHaveLength(3) expect(container.firstChild!.childNodes).toHaveLength(3);
}) });
it('renders empty grid with no skills', () => { it('renders empty grid with no skills', () => {
const { container } = render(<TechStackGrid skills={[]} />) const { container } = render(<TechStackGrid skills={[]} />);
expect(container.firstChild!.childNodes).toHaveLength(0) expect(container.firstChild!.childNodes).toHaveLength(0);
}) });
}) });
describe('layout', () => { describe('layout', () => {
it('has grid class', () => { it('has grid class', () => {
const { container } = render(<TechStackGrid skills={['A']} />) const { container } = render(<TechStackGrid skills={['A']} />);
expect(container.firstChild).toHaveClass('grid') expect(container.firstChild).toHaveClass('grid');
}) });
it('applies custom className', () => { it('applies custom className', () => {
const { container } = render(<TechStackGrid skills={[]} className="my-custom" />) const { container } = render(<TechStackGrid skills={[]} className="my-custom" />);
expect(container.firstChild).toHaveClass('my-custom') expect(container.firstChild).toHaveClass('my-custom');
}) });
}) });
}) });
+8 -11
View File
@@ -1,14 +1,14 @@
import { cn } from '$shared/lib' import { cn } from '$shared/lib';
interface TechStackBrickProps { interface TechStackBrickProps {
/** /**
* Technology name displayed in the brick * Technology name displayed in the brick
*/ */
name: string name: string;
/** /**
* CSS classes * CSS classes
*/ */
className?: string className?: string;
} }
/** /**
@@ -25,18 +25,18 @@ export function TechStackBrick({ name, className }: TechStackBrickProps) {
> >
<span className="text-sm uppercase tracking-wide">{name}</span> <span className="text-sm uppercase tracking-wide">{name}</span>
</div> </div>
) );
} }
interface TechStackGridProps { interface TechStackGridProps {
/** /**
* List of technology names to render as bricks * List of technology names to render as bricks
*/ */
skills: string[] skills: string[];
/** /**
* CSS classes * CSS classes
*/ */
className?: string className?: string;
} }
/** /**
@@ -45,14 +45,11 @@ interface TechStackGridProps {
export function TechStackGrid({ skills, className }: TechStackGridProps) { export function TechStackGrid({ skills, className }: TechStackGridProps) {
return ( return (
<div <div
className={cn( className={cn('grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4', className)}
'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4',
className,
)}
> >
{skills.map((skill, index) => ( {skills.map((skill, index) => (
<TechStackBrick key={index} name={skill} /> <TechStackBrick key={index} name={skill} />
))} ))}
</div> </div>
) );
} }
+11 -11
View File
@@ -1,17 +1,17 @@
export { Badge } from './Badge' export { Badge } from './Badge';
export type { BadgeVariant } from './Badge' export type { BadgeVariant } from './Badge';
export { Button } from './Button' export { Button } from './Button';
export type { ButtonVariant, ButtonSize } from './Button' export type { ButtonVariant, ButtonSize } from './Button';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card' export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
export type { CardBackground } from './Card' export type { CardBackground } from './Card';
export { Input, Textarea } from './Input' export { Input, Textarea } from './Input';
export { Section, Container } from './Section' export { Section, Container } from './Section';
export type { SectionBackground, ContainerSize } 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';
+1 -1
View File
@@ -1 +1 @@
import '@testing-library/jest-dom' import '@testing-library/jest-dom';
+4 -4
View File
@@ -1,4 +1,4 @@
export { MobileNav } from './ui/MobileNav' export { MobileNav } from './ui/MobileNav';
export { SidebarNav } from './ui/SidebarNav' export { SidebarNav } from './ui/SidebarNav';
export { UtilityBar } from './ui/UtilityBar' export { UtilityBar } from './ui/UtilityBar';
export type { NavItem } from './model/types' export type { NavItem } from './model/types';
+4 -4
View File
@@ -2,13 +2,13 @@ export type NavItem = {
/** /**
* Section HTML id for anchor scrolling * Section HTML id for anchor scrolling
*/ */
id: string id: string;
/** /**
* Display label * Display label
*/ */
label: string label: string;
/** /**
* Display number prefix (e.g. "01") * Display number prefix (e.g. "01")
*/ */
number: string number: string;
} };
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite' import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { MobileNav } from './MobileNav' import { MobileNav } from './MobileNav';
// MobileNav is lg:hidden — it renders only on mobile viewports. // 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. // Use the viewport toolbar in Storybook to switch to a mobile size to see it.
@@ -11,11 +11,11 @@ const meta: Meta<typeof MobileNav> = {
defaultViewport: 'mobile1', defaultViewport: 'mobile1',
}, },
}, },
} };
export default meta export default meta;
type Story = StoryObj<typeof MobileNav> type Story = StoryObj<typeof MobileNav>;
export const Default: Story = { export const Default: Story = {
args: { args: {
@@ -25,4 +25,4 @@ export const Default: Story = {
{ id: 'contact', label: 'Contact', number: '03' }, { id: 'contact', label: 'Contact', number: '03' },
], ],
}, },
} };
+31 -31
View File
@@ -1,46 +1,46 @@
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event';
import { MobileNav } from './MobileNav' import { MobileNav } from './MobileNav';
import type { NavItem } from '../model/types' 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('MobileNav', () => {
describe('rendering', () => { describe('rendering', () => {
it('renders title "allmy.work"', () => { it('renders title "allmy.work"', () => {
render(<MobileNav items={ITEMS} />) render(<MobileNav items={ITEMS} />);
expect(screen.getByText('allmy.work')).toBeInTheDocument() expect(screen.getByText('allmy.work')).toBeInTheDocument();
}) });
it('renders toggle button with text "Menu" initially', () => { it('renders toggle button with text "Menu" initially', () => {
render(<MobileNav items={ITEMS} />) render(<MobileNav items={ITEMS} />);
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
}) });
it('menu items are hidden initially', () => { it('menu items are hidden initially', () => {
render(<MobileNav items={ITEMS} />) render(<MobileNav items={ITEMS} />);
expect(screen.queryByRole('button', { name: /about/i })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: /about/i })).not.toBeInTheDocument();
}) });
}) });
describe('interactions', () => { describe('interactions', () => {
it('click toggle shows item buttons and changes label to "Close"', async () => { it('click toggle shows item buttons and changes label to "Close"', async () => {
render(<MobileNav items={ITEMS} />) render(<MobileNav items={ITEMS} />);
await userEvent.click(screen.getByRole('button', { name: 'Menu' })) await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
expect(screen.getByText('About')).toBeInTheDocument() expect(screen.getByText('About')).toBeInTheDocument();
}) });
it('click item button closes the menu', async () => { it('click item button closes the menu', async () => {
render(<MobileNav items={ITEMS} />) render(<MobileNav items={ITEMS} />);
await userEvent.click(screen.getByRole('button', { name: 'Menu' })) await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
// item button label contains number + label text; find by accessible name fragment // item button label contains number + label text; find by accessible name fragment
const itemBtn = screen.getAllByRole('button').find(b => b.textContent?.includes('About')) const itemBtn = screen.getAllByRole('button').find((b) => b.textContent?.includes('About'));
expect(itemBtn).toBeDefined() expect(itemBtn).toBeDefined();
await userEvent.click(itemBtn!) await userEvent.click(itemBtn!);
expect(screen.queryByText('Close')).not.toBeInTheDocument() expect(screen.queryByText('Close')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
}) });
}) });
}) });
+13 -13
View File
@@ -1,32 +1,32 @@
'use client' 'use client';
import { useState } from 'react' import { useState } from 'react';
import { cn } from '$shared/lib' import { cn } from '$shared/lib';
import type { NavItem } from '../model/types' import type { NavItem } from '../model/types';
interface Props { interface Props {
/** /**
* Navigation items to render * Navigation items to render
*/ */
items: NavItem[] items: NavItem[];
} }
/** /**
* Mobile navigation overlay, hidden on lg+ screens. * Mobile navigation overlay, hidden on lg+ screens.
*/ */
export function MobileNav({ items }: Props) { 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. * Scrolls to the section by id with a 100px offset, then closes the menu.
*/ */
function scrollToSection(id: string) { function scrollToSection(id: string) {
const el = document.getElementById(id) const el = document.getElementById(id);
if (el) { if (el) {
const top = el.getBoundingClientRect().top + window.scrollY - 100 const top = el.getBoundingClientRect().top + window.scrollY - 100;
window.scrollTo({ top, behavior: 'smooth' }) window.scrollTo({ top, behavior: 'smooth' });
} }
setIsOpen(false) setIsOpen(false);
} }
return ( return (
@@ -34,7 +34,7 @@ export function MobileNav({ items }: Props) {
<div className="px-6 py-4 flex items-center justify-between"> <div className="px-6 py-4 flex items-center justify-between">
<h4>allmy.work</h4> <h4>allmy.work</h4>
<button <button
onClick={() => setIsOpen(prev => !prev)} onClick={() => setIsOpen((prev) => !prev)}
className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay" className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay"
> >
{isOpen ? 'Close' : 'Menu'} {isOpen ? 'Close' : 'Menu'}
@@ -42,7 +42,7 @@ export function MobileNav({ items }: Props) {
</div> </div>
{isOpen && ( {isOpen && (
<div className="px-6 py-6 brutal-border-top space-y-2 max-h-[80vh] overflow-y-auto"> <div className="px-6 py-6 brutal-border-top space-y-2 max-h-[80vh] overflow-y-auto">
{items.map(item => ( {items.map((item) => (
<button <button
key={item.id} key={item.id}
onClick={() => scrollToSection(item.id)} onClick={() => scrollToSection(item.id)}
@@ -62,5 +62,5 @@ export function MobileNav({ items }: Props) {
</div> </div>
)} )}
</div> </div>
) );
} }
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite' import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { SidebarNav } from './SidebarNav' import { SidebarNav } from './SidebarNav';
// SidebarNav is hidden lg:block — it renders only on desktop viewports. // 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. // Use the viewport toolbar in Storybook to switch to a desktop size to see it.
@@ -12,11 +12,11 @@ const meta: Meta<typeof SidebarNav> = {
defaultViewport: 'desktop', defaultViewport: 'desktop',
}, },
}, },
} };
export default meta export default meta;
type Story = StoryObj<typeof SidebarNav> type Story = StoryObj<typeof SidebarNav>;
export const Default: Story = { export const Default: Story = {
args: { args: {
@@ -26,4 +26,4 @@ export const Default: Story = {
{ id: 'contact', label: 'Contact', number: '03' }, { id: 'contact', label: 'Contact', number: '03' },
], ],
}, },
} };
+35 -35
View File
@@ -1,12 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react';
import { SidebarNav } from './SidebarNav' import { SidebarNav } from './SidebarNav';
import type { NavItem } from '../model/types' import type { NavItem } from '../model/types';
const ITEMS: NavItem[] = [ const ITEMS: NavItem[] = [
{ id: 'bio', label: 'Bio', number: '01' }, { id: 'bio', label: 'Bio', number: '01' },
{ id: 'work', label: 'Work', number: '02' }, { id: 'work', label: 'Work', number: '02' },
] ];
beforeEach(() => { beforeEach(() => {
global.IntersectionObserver = vi.fn(function () { global.IntersectionObserver = vi.fn(function () {
@@ -14,49 +14,49 @@ beforeEach(() => {
observe: vi.fn(), observe: vi.fn(),
disconnect: vi.fn(), disconnect: vi.fn(),
unobserve: vi.fn(), unobserve: vi.fn(),
} };
}) as unknown as typeof IntersectionObserver }) as unknown as typeof IntersectionObserver;
}) });
describe('SidebarNav', () => { describe('SidebarNav', () => {
describe('rendering', () => { describe('rendering', () => {
it('renders a nav element', () => { it('renders a nav element', () => {
render(<SidebarNav items={ITEMS} />) render(<SidebarNav items={ITEMS} />);
expect(screen.getByRole('navigation')).toBeInTheDocument() expect(screen.getByRole('navigation')).toBeInTheDocument();
}) });
it('renders "Index" heading', () => { it('renders "Index" heading', () => {
render(<SidebarNav items={ITEMS} />) render(<SidebarNav items={ITEMS} />);
expect(screen.getByText('Index')).toBeInTheDocument() expect(screen.getByText('Index')).toBeInTheDocument();
}) });
it('renders "Digital Monograph" subtitle', () => { it('renders "Digital Monograph" subtitle', () => {
render(<SidebarNav items={ITEMS} />) render(<SidebarNav items={ITEMS} />);
expect(screen.getByText('Digital Monograph')).toBeInTheDocument() expect(screen.getByText('Digital Monograph')).toBeInTheDocument();
}) });
it('renders each item label and number', () => { it('renders each item label and number', () => {
render(<SidebarNav items={ITEMS} />) render(<SidebarNav items={ITEMS} />);
expect(screen.getByText('Bio')).toBeInTheDocument() expect(screen.getByText('Bio')).toBeInTheDocument();
expect(screen.getByText('01')).toBeInTheDocument() expect(screen.getByText('01')).toBeInTheDocument();
expect(screen.getByText('Work')).toBeInTheDocument() expect(screen.getByText('Work')).toBeInTheDocument();
expect(screen.getByText('02')).toBeInTheDocument() expect(screen.getByText('02')).toBeInTheDocument();
}) });
it('renders "Quick Links" section', () => { it('renders "Quick Links" section', () => {
render(<SidebarNav items={ITEMS} />) render(<SidebarNav items={ITEMS} />);
expect(screen.getByText('Quick Links')).toBeInTheDocument() expect(screen.getByText('Quick Links')).toBeInTheDocument();
}) });
it('renders Email quick link', () => { it('renders Email quick link', () => {
render(<SidebarNav items={ITEMS} />) render(<SidebarNav items={ITEMS} />);
expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument() expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument();
}) });
it('renders a button for each item', () => { it('renders a button for each item', () => {
render(<SidebarNav items={ITEMS} />) render(<SidebarNav items={ITEMS} />);
const buttons = screen.getAllByRole('button') const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThanOrEqual(ITEMS.length) expect(buttons.length).toBeGreaterThanOrEqual(ITEMS.length);
}) });
}) });
}) });
+36 -28
View File
@@ -1,50 +1,50 @@
'use client' 'use client';
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react';
import { cn } from '$shared/lib' import { cn } from '$shared/lib';
import type { NavItem } from '../model/types' import type { NavItem } from '../model/types';
interface Props { interface Props {
/** /**
* Navigation items to render * Navigation items to render
*/ */
items: NavItem[] items: NavItem[];
} }
/** /**
* Fixed sidebar navigation, visible on lg+ screens. * Fixed sidebar navigation, visible on lg+ screens.
*/ */
export function SidebarNav({ items }: Props) { export function SidebarNav({ items }: Props) {
const [activeSection, setActiveSection] = useState('bio') const [activeSection, setActiveSection] = useState('bio');
useEffect(() => { useEffect(() => {
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
entries => { (entries) => {
entries.forEach(entry => { entries.forEach((entry) => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
setActiveSection(entry.target.id) setActiveSection(entry.target.id);
} }
}) });
}, },
{ rootMargin: '-20% 0px -70% 0px', threshold: 0 }, { rootMargin: '-20% 0px -70% 0px', threshold: 0 },
) );
items.forEach(item => { items.forEach((item) => {
const el = document.getElementById(item.id) const el = document.getElementById(item.id);
if (el) observer.observe(el) if (el) observer.observe(el);
}) });
return () => observer.disconnect() return () => observer.disconnect();
}, [items]) }, [items]);
/** /**
* Scrolls to the section by id with a 40px offset. * Scrolls to the section by id with a 40px offset.
*/ */
function scrollToSection(id: string) { function scrollToSection(id: string) {
const el = document.getElementById(id) const el = document.getElementById(id);
if (el) { if (el) {
const top = el.getBoundingClientRect().top + window.scrollY - 40 const top = el.getBoundingClientRect().top + window.scrollY - 40;
window.scrollTo({ top, behavior: 'smooth' }) window.scrollTo({ top, behavior: 'smooth' });
} }
} }
@@ -58,8 +58,8 @@ export function SidebarNav({ items }: Props) {
</div> </div>
</div> </div>
{items.map(item => { {items.map((item) => {
const isActive = activeSection === item.id const isActive = activeSection === item.id;
return ( return (
<button <button
key={item.id} key={item.id}
@@ -76,19 +76,27 @@ export function SidebarNav({ items }: Props) {
<span className="font-heading text-xl font-black">{item.label}</span> <span className="font-heading text-xl font-black">{item.label}</span>
</div> </div>
</button> </button>
) );
})} })}
<div className="mt-12 pt-12 brutal-border-top"> <div className="mt-12 pt-12 brutal-border-top">
<p className="text-sm uppercase tracking-wider mb-4 opacity-60">Quick Links</p> <p className="text-sm uppercase tracking-wider mb-4 opacity-60">Quick Links</p>
<div className="space-y-3"> <div className="space-y-3">
<a href="mailto:hello@allmy.work" className="block">Email</a> <a href="mailto:hello@allmy.work" className="block">
<a href="https://linkedin.com" className="block">LinkedIn</a> Email
<a href="https://instagram.com" className="block">Instagram</a> </a>
<a href="https://are.na" className="block">Are.na</a> <a href="https://linkedin.com" className="block">
LinkedIn
</a>
<a href="https://instagram.com" className="block">
Instagram
</a>
<a href="https://are.na" className="block">
Are.na
</a>
</div> </div>
</div> </div>
</div> </div>
</nav> </nav>
) );
} }
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite' import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { UtilityBar } from './UtilityBar' import { UtilityBar } from './UtilityBar';
const meta: Meta<typeof UtilityBar> = { const meta: Meta<typeof UtilityBar> = {
title: 'Widgets/UtilityBar', title: 'Widgets/UtilityBar',
@@ -11,10 +11,10 @@ const meta: Meta<typeof UtilityBar> = {
</div> </div>
), ),
], ],
} };
export default meta export default meta;
type Story = StoryObj<typeof UtilityBar> type Story = StoryObj<typeof UtilityBar>;
export const Default: Story = {} export const Default: Story = {};
+20 -20
View File
@@ -1,30 +1,30 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react';
import { UtilityBar } from './UtilityBar' import { UtilityBar } from './UtilityBar';
describe('UtilityBar', () => { describe('UtilityBar', () => {
describe('rendering', () => { describe('rendering', () => {
it('renders "Contact" label', () => { it('renders "Contact" label', () => {
render(<UtilityBar />) render(<UtilityBar />);
expect(screen.getByText('Contact')).toBeInTheDocument() expect(screen.getByText('Contact')).toBeInTheDocument();
}) });
it('renders email link with correct href', () => { it('renders email link with correct href', () => {
render(<UtilityBar />) render(<UtilityBar />);
const link = screen.getByRole('link', { name: 'hello@allmy.work' }) const link = screen.getByRole('link', { name: 'hello@allmy.work' });
expect(link).toBeInTheDocument() expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', 'mailto:hello@allmy.work') expect(link).toHaveAttribute('href', 'mailto:hello@allmy.work');
}) });
it('renders "Download CV" button', () => { it('renders "Download CV" button', () => {
render(<UtilityBar />) render(<UtilityBar />);
expect(screen.getByRole('button', { name: /download cv/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /download cv/i })).toBeInTheDocument();
}) });
it('Download CV button has primary variant class', () => { it('Download CV button has primary variant class', () => {
render(<UtilityBar />) render(<UtilityBar />);
const btn = screen.getByRole('button', { name: /download cv/i }) const btn = screen.getByRole('button', { name: /download cv/i });
expect(btn).toHaveClass('bg-burnt-oxide') expect(btn).toHaveClass('bg-burnt-oxide');
}) });
}) });
}) });
+5 -8
View File
@@ -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. * Fixed bottom utility bar with contact info and CV download.
@@ -10,7 +10,7 @@ export function UtilityBar() {
* Handles CV download action. * Handles CV download action.
*/ */
function handleDownloadCV() { function handleDownloadCV() {
console.log('Downloading CV...') console.log('Downloading CV...');
} }
return ( return (
@@ -18,10 +18,7 @@ export function UtilityBar() {
<div className="max-w-[2560px] mx-auto px-6 md:px-12 lg:px-16 py-4 flex items-center justify-between"> <div className="max-w-[2560px] mx-auto px-6 md:px-12 lg:px-16 py-4 flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="text-sm uppercase tracking-wider">Contact</span> <span className="text-sm uppercase tracking-wider">Contact</span>
<a <a href="mailto:hello@allmy.work" className="text-base hover:text-burnt-oxide transition-colors">
href="mailto:hello@allmy.work"
className="text-base hover:text-burnt-oxide transition-colors"
>
hello@allmy.work hello@allmy.work
</a> </a>
</div> </div>
@@ -30,5 +27,5 @@ export function UtilityBar() {
</Button> </Button>
</div> </div>
</div> </div>
) );
} }
+1 -1
View File
@@ -1 +1 @@
export * from './Navigation' export * from './Navigation';