;
export const Default: Story = {
args: {
@@ -24,7 +24,7 @@ export const Default: Story = {
description: 'A brutalist portfolio site built with Next.js and Tailwind CSS.',
tags: ['React', 'TypeScript', 'Next.js'],
},
-}
+};
export const WithImage: Story = {
args: {
@@ -34,4 +34,4 @@ export const WithImage: Story = {
tags: ['React', 'TypeScript', 'Next.js'],
imageUrl: 'https://placehold.co/800x450/3B4A59/D9B48F?text=Project',
},
-}
+};
diff --git a/src/entities/project/ui/ProjectCard/ProjectCard.test.tsx b/src/entities/project/ui/ProjectCard/ProjectCard.test.tsx
new file mode 100644
index 0000000..0985915
--- /dev/null
+++ b/src/entities/project/ui/ProjectCard/ProjectCard.test.tsx
@@ -0,0 +1,128 @@
+import { render, screen } from '@testing-library/react';
+import { ProjectCard } from './ProjectCard';
+
+const DEFAULT_PROPS = {
+ title: 'My Project',
+ year: '2024',
+ description: 'A cool project description',
+ tags: ['React', 'Node'],
+ url: 'https://example.com',
+};
+
+describe('ProjectCard', () => {
+ describe('rendering', () => {
+ it('renders the project title', () => {
+ render();
+ expect(screen.getByText('My Project')).toBeInTheDocument();
+ });
+
+ it('renders the year', () => {
+ render();
+ expect(screen.getByText('2024')).toBeInTheDocument();
+ });
+
+ it('renders the description', () => {
+ render();
+ expect(screen.getByText('A cool project description')).toBeInTheDocument();
+ });
+
+ it('renders each tag', () => {
+ render();
+ expect(screen.getByText('React')).toBeInTheDocument();
+ expect(screen.getByText('Node')).toBeInTheDocument();
+ });
+
+ it('renders the View Project button', () => {
+ render();
+ expect(screen.getByRole('link', { name: /view project/i })).toBeInTheDocument();
+ });
+
+ it('View Project link points to the project url', () => {
+ render();
+ expect(screen.getByRole('link', { name: /view project/i })).toHaveAttribute('href', 'https://example.com');
+ });
+
+ it('View Project link opens in a new tab', () => {
+ render();
+ expect(screen.getByRole('link', { name: /view project/i })).toHaveAttribute('target', '_blank');
+ });
+ });
+
+ describe('layout', () => {
+ it('year is inside the sidebar column', () => {
+ render();
+ expect(screen.getByText('2024').closest('.brutal-border-sidebar')).toBeInTheDocument();
+ });
+
+ it('tags are inside the sidebar column', () => {
+ render();
+ expect(screen.getByText('React').closest('.brutal-border-sidebar')).toBeInTheDocument();
+ expect(screen.getByText('Node').closest('.brutal-border-sidebar')).toBeInTheDocument();
+ });
+
+ it('View Project button is inside the sidebar column', () => {
+ render();
+ const btn = screen.getByRole('link', { name: /view project/i });
+ expect(btn.closest('.brutal-border-sidebar')).toBeInTheDocument();
+ });
+
+ it('title is outside the sidebar column', () => {
+ render();
+ expect(screen.getByText('My Project').closest('.brutal-border-sidebar')).toBeNull();
+ });
+
+ it('description is outside the sidebar column', () => {
+ render();
+ expect(screen.getByText('A cool project description').closest('.brutal-border-sidebar')).toBeNull();
+ });
+ });
+
+ describe('structure', () => {
+ it('card has hover transition classes', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('group', 'transition-shadow', 'duration-300');
+ });
+
+ it('title renders as h3', () => {
+ render();
+ expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('My Project');
+ });
+
+ it('year has period-style left border', () => {
+ render();
+ const year = screen.getByText('2024');
+ expect(year.tagName).toBe('P');
+ expect(year).toHaveClass('brutal-border-left', 'text-sm');
+ });
+
+ it('View Project button uses sm size', () => {
+ render();
+ const btn = screen.getByRole('link', { name: /view project/i });
+ expect(btn).toHaveClass('px-4', 'py-2', 'text-sm');
+ });
+
+ it('tags are xs outline badges', () => {
+ render();
+ const tag = screen.getByText('React');
+ expect(tag).toHaveClass('brutal-border', 'bg-transparent', 'px-2');
+ });
+ });
+
+ describe('conditional image rendering', () => {
+ it('does not render image when imageUrl is absent', () => {
+ const { container } = render();
+ expect(container.querySelector('img')).toBeNull();
+ });
+
+ it('renders image when imageUrl is provided', () => {
+ render();
+ expect(screen.getByRole('img')).toBeInTheDocument();
+ });
+
+ it('image wrapper has aspect-video and overflow-hidden', () => {
+ const { container } = render();
+ const imgWrapper = container.querySelector('img')?.parentElement;
+ expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border');
+ });
+ });
+});
diff --git a/src/entities/project/ui/ProjectCard/ProjectCard.tsx b/src/entities/project/ui/ProjectCard/ProjectCard.tsx
new file mode 100644
index 0000000..b1d7422
--- /dev/null
+++ b/src/entities/project/ui/ProjectCard/ProjectCard.tsx
@@ -0,0 +1,71 @@
+import Image from 'next/image';
+import { cn } from '$shared/lib';
+import { Badge, Button, Card, CardSidebar, CardTitle, RichText } from '$shared/ui';
+
+type Props = {
+ /**
+ * Project name
+ */
+ title: string;
+ /**
+ * Year the project was completed
+ */
+ year: string;
+ /**
+ * Short project description
+ */
+ description: string;
+ /**
+ * Technology or category tags
+ */
+ tags: string[];
+ /**
+ * Project's URL
+ */
+ url: string;
+ /**
+ * Optional preview image URL
+ */
+ imageUrl?: string;
+};
+
+/**
+ * Project card with sidebar layout.
+ * Sidebar: year badge, stack tags, View Project button.
+ * Main: title, optional image, description.
+ */
+export function ProjectCard({ title, year, description, tags, url, imageUrl }: Props) {
+ return (
+
+
+ {year}
+ {tags.length > 0 && (
+
+ {tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+
+ }
+ >
+
+
{title}
+ {imageUrl && (
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/entities/project/ui/ProjectMetadata.test.tsx b/src/entities/project/ui/ProjectMetadata.test.tsx
deleted file mode 100644
index 2efc055..0000000
--- a/src/entities/project/ui/ProjectMetadata.test.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import { describe, it, expect } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import { ProjectMetadata } from './ProjectMetadata'
-
-const DEFAULT_PROPS = {
- year: '2024',
- role: 'Frontend Engineer',
- stack: ['React', 'TypeScript', 'Tailwind'],
-}
-
-describe('ProjectMetadata', () => {
- describe('rendering', () => {
- it('renders the year value', () => {
- render()
- expect(screen.getByText('2024')).toBeInTheDocument()
- })
-
- it('renders the YEAR label', () => {
- render()
- expect(screen.getByText('YEAR')).toBeInTheDocument()
- })
-
- it('renders the role value', () => {
- render()
- expect(screen.getByText('Frontend Engineer')).toBeInTheDocument()
- })
-
- it('renders the ROLE label', () => {
- render()
- expect(screen.getByText('ROLE')).toBeInTheDocument()
- })
-
- it('renders the STACK label', () => {
- render()
- expect(screen.getByText('STACK')).toBeInTheDocument()
- })
-
- it('renders each stack technology', () => {
- render()
- expect(screen.getByText('React')).toBeInTheDocument()
- expect(screen.getByText('TypeScript')).toBeInTheDocument()
- expect(screen.getByText('Tailwind')).toBeInTheDocument()
- })
- })
-
- describe('structure', () => {
- it('outer div has space-y-6', () => {
- const { container } = render()
- expect(container.firstChild).toHaveClass('space-y-6')
- })
-
- it('year section has no brutal-border-top (first section)', () => {
- const { container } = render()
- const sections = container.firstChild!.childNodes
- expect(sections[0]).not.toHaveClass('brutal-border-top')
- })
-
- it('role section has brutal-border-top and pt-6', () => {
- const { container } = render()
- const sections = container.firstChild!.childNodes
- expect(sections[1]).toHaveClass('brutal-border-top', 'pt-6')
- })
-
- it('stack section has brutal-border-top and pt-6', () => {
- const { container } = render()
- const sections = container.firstChild!.childNodes
- expect(sections[2]).toHaveClass('brutal-border-top', 'pt-6')
- })
-
- it('label has text-xs uppercase tracking-wider opacity-60', () => {
- render()
- const yearLabel = screen.getByText('YEAR')
- expect(yearLabel).toHaveClass('text-xs', 'uppercase', 'tracking-wider', 'opacity-60')
- })
-
- it('year value has text-base font-bold', () => {
- render()
- const yearValue = screen.getByText('2024')
- expect(yearValue).toHaveClass('text-base', 'font-bold')
- })
-
- it('each stack tech is rendered as a with text-sm', () => {
- render()
- const techEl = screen.getByText('React')
- expect(techEl.tagName).toBe('P')
- expect(techEl).toHaveClass('text-sm')
- })
- })
-
- describe('className passthrough', () => {
- it('merges custom className onto outer div', () => {
- const { container } = render()
- expect(container.firstChild).toHaveClass('my-custom')
- })
- })
-})
diff --git a/src/entities/project/ui/ProjectMetadata.stories.tsx b/src/entities/project/ui/ProjectMetadata/ProjectMetadata.stories.tsx
similarity index 68%
rename from src/entities/project/ui/ProjectMetadata.stories.tsx
rename to src/entities/project/ui/ProjectMetadata/ProjectMetadata.stories.tsx
index 2773220..0850c18 100644
--- a/src/entities/project/ui/ProjectMetadata.stories.tsx
+++ b/src/entities/project/ui/ProjectMetadata/ProjectMetadata.stories.tsx
@@ -1,5 +1,5 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite'
-import { ProjectMetadata } from './ProjectMetadata'
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { ProjectMetadata } from './ProjectMetadata';
const meta: Meta = {
title: 'Entities/ProjectMetadata',
@@ -11,11 +11,11 @@ const meta: Meta = {
),
],
-}
+};
-export default meta
+export default meta;
-type Story = StoryObj
+type Story = StoryObj;
export const Default: Story = {
args: {
@@ -23,4 +23,4 @@ export const Default: Story = {
role: 'Lead Frontend Engineer',
stack: ['React', 'TypeScript', 'Next.js', 'Tailwind'],
},
-}
+};
diff --git a/src/entities/project/ui/ProjectMetadata/ProjectMetadata.test.tsx b/src/entities/project/ui/ProjectMetadata/ProjectMetadata.test.tsx
new file mode 100644
index 0000000..363997e
--- /dev/null
+++ b/src/entities/project/ui/ProjectMetadata/ProjectMetadata.test.tsx
@@ -0,0 +1,95 @@
+import { render, screen } from '@testing-library/react';
+import { ProjectMetadata } from './ProjectMetadata';
+
+const DEFAULT_PROPS = {
+ year: '2024',
+ role: 'Frontend Engineer',
+ stack: ['React', 'TypeScript', 'Tailwind'],
+};
+
+describe('ProjectMetadata', () => {
+ describe('rendering', () => {
+ it('renders the year value', () => {
+ render();
+ expect(screen.getByText('2024')).toBeInTheDocument();
+ });
+
+ it('renders the YEAR label', () => {
+ render();
+ expect(screen.getByText('YEAR')).toBeInTheDocument();
+ });
+
+ it('renders the role value', () => {
+ render();
+ expect(screen.getByText('Frontend Engineer')).toBeInTheDocument();
+ });
+
+ it('renders the ROLE label', () => {
+ render();
+ expect(screen.getByText('ROLE')).toBeInTheDocument();
+ });
+
+ it('renders the STACK label', () => {
+ render();
+ expect(screen.getByText('STACK')).toBeInTheDocument();
+ });
+
+ it('renders each stack technology', () => {
+ render();
+ expect(screen.getByText('React')).toBeInTheDocument();
+ expect(screen.getByText('TypeScript')).toBeInTheDocument();
+ expect(screen.getByText('Tailwind')).toBeInTheDocument();
+ });
+ });
+
+ describe('structure', () => {
+ it('outer div has space-y-6', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('space-y-6');
+ });
+
+ it('year section has no brutal-border-top (first section)', () => {
+ const { container } = render();
+ const sections = container.firstChild?.childNodes as NodeListOf;
+ expect(sections[0]).not.toHaveClass('brutal-border-top');
+ });
+
+ it('role section has brutal-border-top and pt-6', () => {
+ const { container } = render();
+ const sections = container.firstChild?.childNodes as NodeListOf;
+ expect(sections[1]).toHaveClass('brutal-border-top', 'pt-6');
+ });
+
+ it('stack section has brutal-border-top and pt-6', () => {
+ const { container } = render();
+ const sections = container.firstChild?.childNodes as NodeListOf;
+ expect(sections[2]).toHaveClass('brutal-border-top', 'pt-6');
+ });
+
+ it('label has text-xs uppercase tracking-wider opacity-60', () => {
+ render();
+ const yearLabel = screen.getByText('YEAR');
+ expect(yearLabel).toHaveClass('text-xs', 'uppercase', 'tracking-wider', 'opacity-60');
+ });
+
+ it('year value has text-base font-bold', () => {
+ render();
+ const yearValue = screen.getByText('2024');
+ expect(yearValue).toHaveClass('text-base', 'font-bold');
+ });
+
+ it('each stack tech is rendered as a with text-sm', () => {
+ render();
+ const techEl = screen.getByText('React');
+ expect(techEl.tagName).toBe('P');
+ expect(techEl).toHaveClass('text-sm');
+ });
+ });
+
+ describe('className passthrough', () => {
+ it('merges custom className onto outer div', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('my-custom');
+ });
+ });
+});
diff --git a/src/entities/project/ui/ProjectMetadata.tsx b/src/entities/project/ui/ProjectMetadata/ProjectMetadata.tsx
similarity index 82%
rename from src/entities/project/ui/ProjectMetadata.tsx
rename to src/entities/project/ui/ProjectMetadata/ProjectMetadata.tsx
index f1b73f5..9722b83 100644
--- a/src/entities/project/ui/ProjectMetadata.tsx
+++ b/src/entities/project/ui/ProjectMetadata/ProjectMetadata.tsx
@@ -1,23 +1,23 @@
-import { cn } from '$shared/lib'
+import { cn } from '$shared/lib';
type Props = {
/**
* Project year
*/
- year: string
+ year: string;
/**
* Developer role on the project
*/
- role: string
+ role: string;
/**
* Technology stack list
*/
- stack: string[]
+ stack: string[];
/**
* Additional CSS classes
*/
- className?: string
-}
+ className?: string;
+};
/**
* Sidebar metadata display for a project: year, role, and stack.
@@ -36,9 +36,11 @@ export function ProjectMetadata({ year, role, stack, className }: Props) {
STACK
{stack.map((tech) => (
-
{tech}
+
+ {tech}
+
))}
- )
+ );
}
diff --git a/src/entities/project/ui/index.ts b/src/entities/project/ui/index.ts
new file mode 100644
index 0000000..e61c189
--- /dev/null
+++ b/src/entities/project/ui/index.ts
@@ -0,0 +1,3 @@
+export { DetailedProjectCard } from './DetailedProjectCard/DetailedProjectCard';
+export { ProjectCard } from './ProjectCard/ProjectCard';
+export { ProjectMetadata } from './ProjectMetadata/ProjectMetadata';
diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts
new file mode 100644
index 0000000..5572351
--- /dev/null
+++ b/src/shared/api/client.ts
@@ -0,0 +1,79 @@
+import type { ListResponse } from './types';
+
+/*
+ * Native fetch wrapper for PocketBase API requests.
+ */
+
+/* Prefer the server-only var (not exposed to the browser bundle),
+ * fall back to the public var for client-side usage, then to the
+ * local dev default. */
+const PB_URL = process.env.PB_URL ?? process.env.NEXT_PUBLIC_PB_URL ?? 'http://127.0.0.1:8090';
+
+/**
+ * Options for PocketBase collection fetching.
+ */
+export type PBFetchOptions = {
+ /**
+ * Sorting criteria (e.g., "-created,order")
+ */
+ sort?: string;
+ /**
+ * Filter query string
+ */
+ filter?: string;
+ /**
+ * Fields to expand (e.g., "stack")
+ */
+ expand?: string;
+ /**
+ * Cache tags for on-demand revalidation via `revalidateTag`.
+ * Typically set to the collection name.
+ */
+ tags?: string[];
+ /**
+ * ISR revalidation interval in seconds.
+ * @default 3600
+ */
+ revalidate?: number;
+};
+
+/**
+ * Fetch a list of records from a PocketBase collection.
+ */
+export async function getCollection(collection: string, options: PBFetchOptions = {}): Promise> {
+ const { sort, filter, expand, tags, revalidate } = options;
+
+ const params = new URLSearchParams();
+ if (sort) {
+ params.set('sort', sort);
+ }
+ if (filter) {
+ params.set('filter', filter);
+ }
+ if (expand) {
+ params.set('expand', expand);
+ }
+
+ const url = `${PB_URL}/api/collections/${collection}/records?${params.toString()}`;
+
+ const res = await fetch(url, {
+ next: {
+ tags: tags ?? [],
+ revalidate: revalidate ?? 3600,
+ },
+ });
+
+ if (!res.ok) {
+ throw new Error(`PocketBase ${res.status} ${res.statusText} on collection "${collection}"`);
+ }
+
+ return res.json();
+}
+
+/**
+ * Fetch the first record matching an optional filter from a PocketBase collection.
+ */
+export async function getFirstRecord(collection: string, options: PBFetchOptions = {}): Promise {
+ const data = await getCollection(collection, options);
+ return data.items[0] ?? null;
+}
diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts
new file mode 100644
index 0000000..d860d06
--- /dev/null
+++ b/src/shared/api/index.ts
@@ -0,0 +1,2 @@
+export * from './client';
+export * from './types';
diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts
new file mode 100644
index 0000000..b612be1
--- /dev/null
+++ b/src/shared/api/types.ts
@@ -0,0 +1,218 @@
+/**
+ * Common properties for all PocketBase records.
+ */
+export type BaseRecord = {
+ /**
+ * Unique record ID
+ */
+ id: string;
+ /**
+ * ID of the collection this record belongs to
+ */
+ collectionId: string;
+ /**
+ * Name of the collection this record belongs to
+ */
+ collectionName: string;
+ /**
+ * Record creation timestamp (ISO 8601)
+ */
+ created: string;
+ /**
+ * Record last update timestamp (ISO 8601)
+ */
+ updated: string;
+};
+
+/**
+ * PocketBase collection for simple text blocks (Intro, Bio).
+ * Each collection is named after its section — no slug field.
+ */
+export type PageContentRecord = BaseRecord & {
+ /**
+ * HTML or Markdown content string
+ */
+ content: string;
+};
+
+/**
+ * PocketBase collection for technology skills.
+ */
+export type SkillRecord = BaseRecord & {
+ /**
+ * Name of the technology or tool
+ */
+ name: string;
+ /**
+ * Grouping category (e.g., 'Frontend', 'Backend')
+ */
+ category: 'Frontend' | 'Backend' | 'Tools' | 'Design' | string;
+ /**
+ * Sorting weight within the category
+ */
+ order: number;
+};
+
+/**
+ * PocketBase collection for work experience history.
+ */
+export type ExperienceRecord = BaseRecord & {
+ /**
+ * Name of the organization
+ */
+ company: string;
+ /**
+ * Professional title held
+ */
+ role: string;
+ /**
+ * Start date of the tenure
+ */
+ start_date: string;
+ /**
+ * End date of the tenure, or null if currently employed
+ */
+ end_date: string | null;
+ /**
+ * Rich text description of responsibilities and achievements
+ */
+ description: string;
+ /**
+ * Technologies used during this role
+ */
+ stack: string[];
+ /**
+ * Sorting weight for chronological display
+ */
+ order: number;
+};
+
+/**
+ * PocketBase collection for portfolio projects.
+ */
+export type ProjectRecord = BaseRecord & {
+ /**
+ * Full title of the project
+ */
+ title: string;
+ /**
+ * Completion or duration year (e.g., "2024")
+ */
+ year: string;
+ /**
+ * Role performed on the project
+ */
+ role: string;
+ /**
+ * Project description as HTML from the PocketBase rich-text editor
+ */
+ description: string;
+ /**
+ * List of specific feature or achievement points
+ */
+ details: string[];
+ /**
+ * List of SkillRecord IDs used in the project
+ */
+ stack: string[];
+ /**
+ * Primary thumbnail or hero image filename
+ */
+ image: string;
+ /**
+ * Project's url
+ */
+ url: string;
+ /**
+ * Sorting weight for the project list
+ */
+ order: number;
+};
+
+/**
+ * PocketBase collection for individual social profile links.
+ */
+export type SocialRecord = BaseRecord & {
+ /**
+ * Display name shown as the link text
+ */
+ label: string;
+ /**
+ * Full URL for the social profile
+ */
+ url: string;
+};
+
+/**
+ * PocketBase collection for the primary contact record.
+ * Single-record collection — only the first record is consumed.
+ */
+export type ContactsRecord = BaseRecord & {
+ /**
+ * Primary contact email address
+ */
+ email: string;
+ /**
+ * Raw relation IDs — use expand?.socials for resolved records
+ */
+ socials: string[];
+ /**
+ * Expanded relation data, present when fetched with expand=socials
+ */
+ expand?: {
+ /**
+ * Resolved social link records
+ */
+ socials?: SocialRecord[];
+ };
+};
+
+/**
+ * PocketBase collection for global site configuration.
+ * Single-record collection — only the first record is consumed.
+ */
+export type SiteSettingsRecord = BaseRecord & {
+ /**
+ * CV filename stored in PocketBase — build the full URL with buildFileUrl()
+ */
+ cv: string;
+ /**
+ * Raw relation ID — use expand?.contacts for the resolved record
+ */
+ contacts: string;
+ /**
+ * Expanded relation data, present when fetched with expand=contacts,contacts.socials
+ */
+ expand?: {
+ /**
+ * Resolved contacts record
+ */
+ contacts?: ContactsRecord;
+ };
+};
+
+/**
+ * Generic response for a list of PocketBase records.
+ */
+export type ListResponse = {
+ /**
+ * Current page index
+ */
+ page: number;
+ /**
+ * Number of items per page
+ */
+ perPage: number;
+ /**
+ * Total number of items across all pages
+ */
+ totalItems: number;
+ /**
+ * Total number of pages available
+ */
+ totalPages: number;
+ /**
+ * Array of records for the current page
+ */
+ items: T[];
+};
diff --git a/src/shared/index.ts b/src/shared/index.ts
index 1485652..49f26a6 100644
--- a/src/shared/index.ts
+++ b/src/shared/index.ts
@@ -1,2 +1,3 @@
-export * from './ui'
-export * from './lib'
+export * from './api';
+export * from './lib';
+export * from './ui';
diff --git a/src/shared/lib/config/config.ts b/src/shared/lib/config/config.ts
new file mode 100644
index 0000000..c758370
--- /dev/null
+++ b/src/shared/lib/config/config.ts
@@ -0,0 +1,21 @@
+/**
+ * Static contact and social links shown in navigation.
+ */
+export const CONTACT_LINKS = {
+ /**
+ * Primary contact email address
+ */
+ email: 'hello@allmy.work',
+ /**
+ * LinkedIn profile URL
+ */
+ linkedin: 'https://linkedin.com',
+ /**
+ * Instagram profile URL
+ */
+ instagram: 'https://instagram.com',
+ /**
+ * Are.na profile URL
+ */
+ arena: 'https://are.na',
+} as const;
diff --git a/src/shared/lib/fonts/fonts.ts b/src/shared/lib/fonts/fonts.ts
new file mode 100644
index 0000000..63d193c
--- /dev/null
+++ b/src/shared/lib/fonts/fonts.ts
@@ -0,0 +1,18 @@
+import { Fraunces, Public_Sans } from 'next/font/google';
+
+/**
+ * Heading font — variable axes for brutalist variation settings
+ */
+export const fraunces = Fraunces({
+ subsets: ['latin'],
+ variable: '--font-fraunces',
+ axes: ['opsz', 'SOFT', 'WONK'],
+});
+
+/**
+ * Body font
+ */
+export const publicSans = Public_Sans({
+ subsets: ['latin'],
+ variable: '--font-public-sans',
+});
diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts
index 4d7a761..c4070ab 100644
--- a/src/shared/lib/index.ts
+++ b/src/shared/lib/index.ts
@@ -1,2 +1,7 @@
-export { cn } from './cn'
-export type { ClassValue } from 'clsx'
+export type { ClassValue } from 'clsx';
+export { CONTACT_LINKS } from './config/config';
+export * from './fonts/fonts';
+export { buildFileUrl } from './utils/buildFileUrl/buildFileUrl';
+export { cn } from './utils/cn/cn';
+export * from './utils/formatDate/formatDate';
+export { groupByKey } from './utils/groupByKey/groupByKey';
diff --git a/src/shared/lib/utils/buildFileUrl/buildFileUrl.test.ts b/src/shared/lib/utils/buildFileUrl/buildFileUrl.test.ts
new file mode 100644
index 0000000..8d56a0a
--- /dev/null
+++ b/src/shared/lib/utils/buildFileUrl/buildFileUrl.test.ts
@@ -0,0 +1,33 @@
+import { buildFileUrl } from './buildFileUrl';
+
+describe('buildFileUrl', () => {
+ describe('default base URL', () => {
+ it('builds correct URL with default base', () => {
+ expect(buildFileUrl('site_settings', 'ss1', 'cv_2024.pdf')).toBe(
+ 'http://127.0.0.1:8090/api/files/site_settings/ss1/cv_2024.pdf',
+ );
+ });
+ });
+
+ describe('custom base URL', () => {
+ it('uses provided baseUrl when given', () => {
+ expect(buildFileUrl('photos', 'rec1', 'avatar.png', 'https://pb.example.com')).toBe(
+ 'https://pb.example.com/api/files/photos/rec1/avatar.png',
+ );
+ });
+ });
+
+ describe('different collections, records, filenames', () => {
+ it('handles projects collection', () => {
+ expect(buildFileUrl('projects', 'proj42', 'screenshot.jpg', 'http://127.0.0.1:8090')).toBe(
+ 'http://127.0.0.1:8090/api/files/projects/proj42/screenshot.jpg',
+ );
+ });
+
+ it('handles contacts collection', () => {
+ expect(buildFileUrl('contacts', 'cid99', 'photo.webp', 'http://127.0.0.1:8090')).toBe(
+ 'http://127.0.0.1:8090/api/files/contacts/cid99/photo.webp',
+ );
+ });
+ });
+});
diff --git a/src/shared/lib/utils/buildFileUrl/buildFileUrl.ts b/src/shared/lib/utils/buildFileUrl/buildFileUrl.ts
new file mode 100644
index 0000000..9ad792a
--- /dev/null
+++ b/src/shared/lib/utils/buildFileUrl/buildFileUrl.ts
@@ -0,0 +1,11 @@
+/**
+ * Builds a URL for a file stored in a PocketBase record.
+ */
+export function buildFileUrl(
+ collectionId: string,
+ recordId: string,
+ filename: string,
+ baseUrl: string = process.env.NEXT_PUBLIC_PB_URL ?? 'http://127.0.0.1:8090',
+): string {
+ return `${baseUrl}/api/files/${collectionId}/${recordId}/${filename}`;
+}
diff --git a/src/shared/lib/cn.test.ts b/src/shared/lib/utils/cn/cn.test.ts
similarity index 56%
rename from src/shared/lib/cn.test.ts
rename to src/shared/lib/utils/cn/cn.test.ts
index 1615ec5..7b13bbf 100644
--- a/src/shared/lib/cn.test.ts
+++ b/src/shared/lib/utils/cn/cn.test.ts
@@ -1,40 +1,39 @@
-import { describe, it, expect } from 'vitest'
-import { cn } from './cn'
+import { cn } from './cn';
describe('cn', () => {
describe('basic merging', () => {
it('returns single class unchanged', () => {
- expect(cn('foo')).toBe('foo')
- })
+ expect(cn('foo')).toBe('foo');
+ });
it('joins multiple classes', () => {
- expect(cn('foo', 'bar')).toBe('foo bar')
- })
- })
+ expect(cn('foo', 'bar')).toBe('foo bar');
+ });
+ });
describe('conditional classes', () => {
it('includes truthy conditional', () => {
- expect(cn('foo', true && 'bar')).toBe('foo bar')
- })
+ expect(cn('foo', true && 'bar')).toBe('foo bar');
+ });
it('excludes falsy conditional', () => {
- expect(cn('foo', false && 'bar')).toBe('foo')
- })
- })
+ expect(cn('foo', false && 'bar')).toBe('foo');
+ });
+ });
describe('object syntax', () => {
it('includes classes with truthy object values', () => {
- expect(cn({ foo: true, bar: false })).toBe('foo')
- })
- })
+ expect(cn({ foo: true, bar: false })).toBe('foo');
+ });
+ });
describe('tailwind conflict resolution', () => {
it('last padding wins', () => {
- expect(cn('px-2', 'px-4')).toBe('px-4')
- })
+ expect(cn('px-2', 'px-4')).toBe('px-4');
+ });
it('last text color wins', () => {
- expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500')
- })
- })
-})
+ expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500');
+ });
+ });
+});
diff --git a/src/shared/lib/cn.ts b/src/shared/lib/utils/cn/cn.ts
similarity index 54%
rename from src/shared/lib/cn.ts
rename to src/shared/lib/utils/cn/cn.ts
index 269848b..99c92fd 100644
--- a/src/shared/lib/cn.ts
+++ b/src/shared/lib/utils/cn/cn.ts
@@ -1,9 +1,9 @@
-import { clsx, type ClassValue } from 'clsx'
-import { twMerge } from 'tailwind-merge'
+import { type ClassValue, clsx } from 'clsx';
+import { twMerge } from 'tailwind-merge';
/**
* Merges Tailwind classes, resolving conflicts in favor of the last value.
*/
export function cn(...inputs: ClassValue[]): string {
- return twMerge(clsx(inputs))
+ return twMerge(clsx(inputs));
}
diff --git a/src/shared/lib/utils/formatDate/formatDate.test.ts b/src/shared/lib/utils/formatDate/formatDate.test.ts
new file mode 100644
index 0000000..e5dbdfe
--- /dev/null
+++ b/src/shared/lib/utils/formatDate/formatDate.test.ts
@@ -0,0 +1,47 @@
+import { formatMonthYearRange } from './formatDate';
+
+describe('formatMonthYearRange', () => {
+ describe('open-ended range', () => {
+ it('formats start date with Present when end is null', () => {
+ expect(formatMonthYearRange('2022-01-01T00:00:00Z', null)).toBe('Jan 2022 — Present');
+ });
+
+ it('uses abbreviated month name', () => {
+ expect(formatMonthYearRange('2020-08-15T00:00:00Z', null)).toBe('Aug 2020 — Present');
+ });
+ });
+
+ describe('closed range', () => {
+ it('formats start and end with month and year', () => {
+ expect(formatMonthYearRange('2021-05-01T00:00:00Z', '2024-03-31T00:00:00Z')).toBe('May 2021 — Mar 2024');
+ });
+
+ it('handles same year with different months', () => {
+ expect(formatMonthYearRange('2024-01-01T00:00:00Z', '2024-12-31T00:00:00Z')).toBe('Jan 2024 — Dec 2024');
+ });
+
+ it('handles same month and year', () => {
+ expect(formatMonthYearRange('2024-06-01T00:00:00Z', '2024-06-30T00:00:00Z')).toBe('Jun 2024');
+ });
+ });
+
+ describe('error cases', () => {
+ it('throws if start date is invalid', () => {
+ expect(() => formatMonthYearRange('not-a-date', null)).toThrow('Invalid start date');
+ });
+
+ it('throws if end date is provided but invalid', () => {
+ expect(() => formatMonthYearRange('2024-01-01T00:00:00Z', 'invalid')).toThrow('Invalid end date');
+ });
+
+ it('throws if start is after end', () => {
+ expect(() => formatMonthYearRange('2024-01-01T00:00:00Z', '2020-01-01T00:00:00Z')).toThrow(
+ 'Start date cannot be after end date',
+ );
+ });
+
+ it('throws on empty string', () => {
+ expect(() => formatMonthYearRange('', null)).toThrow('Invalid start date');
+ });
+ });
+});
diff --git a/src/shared/lib/utils/formatDate/formatDate.ts b/src/shared/lib/utils/formatDate/formatDate.ts
new file mode 100644
index 0000000..a8b353c
--- /dev/null
+++ b/src/shared/lib/utils/formatDate/formatDate.ts
@@ -0,0 +1,38 @@
+const MONTH_FMT = new Intl.DateTimeFormat('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' });
+
+function formatMonthYear(date: Date): string {
+ return MONTH_FMT.format(date);
+}
+
+/**
+ * Formats a PocketBase date string into a localized month+year range or "Present".
+ * @throws {Error} if any date is invalid or if the range is logically impossible.
+ */
+export function formatMonthYearRange(start: string, end: string | null): string {
+ const startDate = new Date(start);
+ if (Number.isNaN(startDate.getTime())) {
+ throw new Error('Invalid start date');
+ }
+
+ if (end === null) {
+ return `${formatMonthYear(startDate)} — Present`;
+ }
+
+ const endDate = new Date(end);
+ if (Number.isNaN(endDate.getTime())) {
+ throw new Error('Invalid end date');
+ }
+
+ if (startDate > endDate) {
+ throw new Error('Start date cannot be after end date');
+ }
+
+ const startLabel = formatMonthYear(startDate);
+ const endLabel = formatMonthYear(endDate);
+
+ if (startLabel === endLabel) {
+ return startLabel;
+ }
+
+ return `${startLabel} — ${endLabel}`;
+}
diff --git a/src/shared/lib/utils/groupByKey/groupByKey.test.ts b/src/shared/lib/utils/groupByKey/groupByKey.test.ts
new file mode 100644
index 0000000..f7bfb14
--- /dev/null
+++ b/src/shared/lib/utils/groupByKey/groupByKey.test.ts
@@ -0,0 +1,54 @@
+import { groupByKey } from './groupByKey';
+
+describe('groupByKey', () => {
+ describe('basic grouping', () => {
+ it('groups items by a string key', () => {
+ const items = [
+ { category: 'Frontend', name: 'React' },
+ { category: 'Backend', name: 'Node' },
+ { category: 'Frontend', name: 'Vue' },
+ ];
+ expect(groupByKey(items, 'category')).toEqual({
+ Frontend: [
+ { category: 'Frontend', name: 'React' },
+ { category: 'Frontend', name: 'Vue' },
+ ],
+ Backend: [{ category: 'Backend', name: 'Node' }],
+ });
+ });
+
+ it('preserves insertion order within each group', () => {
+ const items = [
+ { category: 'A', order: 1 },
+ { category: 'A', order: 2 },
+ ];
+ expect(groupByKey(items, 'category').A).toEqual([
+ { category: 'A', order: 1 },
+ { category: 'A', order: 2 },
+ ]);
+ });
+ });
+
+ describe('edge cases', () => {
+ it('returns empty object for empty array', () => {
+ expect(groupByKey<{ category: string }>([], 'category')).toEqual({});
+ });
+
+ it('handles all items in same group', () => {
+ const items = [
+ { type: 'X', id: 1 },
+ { type: 'X', id: 2 },
+ ];
+ const result = groupByKey(items, 'type');
+ expect(Object.keys(result)).toHaveLength(1);
+ expect(result.X).toHaveLength(2);
+ });
+
+ it('handles single item', () => {
+ const items = [{ category: 'Only', name: 'One' }];
+ expect(groupByKey(items, 'category')).toEqual({
+ Only: [{ category: 'Only', name: 'One' }],
+ });
+ });
+ });
+});
diff --git a/src/shared/lib/utils/groupByKey/groupByKey.ts b/src/shared/lib/utils/groupByKey/groupByKey.ts
new file mode 100644
index 0000000..1919981
--- /dev/null
+++ b/src/shared/lib/utils/groupByKey/groupByKey.ts
@@ -0,0 +1,19 @@
+/**
+ * Groups an array of objects by a shared key into a record of arrays.
+ * @param items - Array of objects to group
+ * @param key - Key whose value determines the group
+ * @returns Record mapping each unique key value to an array of matching items
+ */
+export function groupByKey(items: T[], key: keyof T): Record {
+ return items.reduce(
+ (acc, item) => {
+ const k = String(item[key]);
+ if (!acc[k]) {
+ acc[k] = [];
+ }
+ acc[k].push(item);
+ return acc;
+ },
+ {} as Record,
+ );
+}
diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css
index c78dcda..b4a6007 100644
--- a/src/shared/styles/theme.css
+++ b/src/shared/styles/theme.css
@@ -2,7 +2,7 @@
/* === TYPOGRAPHY SCALE (Augmented Fourth 1.414) === */
--font-size: 16px;
--text-xs: 0.707rem;
- --text-sm: 0.840rem;
+ --text-sm: 0.84rem;
--text-base: 1rem;
--text-lg: 1.414rem;
--text-xl: 2rem;
@@ -20,36 +20,38 @@
--font-weight-body: 600;
--font-weight-normal: 400;
+ /* Fluid section title: scales from 2rem at ~267px to 8rem at ~1707px */
+ --text-section-title: clamp(2rem, 7.5vw, 8rem);
+
/* === LINE HEIGHT === */
--line-height-tight: 1.2;
--line-height-normal: 1.5;
+ --line-height-relaxed: 1.65;
/* === FRAUNCES VARIABLE AXES === */
--fraunces-wonk: 1;
--fraunces-soft: 0;
- /* === COLOR PALETTE === */
- --ochre-clay: #D9B48F;
- --slate-indigo: #3B4A59;
- --burnt-oxide: #A64B35;
- --carbon-black: #121212;
+ /* === COLOR PALETTE: 2-color system === */
+ --cream: #f4f0e8;
+ --blue: #041cf3;
/* === SEMANTIC COLORS === */
- --background: var(--ochre-clay);
- --foreground: var(--carbon-black);
- --card: var(--ochre-clay);
- --card-foreground: var(--carbon-black);
- --primary: var(--burnt-oxide);
- --primary-foreground: var(--ochre-clay);
- --secondary: var(--slate-indigo);
- --secondary-foreground: var(--ochre-clay);
- --muted: var(--slate-indigo);
- --muted-foreground: var(--ochre-clay);
- --accent: var(--burnt-oxide);
- --accent-foreground: var(--ochre-clay);
- --destructive: #d4183d;
- --border: var(--carbon-black);
- --ring: var(--carbon-black);
+ --background: var(--cream);
+ --foreground: var(--blue);
+ --card: var(--cream);
+ --card-foreground: var(--blue);
+ --primary: var(--blue);
+ --primary-foreground: var(--cream);
+ --secondary: var(--cream);
+ --secondary-foreground: var(--blue);
+ --muted: var(--cream);
+ --muted-foreground: rgba(4, 28, 243, 0.5);
+ --accent: var(--blue);
+ --accent-foreground: var(--cream);
+ --destructive: var(--blue);
+ --border: var(--blue);
+ --ring: var(--blue);
/* === SPACING (8pt Linear Scale) === */
--space-0: 0;
@@ -71,19 +73,35 @@
--radius: 0px;
/* === BRUTALIST SHADOWS === */
- --shadow-brutal: 8px 8px 0 var(--carbon-black);
- --shadow-brutal-sm: 4px 4px 0 var(--carbon-black);
- --shadow-brutal-lg: 12px 12px 0 var(--carbon-black);
+ --shadow-brutal-xs: 1px 1px 0 var(--blue);
+ --shadow-brutal-sm: 3px 3px 0 var(--blue);
+ --shadow-brutal: 5px 5px 0 var(--blue);
+ --shadow-brutal-md: 7px 7px 0 var(--blue);
+ --shadow-brutal-lg: 8px 8px 0 var(--blue);
+ --shadow-brutal-xl: 10px 10px 0 var(--blue);
+ --shadow-brutal-2xl: 12px 12px 0 var(--blue);
/* === GRID === */
--grid-gap: var(--space-3);
+ --section-content-width: 72rem;
+
+ /* === ANIMATION === */
+ --ease-default: ease;
+ --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
+ --ease-decelerate: cubic-bezier(0.25, 0, 0, 1);
+ --ease-micro: cubic-bezier(0.22, 1, 0.36, 1);
+ --duration-fast: 100ms;
+ --duration-normal: 150ms;
+ --duration-slow: 350ms;
+ --duration-spring: 220ms;
}
@theme inline {
- --color-ochre-clay: var(--ochre-clay);
- --color-slate-indigo: var(--slate-indigo);
- --color-burnt-oxide: var(--burnt-oxide);
- --color-carbon-black: var(--carbon-black);
+ --font-heading: var(--font-fraunces);
+ --font-body: var(--font-public-sans);
+
+ --color-cream: var(--cream);
+ --color-blue: var(--blue);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
@@ -102,6 +120,16 @@
--radius-sm: var(--radius);
--radius-md: var(--radius);
--radius-lg: var(--radius);
+ --container-section: var(--section-content-width);
+ --text-section-title: var(--text-section-title);
+
+ --shadow-brutal-xs: var(--shadow-brutal-xs);
+ --shadow-brutal-sm: var(--shadow-brutal-sm);
+ --shadow-brutal: var(--shadow-brutal);
+ --shadow-brutal-md: var(--shadow-brutal-md);
+ --shadow-brutal-lg: var(--shadow-brutal-lg);
+ --shadow-brutal-xl: var(--shadow-brutal-xl);
+ --shadow-brutal-2xl: var(--shadow-brutal-2xl);
}
@layer base {
@@ -109,6 +137,16 @@
@apply border-border;
}
+ ::selection {
+ background-color: var(--blue);
+ color: var(--cream);
+ }
+
+ :focus-visible {
+ outline: var(--border-width) solid var(--blue);
+ outline-offset: 2px;
+ }
+
html {
font-size: var(--font-size);
}
@@ -121,45 +159,74 @@
overflow-x: hidden;
}
- /* Paper grain texture */
+ /* Subtle blue-tinted grain on parchment */
body::before {
- content: '';
+ content: "";
position: fixed;
inset: 0;
background-image:
- repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.02) 2px, rgba(0,0,0,0.02) 4px),
- repeating-linear-gradient(90deg, transparent, transparent 2px, rgba(0,0,0,0.02) 2px, rgba(0,0,0,0.02) 4px);
- opacity: 0.4;
+ repeating-linear-gradient(
+ 0deg,
+ transparent,
+ transparent 2px,
+ rgba(4, 28, 243, 0.015) 2px,
+ rgba(4, 28, 243, 0.015) 4px
+ ),
+ repeating-linear-gradient(
+ 90deg,
+ transparent,
+ transparent 2px,
+ rgba(4, 28, 243, 0.015) 2px,
+ rgba(4, 28, 243, 0.015) 4px
+ );
+ opacity: 0.6;
display: block;
pointer-events: none;
z-index: 1;
}
- h1, h2, h3, h4, h5, h6 {
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
font-family: var(--font-heading);
font-weight: var(--font-weight-heading);
line-height: var(--line-height-tight);
- font-variation-settings: 'WONK' var(--fraunces-wonk), 'SOFT' var(--fraunces-soft);
- color: var(--carbon-black);
+ font-variation-settings:
+ "WONK" var(--fraunces-wonk),
+ "SOFT" var(--fraunces-soft);
+ color: var(--blue);
}
- h1 { font-size: var(--text-4xl); }
- h2 { font-size: var(--text-3xl); }
- h3 { font-size: var(--text-2xl); }
- h4 { font-size: var(--text-xl); }
- h5 { font-size: var(--text-lg); }
+ h1 {
+ font-size: var(--text-4xl);
+ }
+ h2 {
+ font-size: var(--text-3xl);
+ }
+ h3 {
+ font-size: var(--text-2xl);
+ }
+ h4 {
+ font-size: var(--text-xl);
+ }
+ h5 {
+ font-size: var(--text-lg);
+ }
p {
font-family: var(--font-body);
font-size: var(--text-base);
font-weight: var(--font-weight-body);
- color: var(--carbon-black);
+ color: var(--blue);
}
a {
- color: var(--burnt-oxide);
+ color: var(--blue);
text-decoration: none;
- border-bottom: 2px solid var(--carbon-black);
+ border-bottom: 2px solid var(--blue);
transition: all 0.2s;
}
@@ -170,25 +237,132 @@
blockquote {
font-family: var(--font-heading);
font-size: var(--text-xl);
- border-left: var(--border-width) solid var(--carbon-black);
+ border-left: var(--border-width) solid var(--blue);
padding-left: var(--space-4);
margin: var(--space-6) 0;
}
}
-/* Brutalist utility classes */
-.brutal-shadow { box-shadow: var(--shadow-brutal); }
-.brutal-shadow-sm { box-shadow: var(--shadow-brutal-sm); }
-.brutal-shadow-lg { box-shadow: var(--shadow-brutal-lg); }
-.brutal-border { border: var(--border-width) solid var(--carbon-black); }
-.brutal-border-top { border-top: var(--border-width) solid var(--carbon-black); }
-.brutal-border-bottom { border-bottom: var(--border-width) solid var(--carbon-black); }
-.brutal-border-left { border-left: var(--border-width) solid var(--carbon-black); }
-.brutal-border-right { border-right: var(--border-width) solid var(--carbon-black); }
-
-/* Animations */
-@keyframes fadeIn {
- from { opacity: 0; transform: translateY(10px); }
- to { opacity: 1; transform: translateY(0); }
+/* Button elevation transition — only transform animates; shadow snaps instantly */
+.btn-transition {
+ transition: transform 0.13s var(--ease-micro);
+}
+
+/* Brutalist utility classes */
+.brutal-shadow {
+ box-shadow: var(--shadow-brutal);
+}
+.brutal-shadow-sm {
+ box-shadow: var(--shadow-brutal-sm);
+}
+.brutal-shadow-lg {
+ box-shadow: var(--shadow-brutal-lg);
+}
+.brutal-border {
+ border: var(--border-width) solid var(--blue);
+}
+.brutal-border-top {
+ border-top: var(--border-width) solid var(--blue);
+}
+.brutal-border-bottom {
+ border-bottom: var(--border-width) solid var(--blue);
+}
+.brutal-border-left {
+ border-left: var(--border-width) solid var(--blue);
+}
+.brutal-border-right {
+ border-right: var(--border-width) solid var(--blue);
+}
+/* Apply Fraunces variable axes to non-heading elements using the heading font */
+.font-wonk {
+ font-variation-settings:
+ "WONK" var(--fraunces-wonk),
+ "SOFT" var(--fraunces-soft);
+}
+
+/* Sidebar divider: bottom border on mobile, right border on desktop */
+.brutal-border-sidebar {
+ border-bottom: var(--border-width) solid var(--blue);
+}
+@media (min-width: 1024px) {
+ .brutal-border-sidebar {
+ border-bottom: none;
+ border-right: var(--border-width) solid var(--blue);
+ }
+}
+
+/* Editorial rich-text typography */
+.rich-text {
+ max-width: 65ch;
+ line-height: var(--line-height-relaxed);
+ font-feature-settings: "onum";
+ hanging-punctuation: first last;
+ text-wrap: pretty;
+}
+
+.rich-text p + p {
+ margin-top: 1.2em;
+}
+
+.rich-text ul {
+ list-style: none;
+ padding-left: 0;
+ margin: 1em 0;
+}
+
+.rich-text ul li {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 0.65em;
+ align-items: start;
+ margin-top: 0.5em;
+}
+
+.rich-text ul li:first-child {
+ margin-top: 0;
+}
+
+.rich-text ul li::before {
+ content: "◆";
+ color: var(--blue);
+ font-size: 0.55em;
+ /* line-height matches parent so diamond centers within the first line box */
+ line-height: calc(var(--line-height-relaxed) / 0.55);
+}
+
+/* Cross-section view transition (navigation between sections) */
+::view-transition-old(section-content) {
+ animation-name: section-fade-out;
+ animation-duration: var(--duration-normal);
+ animation-timing-function: var(--ease-default);
+ animation-fill-mode: both;
+}
+
+::view-transition-new(section-content) {
+ animation-name: section-fade-in;
+ animation-duration: var(--duration-spring);
+ animation-timing-function: var(--ease-spring);
+ animation-fill-mode: both;
+}
+
+@keyframes section-fade-out {
+ from {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ to {
+ opacity: 0;
+ transform: translateY(-8px);
+ }
+}
+
+@keyframes section-fade-in {
+ from {
+ opacity: 0;
+ transform: translateY(12px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
}
-.animate-fadeIn { animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1); }
diff --git a/src/shared/ui/Badge/index.ts b/src/shared/ui/Badge/index.ts
index 5c05677..ed8eed9 100644
--- a/src/shared/ui/Badge/index.ts
+++ b/src/shared/ui/Badge/index.ts
@@ -1,2 +1,2 @@
-export { Badge } from './ui/Badge'
-export type { BadgeVariant } from './ui/Badge'
+export type { BadgeSize, BadgeVariant } from './ui/Badge';
+export { Badge } from './ui/Badge';
diff --git a/src/shared/ui/Badge/ui/Badge.stories.tsx b/src/shared/ui/Badge/ui/Badge.stories.tsx
index e3150bc..392926e 100644
--- a/src/shared/ui/Badge/ui/Badge.stories.tsx
+++ b/src/shared/ui/Badge/ui/Badge.stories.tsx
@@ -1,14 +1,14 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite'
-import { Badge } from './Badge'
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { Badge } from './Badge';
const meta: Meta = {
title: 'Shared/Badge',
component: Badge,
-}
+};
-export default meta
+export default meta;
-type Story = StoryObj
+type Story = StoryObj;
export const AllVariants: Story = {
render: () => (
@@ -19,4 +19,4 @@ export const AllVariants: Story = {
Outline
),
-}
+};
diff --git a/src/shared/ui/Badge/ui/Badge.test.tsx b/src/shared/ui/Badge/ui/Badge.test.tsx
index 67f130c..6830574 100644
--- a/src/shared/ui/Badge/ui/Badge.test.tsx
+++ b/src/shared/ui/Badge/ui/Badge.test.tsx
@@ -1,52 +1,73 @@
-import { describe, it, expect } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import { Badge } from './Badge'
+import { render, screen } from '@testing-library/react';
+import { Badge } from './Badge';
describe('Badge', () => {
describe('rendering', () => {
it('renders children', () => {
- render(React)
- expect(screen.getByText('React')).toBeInTheDocument()
- })
+ render(React);
+ expect(screen.getByText('React')).toBeInTheDocument();
+ });
it('renders as inline span', () => {
- render(Tag)
- expect(screen.getByText('Tag').tagName).toBe('SPAN')
- })
- })
+ render(Tag);
+ expect(screen.getByText('Tag').tagName).toBe('SPAN');
+ });
+ });
describe('variants', () => {
it('applies default variant classes', () => {
- render(Tag)
- const el = screen.getByText('Tag')
- expect(el).toHaveClass('bg-carbon-black', 'text-ochre-clay')
- })
+ render(Tag);
+ const el = screen.getByText('Tag');
+ expect(el).toHaveClass('bg-blue', 'text-cream');
+ });
it('applies primary variant classes', () => {
- render(Tag)
- expect(screen.getByText('Tag')).toHaveClass('bg-burnt-oxide')
- })
+ render(Tag);
+ expect(screen.getByText('Tag')).toHaveClass('bg-blue');
+ });
it('applies secondary variant classes', () => {
- render(Tag)
- expect(screen.getByText('Tag')).toHaveClass('bg-slate-indigo')
- })
+ render(Tag);
+ expect(screen.getByText('Tag')).toHaveClass('bg-blue');
+ });
it('applies outline variant classes', () => {
- render(Tag)
- expect(screen.getByText('Tag')).toHaveClass('bg-transparent')
- })
+ render(Tag);
+ expect(screen.getByText('Tag')).toHaveClass('bg-transparent');
+ });
it('defaults to default variant when unspecified', () => {
- render(Tag)
- expect(screen.getByText('Tag')).toHaveClass('bg-carbon-black')
- })
- })
+ render(Tag);
+ expect(screen.getByText('Tag')).toHaveClass('bg-blue');
+ });
+ });
+
+ describe('sizes', () => {
+ it('defaults to sm size', () => {
+ render(Tag);
+ expect(screen.getByText('Tag')).toHaveClass('px-3', 'py-1', 'text-xs');
+ });
+
+ it('applies xs size classes', () => {
+ render(Tag);
+ expect(screen.getByText('Tag')).toHaveClass('px-2', 'py-0.5');
+ });
+
+ it('applies sm size classes', () => {
+ render(Tag);
+ expect(screen.getByText('Tag')).toHaveClass('px-3', 'py-1', 'text-xs');
+ });
+
+ it('applies md size classes', () => {
+ render(Tag);
+ expect(screen.getByText('Tag')).toHaveClass('px-4', 'py-2', 'text-sm');
+ });
+ });
describe('className passthrough', () => {
it('merges custom className', () => {
- render(Tag)
- expect(screen.getByText('Tag')).toHaveClass('mt-4')
- })
- })
-})
+ render(Tag);
+ expect(screen.getByText('Tag')).toHaveClass('mt-4');
+ });
+ });
+});
diff --git a/src/shared/ui/Badge/ui/Badge.tsx b/src/shared/ui/Badge/ui/Badge.tsx
index 4f5bbd5..9150fd5 100644
--- a/src/shared/ui/Badge/ui/Badge.tsx
+++ b/src/shared/ui/Badge/ui/Badge.tsx
@@ -1,38 +1,50 @@
-import type { ReactNode } from 'react'
-import { cn } from '$shared/lib'
+import type { ReactNode } from 'react';
+import { cn } from '$shared/lib';
-export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'outline'
+export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'outline';
+export type BadgeSize = 'xs' | 'sm' | 'md';
interface Props {
/**
* Badge content
*/
- children: ReactNode
+ children: ReactNode;
/**
* Visual variant
* @default 'default'
*/
- variant?: BadgeVariant
+ variant?: BadgeVariant;
+ /**
+ * Size preset
+ * @default 'sm'
+ */
+ size?: BadgeSize;
/**
* Additional CSS classes
*/
- className?: string
+ className?: string;
}
const VARIANTS: Record = {
- default: 'brutal-border bg-carbon-black text-ochre-clay',
- primary: 'brutal-border bg-burnt-oxide text-ochre-clay',
- secondary: 'brutal-border bg-slate-indigo text-ochre-clay',
- outline: 'brutal-border bg-transparent text-carbon-black',
-}
+ default: 'brutal-border bg-blue text-cream',
+ primary: 'brutal-border bg-blue text-cream',
+ secondary: 'brutal-border bg-blue text-cream',
+ outline: 'brutal-border bg-transparent text-blue',
+};
+
+const SIZES: Record = {
+ xs: 'px-2 py-0.5 text-[10px]',
+ sm: 'px-3 py-1 text-xs',
+ md: 'px-4 py-2 text-sm',
+};
/**
* Small label for categorization or status.
*/
-export function Badge({ children, variant = 'default', className }: Props) {
+export function Badge({ children, variant = 'default', size = 'sm', className }: Props) {
return (
-
+
{children}
- )
+ );
}
diff --git a/src/shared/ui/Button/index.ts b/src/shared/ui/Button/index.ts
index ee20067..24cb05f 100644
--- a/src/shared/ui/Button/index.ts
+++ b/src/shared/ui/Button/index.ts
@@ -1,2 +1,2 @@
-export { Button } from './ui/Button'
-export type { ButtonVariant, ButtonSize } from './ui/Button'
+export type { ButtonSize, ButtonVariant } from './ui/Button';
+export { Button } from './ui/Button';
diff --git a/src/shared/ui/Button/ui/Button.stories.tsx b/src/shared/ui/Button/ui/Button.stories.tsx
index 702c81c..26225f2 100644
--- a/src/shared/ui/Button/ui/Button.stories.tsx
+++ b/src/shared/ui/Button/ui/Button.stories.tsx
@@ -1,35 +1,49 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite'
-import { Button } from './Button'
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { Button } from './Button';
const meta: Meta = {
title: 'Shared/Button',
component: Button,
-}
+};
-export default meta
+export default meta;
-type Story = StoryObj
+type Story = StoryObj;
export const AllVariants: Story = {
render: () => (
-
-
-
-
+
+
+
+
),
-}
+};
export const Sizes: Story = {
render: () => (
-
-
-
+
+
+
),
-}
+};
export const Disabled: Story = {
args: {
@@ -44,4 +58,4 @@ export const Disabled: Story = {
),
],
-}
+};
diff --git a/src/shared/ui/Button/ui/Button.test.tsx b/src/shared/ui/Button/ui/Button.test.tsx
index 48410b8..e6535d1 100644
--- a/src/shared/ui/Button/ui/Button.test.tsx
+++ b/src/shared/ui/Button/ui/Button.test.tsx
@@ -1,67 +1,93 @@
-import { describe, it, expect, vi } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { Button } from './Button'
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Button } from './Button';
describe('Button', () => {
describe('rendering', () => {
it('renders children', () => {
- render()
- expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
+ });
it('renders as button element', () => {
- render()
- expect(screen.getByRole('button')).toBeInTheDocument()
- })
- })
+ render();
+ expect(screen.getByRole('button')).toBeInTheDocument();
+ });
+ });
describe('variants', () => {
it('applies primary variant by default', () => {
- render()
- expect(screen.getByRole('button')).toHaveClass('bg-burnt-oxide')
- })
+ render();
+ expect(screen.getByRole('button')).toHaveClass('bg-blue');
+ });
it('applies secondary variant', () => {
- render()
- expect(screen.getByRole('button')).toHaveClass('bg-slate-indigo')
- })
+ render();
+ expect(screen.getByRole('button')).toHaveClass('bg-blue');
+ });
it('applies outline variant', () => {
- render()
- expect(screen.getByRole('button')).toHaveClass('bg-transparent')
- })
+ render();
+ expect(screen.getByRole('button')).toHaveClass('bg-transparent');
+ });
it('applies ghost variant', () => {
- render()
- expect(screen.getByRole('button')).toHaveClass('bg-ochre-clay')
- })
- })
+ render();
+ expect(screen.getByRole('button')).toHaveClass('bg-cream');
+ });
+ });
describe('sizes', () => {
it('applies md size by default', () => {
- render()
- expect(screen.getByRole('button')).toHaveClass('px-6', 'py-3')
- })
+ render();
+ expect(screen.getByRole('button')).toHaveClass('px-6', 'py-3');
+ });
it('applies sm size', () => {
- render()
- expect(screen.getByRole('button')).toHaveClass('px-4', 'py-2')
- })
+ render();
+ expect(screen.getByRole('button')).toHaveClass('px-4', 'py-2');
+ });
it('applies lg size', () => {
- render()
- expect(screen.getByRole('button')).toHaveClass('px-8', 'py-4')
- })
- })
+ render();
+ expect(screen.getByRole('button')).toHaveClass('px-8', 'py-4');
+ });
+ });
describe('interactions', () => {
it('calls onClick when clicked', async () => {
- const onClick = vi.fn()
- render()
- await userEvent.click(screen.getByRole('button'))
- expect(onClick).toHaveBeenCalledOnce()
- })
+ const onClick = vi.fn();
+ render();
+ await userEvent.click(screen.getByRole('button'));
+ expect(onClick).toHaveBeenCalledOnce();
+ });
it('is disabled when disabled prop is set', () => {
- render()
- expect(screen.getByRole('button')).toBeDisabled()
- })
- })
+ render();
+ expect(screen.getByRole('button')).toBeDisabled();
+ });
+ });
describe('className passthrough', () => {
it('merges custom className', () => {
- render()
- expect(screen.getByRole('button')).toHaveClass('w-full')
- })
- })
-})
+ render();
+ expect(screen.getByRole('button')).toHaveClass('w-full');
+ });
+ });
+ describe('as anchor', () => {
+ it('renders an anchor when href is provided', () => {
+ render();
+ expect(screen.getByRole('link', { name: 'Download' })).toBeInTheDocument();
+ });
+ it('sets href on the anchor', () => {
+ render();
+ expect(screen.getByRole('link')).toHaveAttribute('href', '/cv.pdf');
+ });
+ it('sets download attribute when provided', () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole('link')).toHaveAttribute('download');
+ });
+ it('applies the same variant and size classes as button', () => {
+ render(
+ ,
+ );
+ const link = screen.getByRole('link');
+ expect(link).toHaveClass('bg-blue', 'px-4', 'py-2');
+ });
+ });
+});
diff --git a/src/shared/ui/Button/ui/Button.tsx b/src/shared/ui/Button/ui/Button.tsx
index c1f256c..bd0c39b 100644
--- a/src/shared/ui/Button/ui/Button.tsx
+++ b/src/shared/ui/Button/ui/Button.tsx
@@ -1,48 +1,85 @@
-import type { ButtonHTMLAttributes, ReactNode } from 'react'
-import { cn } from '$shared/lib'
+import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react';
+import { cn } from '$shared/lib';
-export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost'
-export type ButtonSize = 'sm' | 'md' | 'lg'
+export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
+export type ButtonSize = 'sm' | 'md' | 'lg';
-interface Props extends ButtonHTMLAttributes {
+type BaseProps = {
/**
* Visual variant
* @default 'primary'
*/
- variant?: ButtonVariant
+ variant?: ButtonVariant;
/**
* Size preset
* @default 'md'
*/
- size?: ButtonSize
+ size?: ButtonSize;
/**
* Button content
*/
- children: ReactNode
+ children: ReactNode;
+ /**
+ * CSS classes
+ */
+ className?: string;
+};
+
+type AsButton = BaseProps & ButtonHTMLAttributes & { href?: never };
+type AsAnchor = BaseProps & AnchorHTMLAttributes & { href: string };
+
+type Props = AsButton | AsAnchor;
+
+type RestButton = Omit;
+type RestAnchor = Omit;
+
+/**
+ * Narrows spread props to anchor shape when href is a non-undefined string.
+ */
+function isAnchorProps(props: RestButton | RestAnchor): props is RestAnchor {
+ return typeof props.href === 'string';
}
-const VARIANTS: Record = {
- primary: 'bg-burnt-oxide text-ochre-clay',
- secondary: 'bg-slate-indigo text-ochre-clay',
- outline: 'bg-transparent text-carbon-black border-carbon-black',
- ghost: 'bg-ochre-clay text-carbon-black border-carbon-black',
-}
+const VARIANTS = {
+ primary:
+ 'brutal-border bg-blue text-cream shadow-[5px_5px_0_rgba(4,28,243,0.35)] hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-[7px_7px_0_rgba(4,28,243,0.35)] active:translate-x-0.5 active:translate-y-0.5 active:shadow-[1px_1px_0_rgba(4,28,243,0.35)]',
+ secondary:
+ 'brutal-border bg-blue text-cream shadow-brutal hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-brutal-md active:translate-x-0.5 active:translate-y-0.5 active:shadow-brutal-xs',
+ outline:
+ 'brutal-border bg-transparent text-blue shadow-brutal hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-brutal-md active:translate-x-0.5 active:translate-y-0.5 active:shadow-brutal-xs',
+ ghost:
+ 'brutal-border border-blue/35 bg-cream text-blue hover:border-blue hover:bg-blue/10 active:bg-blue active:text-cream',
+} as const satisfies Record;
-const SIZES: Record = {
+const SIZES = {
sm: 'px-4 py-2 text-sm',
md: 'px-6 py-3 text-base',
lg: 'px-8 py-4 text-lg',
-}
+} as const satisfies Record;
-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'
+/* box-shadow excluded from transition intentionally — snaps instantly so the
+ * eye follows the 130ms button movement, not the shadow change. */
+const BASE = 'cursor-pointer btn-transition uppercase tracking-wider';
/**
* Brutalist button with variants and sizes.
+ * Renders as when href is provided, }>Main);
+ expect(screen.getByText('Sidebar')).toBeInTheDocument();
+ });
+
+ it('renders main content', () => {
+ render(Sidebar}>Main);
+ expect(screen.getByText('Main')).toBeInTheDocument();
+ });
+ });
+
+ describe('structure', () => {
+ it('root wrapper is a flex container', () => {
+ const { container } = render(S}>M);
+ expect(container.firstChild).toHaveClass('flex');
+ });
+
+ it('sidebar column has brutal-border-sidebar class', () => {
+ render(Sidebar}>Main);
+ const sidebar = screen.getByText('Sidebar').parentElement;
+ expect(sidebar).toHaveClass('brutal-border-sidebar');
+ });
+
+ it('sidebar column has fixed width on lg', () => {
+ render(Sidebar}>Main);
+ const sidebar = screen.getByText('Sidebar').parentElement;
+ expect(sidebar).toHaveClass('lg:w-64');
+ });
+
+ it('main column fills remaining space', () => {
+ render(Sidebar}>Main);
+ expect(screen.getByText('Main')).toHaveClass('flex-1');
+ });
+ });
+
+ describe('className passthrough', () => {
+ it('forwards className to the root wrapper', () => {
+ const { container } = render(
+ S} className="custom">
+ M
+ ,
+ );
+ expect(container.firstChild).toHaveClass('custom');
+ });
+ });
+});
diff --git a/src/shared/ui/Card/ui/Card.tsx b/src/shared/ui/Card/ui/Card.tsx
index cadf0d1..8e73a82 100644
--- a/src/shared/ui/Card/ui/Card.tsx
+++ b/src/shared/ui/Card/ui/Card.tsx
@@ -1,88 +1,116 @@
-import type { ReactNode } from 'react'
-import { cn } from '$shared/lib'
+import type { ReactNode } from 'react';
+import { cn } from '$shared/lib';
-export type CardBackground = 'ochre' | 'slate' | 'white'
+export type CardBackground = 'cream' | 'blue';
interface CardProps {
/**
* Card content
*/
- children: ReactNode
+ children: ReactNode;
/**
* Additional CSS classes
*/
- className?: string
+ className?: string;
/**
* Background color preset
- * @default 'ochre'
+ * @default 'cream'
*/
- background?: CardBackground
+ background?: CardBackground;
/**
* Remove default padding
* @default false
*/
- noPadding?: boolean
+ noPadding?: boolean;
}
const BG: Record = {
- ochre: 'bg-ochre-clay',
- slate: 'bg-slate-indigo text-ochre-clay',
- white: 'bg-white',
-}
+ cream: 'bg-cream',
+ blue: 'bg-blue text-cream',
+};
/**
* Brutalist card container with background and padding variants.
*/
-export function Card({ children, className, background = 'ochre', noPadding = false }: CardProps) {
+export function Card({ children, className, background = 'cream', noPadding = false }: CardProps) {
return (
{children}
- )
+ );
}
interface SlotProps {
/**
* Slot content
*/
- children: ReactNode
+ children: ReactNode;
/**
* Additional CSS classes
*/
- className?: string
+ className?: string;
}
/**
* Card header wrapper — adds bottom margin.
*/
export function CardHeader({ children, className }: SlotProps) {
- return {children}
+ return {children}
;
}
/**
* Card title — renders as h3.
*/
export function CardTitle({ children, className }: SlotProps) {
- return {children}
+ return {children}
;
}
/**
* Card description — muted paragraph below the title.
*/
export function CardDescription({ children, className }: SlotProps) {
- return {children}
+ return {children}
;
}
/**
* Card body content area.
*/
export function CardContent({ children, className }: SlotProps) {
- return {children}
+ return {children}
;
}
/**
* Card footer — separated by a brutal border-top.
*/
export function CardFooter({ children, className }: SlotProps) {
- return {children}
+ return {children}
;
+}
+
+interface CardSidebarProps {
+ /**
+ * Left sidebar content — metadata such as period, company, stack
+ */
+ sidebar: ReactNode;
+ /**
+ * Main content — primary info such as role title and description
+ */
+ children: ReactNode;
+ /**
+ * Additional CSS classes for the root wrapper
+ */
+ className?: string;
+}
+
+/**
+ * Two-column card layout: narrow sidebar on the left, main content on the right.
+ * On mobile the columns stack vertically with a bottom border separator;
+ * on md+ they sit side-by-side with a right border separator.
+ */
+export function CardSidebar({ sidebar, children, className }: CardSidebarProps) {
+ return (
+
+
{sidebar}
+
{children}
+
+ );
}
diff --git a/src/shared/ui/Input/index.ts b/src/shared/ui/Input/index.ts
index a538399..12065cf 100644
--- a/src/shared/ui/Input/index.ts
+++ b/src/shared/ui/Input/index.ts
@@ -1 +1 @@
-export { Input, Textarea } from './ui/Input'
+export { Input, Textarea } from './ui/Input';
diff --git a/src/shared/ui/Input/ui/Input.stories.tsx b/src/shared/ui/Input/ui/Input.stories.tsx
index e89be1b..4700cde 100644
--- a/src/shared/ui/Input/ui/Input.stories.tsx
+++ b/src/shared/ui/Input/ui/Input.stories.tsx
@@ -1,5 +1,5 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite'
-import { Input, Textarea } from './Input'
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { Input, Textarea } from './Input';
const meta: Meta = {
title: 'Shared/Input',
@@ -11,35 +11,35 @@ const meta: Meta = {
),
],
-}
+};
-export default meta
+export default meta;
-type Story = StoryObj
+type Story = StoryObj;
export const Default: Story = {
args: {},
-}
+};
export const WithLabel: Story = {
args: {
label: 'Email address',
},
-}
+};
export const WithError: Story = {
args: {
label: 'Email',
error: 'This field is required',
},
-}
+};
export const WithPlaceholder: Story = {
args: {
placeholder: 'Enter your email',
type: 'email',
},
-}
+};
export const TextareaStory: Story = {
name: 'Textarea',
@@ -48,7 +48,7 @@ export const TextareaStory: Story = {
),
-}
+};
export const TextareaWithError: Story = {
render: () => (
@@ -56,4 +56,4 @@ export const TextareaWithError: Story = {
),
-}
+};
diff --git a/src/shared/ui/Input/ui/Input.test.tsx b/src/shared/ui/Input/ui/Input.test.tsx
index 2f8bb8e..407bf4b 100644
--- a/src/shared/ui/Input/ui/Input.test.tsx
+++ b/src/shared/ui/Input/ui/Input.test.tsx
@@ -1,110 +1,109 @@
-import { describe, it, expect } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import { Input, Textarea } from './Input'
+import { render, screen } from '@testing-library/react';
+import { Input, Textarea } from './Input';
describe('Input', () => {
describe('rendering', () => {
it('renders an input element', () => {
- render()
- expect(screen.getByRole('textbox')).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
+ });
it('renders label when provided', () => {
- render()
- expect(screen.getByText('Email')).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByText('Email')).toBeInTheDocument();
+ });
it('does not render label when omitted', () => {
- const { container } = render()
- expect(container.querySelector('label')).toBeNull()
- })
+ const { container } = render();
+ expect(container.querySelector('label')).toBeNull();
+ });
it('renders error message when provided', () => {
- render()
- expect(screen.getByText('Required')).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByText('Required')).toBeInTheDocument();
+ });
it('does not render error when omitted', () => {
- render()
- expect(screen.queryByText('Required')).toBeNull()
- })
- })
+ render();
+ expect(screen.queryByText('Required')).toBeNull();
+ });
+ });
describe('accessibility', () => {
it('label is associated with input via htmlFor/id', () => {
- render()
- expect(screen.getByLabelText('Email')).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByLabelText('Email')).toBeInTheDocument();
+ });
it('error span is referenced by aria-describedby', () => {
- render()
- const input = screen.getByRole('textbox')
- const errorId = input.getAttribute('aria-describedby')
- expect(errorId).toBeTruthy()
- expect(document.getElementById(errorId!)).toHaveTextContent('Required')
- })
+ render();
+ const input = screen.getByRole('textbox');
+ const errorId = input.getAttribute('aria-describedby');
+ expect(errorId).toBeTruthy();
+ expect(document.getElementById(errorId as string)).toHaveTextContent('Required');
+ });
it('no aria-describedby when no error', () => {
- render()
- expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby')
- })
+ render();
+ expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby');
+ });
it('uses provided id prop', () => {
- render()
- expect(screen.getByLabelText('Email')).toHaveAttribute('id', 'my-input')
- })
- })
+ render();
+ expect(screen.getByLabelText('Email')).toHaveAttribute('id', 'my-input');
+ });
+ });
describe('styling', () => {
it('has brutal-border class', () => {
- render()
- expect(screen.getByRole('textbox')).toHaveClass('brutal-border')
- })
+ render();
+ expect(screen.getByRole('textbox')).toHaveClass('brutal-border');
+ });
it('applies custom className', () => {
- render()
- expect(screen.getByRole('textbox')).toHaveClass('w-full')
- })
- })
+ render();
+ expect(screen.getByRole('textbox')).toHaveClass('w-full');
+ });
+ });
describe('forwarded props', () => {
it('passes placeholder to input', () => {
- render()
- expect(screen.getByPlaceholderText('Enter email')).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByPlaceholderText('Enter email')).toBeInTheDocument();
+ });
it('passes type to input', () => {
- render()
- expect(screen.getByRole('textbox')).toHaveAttribute('type', 'email')
- })
- })
-})
+ render();
+ expect(screen.getByRole('textbox')).toHaveAttribute('type', 'email');
+ });
+ });
+});
describe('Textarea', () => {
describe('rendering', () => {
it('renders a textarea element', () => {
- render()
- expect(screen.getByRole('textbox')).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
+ });
it('renders label when provided', () => {
- render()
- expect(screen.getByText('Message')).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByText('Message')).toBeInTheDocument();
+ });
it('renders error when provided', () => {
- render()
- expect(screen.getByText('Too short')).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByText('Too short')).toBeInTheDocument();
+ });
it('defaults to 4 rows', () => {
- render()
- expect(screen.getByRole('textbox')).toHaveAttribute('rows', '4')
- })
+ render();
+ expect(screen.getByRole('textbox')).toHaveAttribute('rows', '4');
+ });
it('accepts custom rows', () => {
- render()
- expect(screen.getByRole('textbox')).toHaveAttribute('rows', '8')
- })
- })
+ render();
+ expect(screen.getByRole('textbox')).toHaveAttribute('rows', '8');
+ });
+ });
describe('accessibility', () => {
it('label is associated with textarea via htmlFor/id', () => {
- render()
- expect(screen.getByLabelText('Message')).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByLabelText('Message')).toBeInTheDocument();
+ });
it('error span is referenced by aria-describedby', () => {
- render()
- const textarea = screen.getByRole('textbox')
- const errorId = textarea.getAttribute('aria-describedby')
- expect(errorId).toBeTruthy()
- expect(document.getElementById(errorId!)).toHaveTextContent('Too short')
- })
+ render();
+ const textarea = screen.getByRole('textbox');
+ const errorId = textarea.getAttribute('aria-describedby');
+ expect(errorId).toBeTruthy();
+ expect(document.getElementById(errorId as string)).toHaveTextContent('Too short');
+ });
it('no aria-describedby when no error', () => {
- render()
- expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby')
- })
- })
-})
+ render();
+ expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby');
+ });
+ });
+});
diff --git a/src/shared/ui/Input/ui/Input.tsx b/src/shared/ui/Input/ui/Input.tsx
index cba7023..714ceba 100644
--- a/src/shared/ui/Input/ui/Input.tsx
+++ b/src/shared/ui/Input/ui/Input.tsx
@@ -1,68 +1,81 @@
-import { useId, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react'
-import { cn } from '$shared/lib'
+import { type InputHTMLAttributes, type TextareaHTMLAttributes, useId } from 'react';
+import { cn } from '$shared/lib';
interface InputProps extends InputHTMLAttributes {
/**
* Visible label rendered above the input
*/
- label?: string
+ label?: string;
/**
* Validation error shown below the input
*/
- error?: string
+ error?: string;
}
-const INPUT_BASE = 'brutal-border bg-white px-4 py-3 text-carbon-black focus:outline-none focus:ring-2 focus:ring-burnt-oxide focus:ring-offset-2 focus:ring-offset-ochre-clay transition-all'
+const INPUT_BASE =
+ 'brutal-border bg-cream px-4 py-3 text-blue focus:outline-none focus:ring-2 focus:ring-blue focus:ring-offset-2 focus:ring-offset-cream transition-all';
/**
* Text input with optional label and error state.
*/
export function Input({ label, error, className, id, ...props }: InputProps) {
- const generatedId = useId()
- const inputId = id ?? generatedId
- const errorId = `${inputId}-error`
+ const generatedId = useId();
+ const inputId = id ?? generatedId;
+ const errorId = `${inputId}-error`;
return (
- {label && }
+ {label && (
+
+ )}
- {error && {error}}
+ {error && (
+
+ {error}
+
+ )}
- )
+ );
}
interface TextareaProps extends TextareaHTMLAttributes {
/**
* Visible label rendered above the textarea
*/
- label?: string
+ label?: string;
/**
* Validation error shown below the textarea
*/
- error?: string
+ error?: string;
/**
* Number of visible rows
* @default 4
*/
- rows?: number
+ rows?: number;
}
/**
* Multiline textarea with optional label and error state.
*/
export function Textarea({ label, error, rows = 4, className, id, ...props }: TextareaProps) {
- const generatedId = useId()
- const textareaId = id ?? generatedId
- const errorId = `${textareaId}-error`
+ const generatedId = useId();
+ const textareaId = id ?? generatedId;
+ const errorId = `${textareaId}-error`;
return (
- {label && }
+ {label && (
+
+ )}
- {error && {error}}
+ {error && (
+
+ {error}
+
+ )}
- )
+ );
}
diff --git a/src/shared/ui/Link/index.ts b/src/shared/ui/Link/index.ts
new file mode 100644
index 0000000..a2e27cb
--- /dev/null
+++ b/src/shared/ui/Link/index.ts
@@ -0,0 +1 @@
+export { Link } from './ui/Link/Link';
diff --git a/src/shared/ui/Link/ui/Link/Link.stories.tsx b/src/shared/ui/Link/ui/Link/Link.stories.tsx
new file mode 100644
index 0000000..db957c1
--- /dev/null
+++ b/src/shared/ui/Link/ui/Link/Link.stories.tsx
@@ -0,0 +1,40 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { Link } from './Link';
+
+const meta: Meta = {
+ title: 'Shared/Link',
+ component: Link,
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Internal: Story = {
+ args: {
+ href: '/about',
+ children: 'Internal page',
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export const External: Story = {
+ args: {
+ href: 'https://example.com',
+ external: true,
+ children: 'External site',
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
diff --git a/src/shared/ui/Link/ui/Link/Link.test.tsx b/src/shared/ui/Link/ui/Link/Link.test.tsx
new file mode 100644
index 0000000..d122e04
--- /dev/null
+++ b/src/shared/ui/Link/ui/Link/Link.test.tsx
@@ -0,0 +1,82 @@
+vi.mock('next/link', () => ({
+ default: ({ href, children, className }: { href: string; children: React.ReactNode; className?: string }) => (
+
+ {children}
+
+ ),
+}));
+
+import { render, screen } from '@testing-library/react';
+import type React from 'react';
+import { Link } from './Link';
+
+const BASE = 'underline underline-offset-2 hover:opacity-60 transition-opacity';
+
+describe('internal link', () => {
+ it('renders an anchor element', () => {
+ render(About);
+ expect(screen.getByRole('link', { name: 'About' })).toBeInTheDocument();
+ });
+
+ it('has correct href', () => {
+ render(About);
+ expect(screen.getByRole('link', { name: 'About' })).toHaveAttribute('href', '/about');
+ });
+
+ it('does not have target attribute', () => {
+ render(About);
+ expect(screen.getByRole('link', { name: 'About' })).not.toHaveAttribute('target');
+ });
+
+ it('applies base classes', () => {
+ render(About);
+ const link = screen.getByRole('link', { name: 'About' });
+ for (const cls of BASE.split(' ')) {
+ expect(link).toHaveClass(cls);
+ }
+ });
+});
+
+describe('external link', () => {
+ it('has target="_blank"', () => {
+ render(
+
+ External
+ ,
+ );
+ expect(screen.getByRole('link', { name: 'External' })).toHaveAttribute('target', '_blank');
+ });
+
+ it('has rel="noopener noreferrer"', () => {
+ render(
+
+ External
+ ,
+ );
+ expect(screen.getByRole('link', { name: 'External' })).toHaveAttribute('rel', 'noopener noreferrer');
+ });
+
+ it('has correct href', () => {
+ render(
+
+ External
+ ,
+ );
+ expect(screen.getByRole('link', { name: 'External' })).toHaveAttribute('href', 'https://example.com');
+ });
+});
+
+describe('className passthrough', () => {
+ it('merges custom className with base classes', () => {
+ render(
+
+ Styled
+ ,
+ );
+ const link = screen.getByRole('link', { name: 'Styled' });
+ expect(link).toHaveClass('text-red-500');
+ for (const cls of BASE.split(' ')) {
+ expect(link).toHaveClass(cls);
+ }
+ });
+});
diff --git a/src/shared/ui/Link/ui/Link/Link.tsx b/src/shared/ui/Link/ui/Link/Link.tsx
new file mode 100644
index 0000000..f76a8cb
--- /dev/null
+++ b/src/shared/ui/Link/ui/Link/Link.tsx
@@ -0,0 +1,47 @@
+import NextLink from 'next/link';
+import type { ReactNode } from 'react';
+import { cn } from '$shared/lib';
+
+/**
+ * Props for Link.
+ */
+interface Props {
+ /**
+ * Destination URL. Use a path (e.g. /about) for internal routes, or a full URL for external.
+ */
+ href: string;
+ /**
+ * Link content
+ */
+ children: ReactNode;
+ /**
+ * CSS classes
+ */
+ className?: string;
+ /**
+ * When true, renders a plain with target="_blank" rel="noopener noreferrer".
+ * Use for links that open outside the app.
+ */
+ external?: boolean;
+}
+
+const BASE = 'underline underline-offset-2 hover:opacity-60 transition-opacity';
+
+/**
+ * Inline text link.
+ * Renders as Next.js Link for internal routes, plain for external links.
+ */
+export function Link({ href, children, className, external }: Props) {
+ if (external) {
+ return (
+
+ {children}
+
+ );
+ }
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/shared/ui/RichText/index.ts b/src/shared/ui/RichText/index.ts
new file mode 100644
index 0000000..66c5463
--- /dev/null
+++ b/src/shared/ui/RichText/index.ts
@@ -0,0 +1 @@
+export { RichText } from './ui/RichText';
diff --git a/src/shared/ui/RichText/ui/RichText.test.tsx b/src/shared/ui/RichText/ui/RichText.test.tsx
new file mode 100644
index 0000000..de92582
--- /dev/null
+++ b/src/shared/ui/RichText/ui/RichText.test.tsx
@@ -0,0 +1,45 @@
+import { render, screen } from '@testing-library/react';
+import { RichText } from './RichText';
+
+describe('RichText', () => {
+ describe('rendering', () => {
+ it('renders a paragraph from tag', () => {
+ render();
+ expect(screen.getByText('Hello world').tagName).toBe('P');
+ });
+
+ it('renders bold text from tag', () => {
+ render();
+ expect(screen.getByText('Bold').tagName).toBe('STRONG');
+ });
+
+ it('renders a link from tag', () => {
+ render();
+ const link = screen.getByRole('link', { name: 'Link' });
+ expect(link).toHaveAttribute('href', 'https://example.com');
+ });
+
+ it('renders nested tags', () => {
+ render();
+ expect(screen.getByText('emphasis').tagName).toBe('EM');
+ });
+
+ it('renders nothing for empty string', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders multiple sibling elements', () => {
+ render();
+ expect(screen.getByText('First')).toBeInTheDocument();
+ expect(screen.getByText('Second')).toBeInTheDocument();
+ });
+ });
+
+ describe('className passthrough', () => {
+ it('applies className to the wrapper', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('prose');
+ });
+ });
+});
diff --git a/src/shared/ui/RichText/ui/RichText.tsx b/src/shared/ui/RichText/ui/RichText.tsx
new file mode 100644
index 0000000..36b88fb
--- /dev/null
+++ b/src/shared/ui/RichText/ui/RichText.tsx
@@ -0,0 +1,25 @@
+import parse from 'html-react-parser';
+import { cn } from '$shared/lib';
+
+type Props = {
+ /**
+ * HTML string from PocketBase rich-text editor
+ */
+ html: string;
+ /**
+ * Additional CSS classes merged onto the wrapper div
+ */
+ className?: string;
+};
+
+/**
+ * Renders a PocketBase rich-text HTML string as React elements.
+ * Always applies editorial magazine typography via the rich-text CSS class.
+ */
+export function RichText({ html, className }: Props) {
+ if (!html) {
+ return null;
+ }
+
+ return {parse(html)}
;
+}
diff --git a/src/shared/ui/Section/index.ts b/src/shared/ui/Section/index.ts
index 8f6b80e..caa2678 100644
--- a/src/shared/ui/Section/index.ts
+++ b/src/shared/ui/Section/index.ts
@@ -1,2 +1,2 @@
-export { Section, Container } from './ui/Section'
-export type { SectionBackground, ContainerSize } from './ui/Section'
+export type { ContainerSize, SectionBackground } from './ui/Section';
+export { Container, Section } from './ui/Section';
diff --git a/src/shared/ui/Section/ui/Section.stories.tsx b/src/shared/ui/Section/ui/Section.stories.tsx
index 8fdf423..7601dad 100644
--- a/src/shared/ui/Section/ui/Section.stories.tsx
+++ b/src/shared/ui/Section/ui/Section.stories.tsx
@@ -1,14 +1,14 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite'
-import { Section, Container } from './Section'
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { Container, Section } from './Section';
const meta: Meta = {
title: 'Shared/Section',
component: Section,
-}
+};
-export default meta
+export default meta;
-type Story = StoryObj
+type Story = StoryObj;
export const AllBackgrounds: Story = {
render: () => (
@@ -16,32 +16,44 @@ export const AllBackgrounds: Story = {
Ochre Section
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
+ dolore magna aliqua.
+
Slate Section
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
+ dolore magna aliqua.
+
White Section
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
+ dolore magna aliqua.
+
),
-}
+};
export const Bordered: Story = {
render: () => (
Bordered Section
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore
+ magna aliqua.
+
),
-}
+};
diff --git a/src/shared/ui/Section/ui/Section.test.tsx b/src/shared/ui/Section/ui/Section.test.tsx
index 849cafe..e9857c2 100644
--- a/src/shared/ui/Section/ui/Section.test.tsx
+++ b/src/shared/ui/Section/ui/Section.test.tsx
@@ -1,95 +1,98 @@
-import { describe, it, expect } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import { Section, Container } from './Section'
+import { render, screen } from '@testing-library/react';
+import { Container, Section } from './Section';
describe('Section', () => {
describe('rendering', () => {
it('renders a section element', () => {
- const { container } = render()
- expect(container.querySelector('section')).toBeInTheDocument()
- })
+ const { container } = render();
+ expect(container.querySelector('section')).toBeInTheDocument();
+ });
it('renders children', () => {
- render()
- expect(screen.getByText('hello')).toBeInTheDocument()
- })
- })
+ render(
+ ,
+ );
+ expect(screen.getByText('hello')).toBeInTheDocument();
+ });
+ });
describe('background variants', () => {
- it('defaults to ochre background', () => {
- const { container } = render()
- expect(container.querySelector('section')).toHaveClass('bg-ochre-clay', 'text-carbon-black')
- })
- it('applies slate background', () => {
- const { container } = render()
- expect(container.querySelector('section')).toHaveClass('bg-slate-indigo', 'text-ochre-clay')
- })
- it('applies white background', () => {
- const { container } = render()
- expect(container.querySelector('section')).toHaveClass('bg-white', 'text-carbon-black')
- })
- })
+ it('defaults to cream background', () => {
+ const { container } = render();
+ expect(container.querySelector('section')).toHaveClass('bg-cream', 'text-blue');
+ });
+ it('applies blue background', () => {
+ const { container } = render();
+ expect(container.querySelector('section')).toHaveClass('bg-blue', 'text-cream');
+ });
+ });
describe('bordered', () => {
it('no border classes by default', () => {
- const { container } = render()
- const el = container.querySelector('section')!
- expect(el).not.toHaveClass('brutal-border-top')
- expect(el).not.toHaveClass('brutal-border-bottom')
- })
+ const { container } = render();
+ const el = container.querySelector('section') as HTMLElement;
+ expect(el).not.toHaveClass('brutal-border-top');
+ expect(el).not.toHaveClass('brutal-border-bottom');
+ });
it('adds top and bottom borders when bordered=true', () => {
- const { container } = render()
- const el = container.querySelector('section')!
- expect(el).toHaveClass('brutal-border-top')
- expect(el).toHaveClass('brutal-border-bottom')
- })
- })
+ const { container } = render();
+ const el = container.querySelector('section') as HTMLElement;
+ expect(el).toHaveClass('brutal-border-top');
+ expect(el).toHaveClass('brutal-border-bottom');
+ });
+ });
describe('className', () => {
it('applies custom className', () => {
- const { container } = render()
- expect(container.querySelector('section')).toHaveClass('py-16')
- })
- })
-})
+ const { container } = render();
+ expect(container.querySelector('section')).toHaveClass('py-16');
+ });
+ });
+});
describe('Container', () => {
describe('rendering', () => {
it('renders a div with children', () => {
- render(inner)
- expect(screen.getByText('inner')).toBeInTheDocument()
- })
- })
+ render(
+
+ inner
+ ,
+ );
+ expect(screen.getByText('inner')).toBeInTheDocument();
+ });
+ });
describe('size variants', () => {
it('defaults to max-w-7xl', () => {
- const { container } = render(x)
- expect(container.firstChild).toHaveClass('max-w-7xl')
- })
+ const { container } = render(x);
+ expect(container.firstChild).toHaveClass('max-w-7xl');
+ });
it('wide applies max-w-[1920px]', () => {
- const { container } = render(x)
- expect(container.firstChild).toHaveClass('max-w-[1920px]')
- })
+ const { container } = render(x);
+ expect(container.firstChild).toHaveClass('max-w-[1920px]');
+ });
it('ultra-wide applies max-w-[2560px]', () => {
- const { container } = render(x)
- expect(container.firstChild).toHaveClass('max-w-[2560px]')
- })
- })
+ const { container } = render(x);
+ expect(container.firstChild).toHaveClass('max-w-[2560px]');
+ });
+ });
describe('layout', () => {
it('centers content horizontally', () => {
- const { container } = render(x)
- expect(container.firstChild).toHaveClass('mx-auto')
- })
+ const { container } = render(x);
+ expect(container.firstChild).toHaveClass('mx-auto');
+ });
it('applies horizontal padding', () => {
- const { container } = render(x)
- expect(container.firstChild).toHaveClass('px-6')
- })
- })
+ const { container } = render(x);
+ expect(container.firstChild).toHaveClass('px-6');
+ });
+ });
describe('className', () => {
it('applies custom className', () => {
- const { container } = render(x)
- expect(container.firstChild).toHaveClass('my-custom')
- })
- })
-})
+ const { container } = render(x);
+ expect(container.firstChild).toHaveClass('my-custom');
+ });
+ });
+});
diff --git a/src/shared/ui/Section/ui/Section.tsx b/src/shared/ui/Section/ui/Section.tsx
index ea2ffbb..257c8b5 100644
--- a/src/shared/ui/Section/ui/Section.tsx
+++ b/src/shared/ui/Section/ui/Section.tsx
@@ -1,82 +1,71 @@
-import type { ReactNode } from 'react'
-import { cn } from '$shared/lib'
+import type { ReactNode } from 'react';
+import { cn } from '$shared/lib';
-export type SectionBackground = 'ochre' | 'slate' | 'white'
-export type ContainerSize = 'default' | 'wide' | 'ultra-wide'
+export type SectionBackground = 'cream' | 'blue';
+export type ContainerSize = 'default' | 'wide' | 'ultra-wide';
interface SectionProps {
/**
* Section content
*/
- children: ReactNode
+ children: ReactNode;
/**
* Background color variant
- * @default 'ochre'
+ * @default 'cream'
*/
- background?: SectionBackground
+ background?: SectionBackground;
/**
* Adds top and bottom brutal borders
* @default false
*/
- bordered?: boolean
+ bordered?: boolean;
/**
* CSS classes
*/
- className?: string
+ className?: string;
}
const BACKGROUNDS: Record = {
- ochre: 'bg-ochre-clay text-carbon-black',
- slate: 'bg-slate-indigo text-ochre-clay',
- white: 'bg-white text-carbon-black',
-}
+ cream: 'bg-cream text-blue',
+ blue: 'bg-blue text-cream',
+};
/**
* Full-width page section with background and optional borders.
*/
-export function Section({ children, background = 'ochre', bordered = false, className }: SectionProps) {
+export function Section({ children, background = 'cream', bordered = false, className }: SectionProps) {
return (
-
+
- )
+ );
}
interface ContainerProps {
/**
* Container content
*/
- children: ReactNode
+ children: ReactNode;
/**
* Max-width constraint
* @default 'default'
*/
- size?: ContainerSize
+ size?: ContainerSize;
/**
* CSS classes
*/
- className?: string
+ className?: string;
}
const SIZES: Record = {
- 'default': 'max-w-7xl',
- 'wide': 'max-w-[1920px]',
+ default: 'max-w-7xl',
+ wide: 'max-w-[1920px]',
'ultra-wide': 'max-w-[2560px]',
-}
+};
/**
* Centered content container with responsive horizontal padding.
*/
export function Container({ children, size = 'default', className }: ContainerProps) {
- return (
-
- {children}
-
- )
+ return {children}
;
}
diff --git a/src/shared/ui/SectionAccordion/index.ts b/src/shared/ui/SectionAccordion/index.ts
deleted file mode 100644
index 5af53e2..0000000
--- a/src/shared/ui/SectionAccordion/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { SectionAccordion } from './ui/SectionAccordion'
diff --git a/src/shared/ui/SectionAccordion/ui/SectionAccordion.test.tsx b/src/shared/ui/SectionAccordion/ui/SectionAccordion.test.tsx
deleted file mode 100644
index e68d60b..0000000
--- a/src/shared/ui/SectionAccordion/ui/SectionAccordion.test.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { describe, it, expect, vi } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { SectionAccordion } from './SectionAccordion'
-
-const defaultProps = {
- number: '01',
- title: 'About',
- id: 'about',
- isActive: false,
- onClick: vi.fn(),
- children: Content here
,
-}
-
-describe('SectionAccordion', () => {
- describe('collapsed state (isActive=false)', () => {
- it('renders a section element with the given id', () => {
- const { container } = render()
- expect(container.querySelector('section#about')).toBeInTheDocument()
- })
- it('renders a button with number and title', () => {
- render()
- expect(screen.getByRole('button', { name: /01.*About/i })).toBeInTheDocument()
- })
- it('does not render children', () => {
- render()
- expect(screen.queryByText('Content here')).not.toBeInTheDocument()
- })
- it('calls onClick when button is clicked', async () => {
- const onClick = vi.fn()
- render()
- await userEvent.click(screen.getByRole('button'))
- expect(onClick).toHaveBeenCalledOnce()
- })
- })
-
- describe('active state (isActive=true)', () => {
- const activeProps = { ...defaultProps, isActive: true }
-
- it('renders an h1 with number and title', () => {
- render()
- expect(screen.getByRole('heading', { level: 1, name: /01.*About/i })).toBeInTheDocument()
- })
- it('renders children', () => {
- render()
- expect(screen.getByText('Content here')).toBeInTheDocument()
- })
- it('does not render a button', () => {
- render()
- expect(screen.queryByRole('button')).not.toBeInTheDocument()
- })
- it('content wrapper has animate-fadeIn class', () => {
- const { container } = render()
- expect(container.querySelector('.animate-fadeIn')).toBeInTheDocument()
- })
- })
-})
diff --git a/src/shared/ui/SectionAccordion/ui/SectionAccordion.tsx b/src/shared/ui/SectionAccordion/ui/SectionAccordion.tsx
deleted file mode 100644
index 88fc0de..0000000
--- a/src/shared/ui/SectionAccordion/ui/SectionAccordion.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import type { ReactNode } from 'react'
-
-interface SectionAccordionProps {
- /**
- * Display number prefix (e.g. "01")
- */
- number: string
- /**
- * Section title
- */
- title: string
- /**
- * HTML id for anchor navigation
- */
- id: string
- /**
- * Whether this section is expanded
- */
- isActive: boolean
- /**
- * Called when the collapsed header is clicked
- */
- onClick: () => void
- /**
- * Section content, shown when active
- */
- children: ReactNode
-}
-
-/**
- * Accordion-style section that collapses to a heading button when inactive.
- */
-export function SectionAccordion({ number, title, id, isActive, onClick, children }: SectionAccordionProps) {
- return (
-
- {isActive ? (
-
-
-
- {number}. {title}
-
-
-
- {children}
-
-
- ) : (
-
-
- {number}. {title}
-
-
- )}
-
- )
-}
diff --git a/src/shared/ui/TechStack/index.ts b/src/shared/ui/TechStack/index.ts
index 7b8d6da..57f6946 100644
--- a/src/shared/ui/TechStack/index.ts
+++ b/src/shared/ui/TechStack/index.ts
@@ -1 +1 @@
-export { TechStackBrick, TechStackGrid } from './ui/TechStack'
+export { TechStackBrick, TechStackGrid } from './ui/TechStack';
diff --git a/src/shared/ui/TechStack/ui/TechStack.stories.tsx b/src/shared/ui/TechStack/ui/TechStack.stories.tsx
index ee2fc6c..777e0dd 100644
--- a/src/shared/ui/TechStack/ui/TechStack.stories.tsx
+++ b/src/shared/ui/TechStack/ui/TechStack.stories.tsx
@@ -1,5 +1,5 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite'
-import { TechStackGrid, TechStackBrick } from './TechStack'
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { TechStackBrick, TechStackGrid } from './TechStack';
const meta: Meta = {
title: 'Shared/TechStack',
@@ -11,11 +11,11 @@ const meta: Meta = {
),
],
-}
+};
-export default meta
+export default meta;
-type Story = StoryObj
+type Story = StoryObj;
export const Grid: Story = {
args: {
@@ -34,7 +34,7 @@ export const Grid: Story = {
'Rust',
],
},
-}
+};
export const SingleBrick: Story = {
render: () => (
@@ -42,4 +42,4 @@ export const SingleBrick: Story = {
),
-}
+};
diff --git a/src/shared/ui/TechStack/ui/TechStack.test.tsx b/src/shared/ui/TechStack/ui/TechStack.test.tsx
index 21cd240..40e472f 100644
--- a/src/shared/ui/TechStack/ui/TechStack.test.tsx
+++ b/src/shared/ui/TechStack/ui/TechStack.test.tsx
@@ -1,62 +1,61 @@
-import { describe, it, expect } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import { TechStackBrick, TechStackGrid } from './TechStack'
+import { render, screen } from '@testing-library/react';
+import { TechStackBrick, TechStackGrid } from './TechStack';
describe('TechStackBrick', () => {
describe('rendering', () => {
it('renders the technology name', () => {
- render()
- expect(screen.getByText('TypeScript')).toBeInTheDocument()
- })
- })
+ render();
+ expect(screen.getByText('TypeScript')).toBeInTheDocument();
+ });
+ });
describe('styling', () => {
it('has brutal-border class', () => {
- const { container } = render()
- expect(container.firstChild).toHaveClass('brutal-border')
- })
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('brutal-border');
+ });
it('has brutal-shadow class', () => {
- const { container } = render()
- expect(container.firstChild).toHaveClass('brutal-shadow')
- })
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('brutal-shadow');
+ });
it('name span has uppercase and tracking-wide', () => {
- render()
- const span = screen.getByText('Go')
- expect(span).toHaveClass('uppercase', 'tracking-wide')
- })
+ render();
+ const span = screen.getByText('Go');
+ expect(span).toHaveClass('uppercase', 'tracking-wide');
+ });
it('applies custom className', () => {
- const { container } = render()
- expect(container.firstChild).toHaveClass('w-full')
- })
- })
-})
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('w-full');
+ });
+ });
+});
describe('TechStackGrid', () => {
describe('rendering', () => {
it('renders all skill names', () => {
- render()
- expect(screen.getByText('React')).toBeInTheDocument()
- expect(screen.getByText('TypeScript')).toBeInTheDocument()
- expect(screen.getByText('Go')).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByText('React')).toBeInTheDocument();
+ expect(screen.getByText('TypeScript')).toBeInTheDocument();
+ expect(screen.getByText('Go')).toBeInTheDocument();
+ });
it('renders correct number of bricks', () => {
- const { container } = render()
- expect(container.firstChild!.childNodes).toHaveLength(3)
- })
+ const { container } = render();
+ expect(container.firstChild?.childNodes).toHaveLength(3);
+ });
it('renders empty grid with no skills', () => {
- const { container } = render()
- expect(container.firstChild!.childNodes).toHaveLength(0)
- })
- })
+ const { container } = render();
+ expect(container.firstChild?.childNodes).toHaveLength(0);
+ });
+ });
describe('layout', () => {
it('has grid class', () => {
- const { container } = render()
- expect(container.firstChild).toHaveClass('grid')
- })
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('grid');
+ });
it('applies custom className', () => {
- const { container } = render()
- expect(container.firstChild).toHaveClass('my-custom')
- })
- })
-})
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('my-custom');
+ });
+ });
+});
diff --git a/src/shared/ui/TechStack/ui/TechStack.tsx b/src/shared/ui/TechStack/ui/TechStack.tsx
index f7fe5de..ea5de63 100644
--- a/src/shared/ui/TechStack/ui/TechStack.tsx
+++ b/src/shared/ui/TechStack/ui/TechStack.tsx
@@ -1,14 +1,14 @@
-import { cn } from '$shared/lib'
+import { cn } from '$shared/lib';
interface TechStackBrickProps {
/**
* Technology name displayed in the brick
*/
- name: string
+ name: string;
/**
* CSS classes
*/
- className?: string
+ className?: string;
}
/**
@@ -18,25 +18,25 @@ export function TechStackBrick({ name, className }: TechStackBrickProps) {
return (
{name}
- )
+ );
}
interface TechStackGridProps {
/**
* List of technology names to render as bricks
*/
- skills: string[]
+ skills: string[];
/**
* CSS classes
*/
- className?: string
+ className?: string;
}
/**
@@ -45,14 +45,11 @@ interface TechStackGridProps {
export function TechStackGrid({ skills, className }: TechStackGridProps) {
return (
- {skills.map((skill, index) => (
-
+ {skills.map((skill) => (
+
))}
- )
+ );
}
diff --git a/src/shared/ui/ViewTransitionWrapper/index.ts b/src/shared/ui/ViewTransitionWrapper/index.ts
new file mode 100644
index 0000000..548b11f
--- /dev/null
+++ b/src/shared/ui/ViewTransitionWrapper/index.ts
@@ -0,0 +1 @@
+export { ViewTransitionWrapper } from './ui/ViewTransitionWrapper';
diff --git a/src/shared/ui/ViewTransitionWrapper/ui/ViewTransitionWrapper.test.tsx b/src/shared/ui/ViewTransitionWrapper/ui/ViewTransitionWrapper.test.tsx
new file mode 100644
index 0000000..b34b133
--- /dev/null
+++ b/src/shared/ui/ViewTransitionWrapper/ui/ViewTransitionWrapper.test.tsx
@@ -0,0 +1,33 @@
+import { render, screen } from '@testing-library/react';
+import { ViewTransitionWrapper } from './ViewTransitionWrapper';
+
+describe('ViewTransitionWrapper', () => {
+ it('renders children', () => {
+ render(
+
+ Hello
+ ,
+ );
+ expect(screen.getByText('Hello')).toBeInTheDocument();
+ });
+
+ it('renders multiple children', () => {
+ render(
+
+ First
+ Second
+ ,
+ );
+ expect(screen.getByText('First')).toBeInTheDocument();
+ expect(screen.getByText('Second')).toBeInTheDocument();
+ });
+
+ it('does not add an extra wrapper DOM element', () => {
+ const { container } = render(
+
+ Content
+ ,
+ );
+ expect(container.firstChild?.nodeName).toBe('P');
+ });
+});
diff --git a/src/shared/ui/ViewTransitionWrapper/ui/ViewTransitionWrapper.tsx b/src/shared/ui/ViewTransitionWrapper/ui/ViewTransitionWrapper.tsx
new file mode 100644
index 0000000..3aad3d4
--- /dev/null
+++ b/src/shared/ui/ViewTransitionWrapper/ui/ViewTransitionWrapper.tsx
@@ -0,0 +1,26 @@
+import { Fragment, type ReactNode, ViewTransition as VT } from 'react';
+
+/**
+ * VT is undefined in stable react-dom (test env / non-experimental builds).
+ * Fall back to Fragment so children render and the name prop is silently ignored.
+ */
+const Transition = (VT ?? Fragment) as typeof VT;
+
+type Props = {
+ /**
+ * Maps to the view-transition-name CSS property
+ */
+ name: string;
+ /**
+ * Content to animate
+ */
+ children: ReactNode;
+};
+
+/**
+ * Wraps children in React's ViewTransition when available,
+ * falling back to a Fragment in environments where ViewTransition is undefined.
+ */
+export function ViewTransitionWrapper({ name, children }: Props) {
+ return {children};
+}
diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts
index 45e7d09..69a21bf 100644
--- a/src/shared/ui/index.ts
+++ b/src/shared/ui/index.ts
@@ -1,17 +1,14 @@
-export { Badge } from './Badge'
-export type { BadgeVariant } from './Badge'
+export type { BadgeSize, BadgeVariant } from './Badge';
+export { Badge } from './Badge';
+export type { ButtonSize, ButtonVariant } from './Button';
+export { Button } from './Button';
+export type { CardBackground } from './Card';
+export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './Card';
-export { Button } from './Button'
-export type { ButtonVariant, ButtonSize } from './Button'
-
-export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'
-export type { CardBackground } from './Card'
-
-export { Input, Textarea } from './Input'
-
-export { Section, Container } from './Section'
-export type { SectionBackground, ContainerSize } from './Section'
-
-export { SectionAccordion } from './SectionAccordion'
-
-export { TechStackBrick, TechStackGrid } from './TechStack'
+export { Input, Textarea } from './Input';
+export { Link } from './Link';
+export { RichText } from './RichText';
+export type { ContainerSize, SectionBackground } from './Section';
+export { Container, Section } from './Section';
+export { TechStackBrick, TechStackGrid } from './TechStack';
+export { ViewTransitionWrapper } from './ViewTransitionWrapper';
diff --git a/src/test/__mocks__/next-font-google.ts b/src/test/__mocks__/next-font-google.ts
new file mode 100644
index 0000000..9c99157
--- /dev/null
+++ b/src/test/__mocks__/next-font-google.ts
@@ -0,0 +1,12 @@
+import { vi } from 'vitest';
+
+const mockFont = () => ({
+ variable: '--font-mock',
+ className: 'mock-font',
+ style: { fontFamily: 'mock-font' },
+});
+
+vi.mock('next/font/google', () => ({
+ Fraunces: mockFont,
+ Public_Sans: mockFont,
+}));
diff --git a/src/test/setup.ts b/src/test/setup.ts
index c44951a..b166ee9 100644
--- a/src/test/setup.ts
+++ b/src/test/setup.ts
@@ -1 +1,2 @@
-import '@testing-library/jest-dom'
+import '@testing-library/jest-dom';
+import './__mocks__/next-font-google';
diff --git a/src/widgets/BioSection/ui/BioSection/BioSection.tsx b/src/widgets/BioSection/ui/BioSection/BioSection.tsx
new file mode 100644
index 0000000..1093cd2
--- /dev/null
+++ b/src/widgets/BioSection/ui/BioSection/BioSection.tsx
@@ -0,0 +1,18 @@
+import { notFound } from 'next/navigation';
+import type { PageContentRecord } from '$shared/api';
+import { getFirstRecord } from '$shared/api';
+import { RichText } from '$shared/ui';
+
+/**
+ * Bio section component.
+ * Displays personal biography content from PocketBase.
+ */
+export default async function BioSection() {
+ const data = await getFirstRecord('bio', { tags: ['bio'] });
+
+ if (!data) {
+ notFound();
+ }
+
+ return ;
+}
diff --git a/src/widgets/ExperienceSection/index.ts b/src/widgets/ExperienceSection/index.ts
new file mode 100644
index 0000000..78cef7e
--- /dev/null
+++ b/src/widgets/ExperienceSection/index.ts
@@ -0,0 +1 @@
+export { default as ExperienceSection } from './ui/ExperienceSection/ExperienceSection';
diff --git a/src/widgets/ExperienceSection/ui/ExperienceSection/ExperienceSection.test.tsx b/src/widgets/ExperienceSection/ui/ExperienceSection/ExperienceSection.test.tsx
new file mode 100644
index 0000000..8a4c524
--- /dev/null
+++ b/src/widgets/ExperienceSection/ui/ExperienceSection/ExperienceSection.test.tsx
@@ -0,0 +1,89 @@
+vi.mock('$shared/api', () => ({
+ getCollection: vi.fn(),
+}));
+
+import { render, screen } from '@testing-library/react';
+import { getCollection } from '$shared/api';
+import ExperienceSection from './ExperienceSection';
+
+const mockItems = [
+ {
+ id: '1',
+ collectionId: 'c1',
+ collectionName: 'experience',
+ created: '',
+ updated: '',
+ company: 'Acme Corp',
+ role: 'Senior Developer',
+ start_date: '2022-01-01T00:00:00Z',
+ end_date: null,
+ description: 'Built critical systems.',
+ stack: ['React', 'TypeScript'],
+ order: 1,
+ },
+ {
+ id: '2',
+ collectionId: 'c1',
+ collectionName: 'experience',
+ created: '',
+ updated: '',
+ company: 'Beta Ltd',
+ role: 'Junior Developer',
+ start_date: '2020-01-01T00:00:00Z',
+ end_date: '2021-12-31T00:00:00Z',
+ description: 'Learned the ropes.',
+ stack: [],
+ order: 2,
+ },
+];
+
+const listResponse = (items: typeof mockItems) => ({
+ items,
+ page: 1,
+ perPage: 50,
+ totalItems: items.length,
+ totalPages: 1,
+});
+
+describe('ExperienceSection', () => {
+ beforeEach(() => {
+ vi.mocked(getCollection).mockResolvedValue(listResponse(mockItems) as never);
+ });
+
+ describe('rendering', () => {
+ it('renders a card for each experience record', async () => {
+ render(await ExperienceSection());
+ expect(screen.getByText('Senior Developer')).toBeInTheDocument();
+ expect(screen.getByText('Junior Developer')).toBeInTheDocument();
+ });
+
+ it('renders company names', async () => {
+ render(await ExperienceSection());
+ expect(screen.getByText('Acme Corp')).toBeInTheDocument();
+ expect(screen.getByText('Beta Ltd')).toBeInTheDocument();
+ });
+
+ it('formats open-ended period as "Present"', async () => {
+ render(await ExperienceSection());
+ expect(screen.getByText('Jan 2022 — Present')).toBeInTheDocument();
+ });
+
+ it('formats closed period with month and year range', async () => {
+ render(await ExperienceSection());
+ expect(screen.getByText('Jan 2020 — Dec 2021')).toBeInTheDocument();
+ });
+
+ it('renders description text', async () => {
+ render(await ExperienceSection());
+ expect(screen.getByText('Built critical systems.')).toBeInTheDocument();
+ });
+ });
+
+ describe('empty state', () => {
+ it('renders empty container when no items', async () => {
+ vi.mocked(getCollection).mockResolvedValue(listResponse([]) as never);
+ const { container } = render(await ExperienceSection());
+ expect(container.firstChild).toBeEmptyDOMElement();
+ });
+ });
+});
diff --git a/src/widgets/ExperienceSection/ui/ExperienceSection/ExperienceSection.tsx b/src/widgets/ExperienceSection/ui/ExperienceSection/ExperienceSection.tsx
new file mode 100644
index 0000000..3becc5f
--- /dev/null
+++ b/src/widgets/ExperienceSection/ui/ExperienceSection/ExperienceSection.tsx
@@ -0,0 +1,30 @@
+import { ExperienceCard } from '$entities/experience';
+import type { ExperienceRecord } from '$shared/api';
+import { getCollection } from '$shared/api';
+import { formatMonthYearRange } from '$shared/lib';
+
+/**
+ * Experience section component.
+ * Lists work history entries sorted by order field.
+ */
+export default async function ExperienceSection() {
+ const { items } = await getCollection('experience', {
+ sort: 'order',
+ tags: ['experience'],
+ });
+
+ return (
+
+ {items.map((exp) => (
+
+ ))}
+
+ );
+}
diff --git a/src/widgets/Footer/index.ts b/src/widgets/Footer/index.ts
new file mode 100644
index 0000000..8094bd1
--- /dev/null
+++ b/src/widgets/Footer/index.ts
@@ -0,0 +1 @@
+export { Footer } from './ui/Footer/Footer';
diff --git a/src/widgets/Footer/ui/Footer/Footer.test.tsx b/src/widgets/Footer/ui/Footer/Footer.test.tsx
new file mode 100644
index 0000000..e31eb90
--- /dev/null
+++ b/src/widgets/Footer/ui/Footer/Footer.test.tsx
@@ -0,0 +1,147 @@
+vi.mock('$shared/api', () => ({
+ getFirstRecord: vi.fn(),
+}));
+
+import { render, screen } from '@testing-library/react';
+import { getFirstRecord } from '$shared/api';
+import { Footer } from './Footer';
+
+const mockSettings = {
+ id: 'ss1',
+ collectionId: 'site_settings',
+ collectionName: 'site_settings',
+ created: '',
+ updated: '',
+ cv: 'cv_2024.pdf',
+ contacts: 'c1',
+ expand: {
+ contacts: {
+ id: 'c1',
+ collectionId: 'contacts',
+ collectionName: 'contacts',
+ created: '',
+ updated: '',
+ email: 'hello@allmy.work',
+ socials: ['s1'],
+ expand: {
+ socials: [
+ {
+ id: 's1',
+ collectionId: 'contact',
+ collectionName: 'contact',
+ created: '',
+ updated: '',
+ label: 'GitHub',
+ url: 'https://github.com',
+ },
+ ],
+ },
+ },
+ },
+};
+
+describe('Footer', () => {
+ beforeEach(() => {
+ vi.mocked(getFirstRecord).mockResolvedValue(mockSettings);
+ });
+
+ describe('structure', () => {
+ it('renders a footer element', async () => {
+ const { container } = render(await Footer());
+ expect(container.querySelector('footer')).toBeInTheDocument();
+ });
+
+ it('has brutal-border-top separator', async () => {
+ const { container } = render(await Footer());
+ expect(container.querySelector('footer')).toHaveClass('brutal-border-top');
+ });
+ });
+
+ describe('email link', () => {
+ it('renders the contact email as a mailto link', async () => {
+ render(await Footer());
+ const link = screen.getByRole('link', { name: /hello@allmy\.work/i });
+ expect(link).toHaveAttribute('href', 'mailto:hello@allmy.work');
+ });
+
+ it('does not render email link when contacts.email is missing', async () => {
+ vi.mocked(getFirstRecord).mockResolvedValue({
+ ...mockSettings,
+ expand: {
+ contacts: {
+ ...mockSettings.expand.contacts,
+ email: '',
+ },
+ },
+ });
+ render(await Footer());
+ expect(screen.queryByRole('link', { name: /hello@allmy\.work/i })).not.toBeInTheDocument();
+ });
+ });
+
+ describe('social links', () => {
+ it('renders GitHub social link with correct href', async () => {
+ render(await Footer());
+ const link = screen.getByRole('link', { name: 'GitHub' });
+ expect(link).toHaveAttribute('href', 'https://github.com');
+ });
+
+ it('social links have target="_blank"', async () => {
+ render(await Footer());
+ const link = screen.getByRole('link', { name: 'GitHub' });
+ expect(link).toHaveAttribute('target', '_blank');
+ });
+
+ it('does not render social links when expand.socials is empty', async () => {
+ vi.mocked(getFirstRecord).mockResolvedValue({
+ ...mockSettings,
+ expand: {
+ contacts: {
+ ...mockSettings.expand.contacts,
+ expand: { socials: [] },
+ },
+ },
+ });
+ render(await Footer());
+ expect(screen.queryByRole('link', { name: 'GitHub' })).not.toBeInTheDocument();
+ });
+ });
+
+ describe('CV download', () => {
+ it('renders a CV download link when cv is available', async () => {
+ render(await Footer());
+ expect(screen.getByRole('link', { name: /download cv/i })).toBeInTheDocument();
+ });
+
+ it('CV link points to the PocketBase file URL', async () => {
+ render(await Footer());
+ expect(screen.getByRole('link', { name: /download cv/i })).toHaveAttribute(
+ 'href',
+ 'http://127.0.0.1:8090/api/files/site_settings/ss1/cv_2024.pdf',
+ );
+ });
+
+ it('CV link has download attribute', async () => {
+ render(await Footer());
+ expect(screen.getByRole('link', { name: /download cv/i })).toHaveAttribute('download');
+ });
+
+ it('CV link has button styling', async () => {
+ render(await Footer());
+ const link = screen.getByRole('link', { name: /download cv/i });
+ expect(link).toHaveClass('brutal-border', 'uppercase');
+ });
+
+ it('does not render CV link when no cv field', async () => {
+ vi.mocked(getFirstRecord).mockResolvedValue({ ...mockSettings, cv: '' });
+ render(await Footer());
+ expect(screen.queryByRole('link', { name: /download cv/i })).not.toBeInTheDocument();
+ });
+
+ it('does not render CV link when settings record is missing', async () => {
+ vi.mocked(getFirstRecord).mockResolvedValue(null);
+ render(await Footer());
+ expect(screen.queryByRole('link', { name: /download cv/i })).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/widgets/Footer/ui/Footer/Footer.tsx b/src/widgets/Footer/ui/Footer/Footer.tsx
new file mode 100644
index 0000000..b89068f
--- /dev/null
+++ b/src/widgets/Footer/ui/Footer/Footer.tsx
@@ -0,0 +1,48 @@
+import type { SiteSettingsRecord } from '$shared/api';
+import { getFirstRecord } from '$shared/api';
+import { buildFileUrl } from '$shared/lib';
+import { Button, Link } from '$shared/ui';
+
+/**
+ * Site-wide footer with contact email, social links, and CV download.
+ * All contact data is fetched from the site_settings CMS collection with nested expand.
+ */
+export async function Footer() {
+ const settings = await getFirstRecord('site_settings', {
+ expand: 'contacts,contacts.socials',
+ tags: ['site_settings'],
+ });
+
+ const cvUrl = settings?.cv ? buildFileUrl(settings.collectionId, settings.id, settings.cv) : null;
+ const contacts = settings?.expand?.contacts;
+ const socials = contacts?.expand?.socials ?? [];
+
+ return (
+
+ );
+}
diff --git a/src/widgets/IntroSection/ui/IntroSection/IntroSection.tsx b/src/widgets/IntroSection/ui/IntroSection/IntroSection.tsx
new file mode 100644
index 0000000..c627bbb
--- /dev/null
+++ b/src/widgets/IntroSection/ui/IntroSection/IntroSection.tsx
@@ -0,0 +1,18 @@
+import { notFound } from 'next/navigation';
+import type { PageContentRecord } from '$shared/api';
+import { getFirstRecord } from '$shared/api';
+import { RichText } from '$shared/ui';
+
+/**
+ * Intro section component.
+ * Displays primary introduction content from PocketBase.
+ */
+export default async function IntroSection() {
+ const data = await getFirstRecord('intro', { tags: ['intro'] });
+
+ if (!data) {
+ notFound();
+ }
+
+ return ;
+}
diff --git a/src/widgets/Navigation/index.ts b/src/widgets/Navigation/index.ts
index 2c117b2..d7cb7c5 100644
--- a/src/widgets/Navigation/index.ts
+++ b/src/widgets/Navigation/index.ts
@@ -1,4 +1,4 @@
-export { MobileNav } from './ui/MobileNav'
-export { SidebarNav } from './ui/SidebarNav'
-export { UtilityBar } from './ui/UtilityBar'
-export type { NavItem } from './model/types'
+export type { NavItem } from './model/types';
+export { MobileNav } from './ui/MobileNav';
+export { SidebarNav } from './ui/SidebarNav';
+export { UtilityBar } from './ui/UtilityBar';
diff --git a/src/widgets/Navigation/model/types.ts b/src/widgets/Navigation/model/types.ts
index e23d3a7..c8e0334 100644
--- a/src/widgets/Navigation/model/types.ts
+++ b/src/widgets/Navigation/model/types.ts
@@ -2,13 +2,13 @@ export type NavItem = {
/**
* Section HTML id for anchor scrolling
*/
- id: string
+ id: string;
/**
* Display label
*/
- label: string
+ label: string;
/**
* Display number prefix (e.g. "01")
*/
- number: string
-}
+ number: string;
+};
diff --git a/src/widgets/Navigation/ui/MobileNav.stories.tsx b/src/widgets/Navigation/ui/MobileNav.stories.tsx
index 93fa697..8b44421 100644
--- a/src/widgets/Navigation/ui/MobileNav.stories.tsx
+++ b/src/widgets/Navigation/ui/MobileNav.stories.tsx
@@ -1,5 +1,5 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite'
-import { MobileNav } from './MobileNav'
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { MobileNav } from './MobileNav';
// MobileNav is lg:hidden — it renders only on mobile viewports.
// Use the viewport toolbar in Storybook to switch to a mobile size to see it.
@@ -11,11 +11,11 @@ const meta: Meta = {
defaultViewport: 'mobile1',
},
},
-}
+};
-export default meta
+export default meta;
-type Story = StoryObj
+type Story = StoryObj;
export const Default: Story = {
args: {
@@ -25,4 +25,4 @@ export const Default: Story = {
{ id: 'contact', label: 'Contact', number: '03' },
],
},
-}
+};
diff --git a/src/widgets/Navigation/ui/MobileNav.test.tsx b/src/widgets/Navigation/ui/MobileNav.test.tsx
index 2addc9d..3d4036a 100644
--- a/src/widgets/Navigation/ui/MobileNav.test.tsx
+++ b/src/widgets/Navigation/ui/MobileNav.test.tsx
@@ -1,46 +1,62 @@
-import { describe, it, expect, vi } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import userEvent from '@testing-library/user-event'
-import { MobileNav } from './MobileNav'
-import type { NavItem } from '../model/types'
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import type { NavItem } from '../model/types';
+import { MobileNav } from './MobileNav';
-const ITEMS: NavItem[] = [{ id: 'about', label: 'About', number: '01' }]
+vi.mock('next/navigation', () => ({ usePathname: vi.fn(() => '/') }));
+
+const ITEMS: NavItem[] = [
+ { id: 'intro', label: 'Intro', number: '01' },
+ { id: 'bio', label: 'Bio', number: '02' },
+];
describe('MobileNav', () => {
describe('rendering', () => {
it('renders title "allmy.work"', () => {
- render()
- expect(screen.getByText('allmy.work')).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByText('allmy.work')).toBeInTheDocument();
+ });
it('renders toggle button with text "Menu" initially', () => {
- render()
- expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
+ });
it('menu items are hidden initially', () => {
- render()
- expect(screen.queryByRole('button', { name: /about/i })).not.toBeInTheDocument()
- })
- })
+ render();
+ expect(screen.queryByRole('link', { name: /intro/i })).not.toBeInTheDocument();
+ });
+ });
+
+ describe('navigation items', () => {
+ it('shows items as links with correct hrefs when open', async () => {
+ render();
+ await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
+ expect(screen.getByRole('link', { name: /01.*Intro/i })).toHaveAttribute('href', '/intro');
+ expect(screen.getByRole('link', { name: /02.*Bio/i })).toHaveAttribute('href', '/bio');
+ });
+ });
describe('interactions', () => {
- it('click toggle shows item buttons and changes label to "Close"', async () => {
- render()
- await userEvent.click(screen.getByRole('button', { name: 'Menu' }))
- expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
- expect(screen.getByText('About')).toBeInTheDocument()
- })
+ it('click toggle shows links and changes label to "Close"', async () => {
+ render();
+ await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
+ expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
+ expect(screen.getByText('Intro')).toBeInTheDocument();
+ });
- it('click item button closes the menu', async () => {
- render()
- await userEvent.click(screen.getByRole('button', { name: 'Menu' }))
- // item button label contains number + label text; find by accessible name fragment
- const itemBtn = screen.getAllByRole('button').find(b => b.textContent?.includes('About'))
- expect(itemBtn).toBeDefined()
- await userEvent.click(itemBtn!)
- expect(screen.queryByText('Close')).not.toBeInTheDocument()
- expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument()
- })
- })
-})
+ it('closes menu when pathname changes', async () => {
+ const { usePathname } = await import('next/navigation');
+ vi.mocked(usePathname).mockReturnValue('/');
+ const { rerender } = render();
+ await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
+ expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
+
+ vi.mocked(usePathname).mockReturnValue('/bio');
+ rerender();
+
+ expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/widgets/Navigation/ui/MobileNav.tsx b/src/widgets/Navigation/ui/MobileNav.tsx
index 0940ed8..ea4e808 100644
--- a/src/widgets/Navigation/ui/MobileNav.tsx
+++ b/src/widgets/Navigation/ui/MobileNav.tsx
@@ -1,53 +1,50 @@
-'use client'
+'use client';
-import { useState } from 'react'
-import { cn } from '$shared/lib'
-import type { NavItem } from '../model/types'
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+import { useEffect, useState } from 'react';
+import { cn } from '$shared/lib';
+import type { NavItem } from '../model/types';
+/**
+ * Props for MobileNav.
+ */
interface Props {
/**
* Navigation items to render
*/
- items: NavItem[]
+ items: NavItem[];
}
/**
* Mobile navigation overlay, hidden on lg+ screens.
+ * Closes automatically when the URL pathname changes after navigation.
*/
export function MobileNav({ items }: Props) {
- const [isOpen, setIsOpen] = useState(false)
+ const [isOpen, setIsOpen] = useState(false);
+ const pathname = usePathname();
- /**
- * Scrolls to the section by id with a 100px offset, then closes the menu.
- */
- function scrollToSection(id: string) {
- const el = document.getElementById(id)
- if (el) {
- const top = el.getBoundingClientRect().top + window.scrollY - 100
- window.scrollTo({ top, behavior: 'smooth' })
- }
- setIsOpen(false)
- }
+ // biome-ignore lint/correctness/useExhaustiveDependencies: pathname is the trigger, not a value used inside the callback
+ useEffect(() => {
+ setIsOpen(false);
+ }, [pathname]);
return (
-
+
allmy.work
setIsOpen(prev => !prev)}
- className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay"
+ type="button"
+ onClick={() => setIsOpen((prev) => !prev)}
+ className="brutal-border px-4 py-2 bg-blue text-cream"
>
{isOpen ? 'Close' : 'Menu'}
{isOpen && (
- {items.map(item => (
-
scrollToSection(item.id)}
- className="w-full text-left brutal-border bg-ochre-clay px-4 py-3"
- >
+ {items.map((item) => (
+
{item.number}
-
+
))}
)}
- )
+ );
}
diff --git a/src/widgets/Navigation/ui/SidebarNav.stories.tsx b/src/widgets/Navigation/ui/SidebarNav.stories.tsx
index f4b3917..2c98381 100644
--- a/src/widgets/Navigation/ui/SidebarNav.stories.tsx
+++ b/src/widgets/Navigation/ui/SidebarNav.stories.tsx
@@ -1,5 +1,5 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite'
-import { SidebarNav } from './SidebarNav'
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { SidebarNav } from './SidebarNav';
// SidebarNav is hidden lg:block — it renders only on desktop viewports.
// Use the viewport toolbar in Storybook to switch to a desktop size to see it.
@@ -12,11 +12,11 @@ const meta: Meta
= {
defaultViewport: 'desktop',
},
},
-}
+};
-export default meta
+export default meta;
-type Story = StoryObj
+type Story = StoryObj;
export const Default: Story = {
args: {
@@ -26,4 +26,4 @@ export const Default: Story = {
{ id: 'contact', label: 'Contact', number: '03' },
],
},
-}
+};
diff --git a/src/widgets/Navigation/ui/SidebarNav.test.tsx b/src/widgets/Navigation/ui/SidebarNav.test.tsx
index d9f9784..10d16f3 100644
--- a/src/widgets/Navigation/ui/SidebarNav.test.tsx
+++ b/src/widgets/Navigation/ui/SidebarNav.test.tsx
@@ -1,62 +1,84 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import { SidebarNav } from './SidebarNav'
-import type { NavItem } from '../model/types'
+import { render, screen } from '@testing-library/react';
+import type { NavItem } from '../model/types';
+import { SidebarNav } from './SidebarNav';
+
+vi.mock('next/navigation', () => ({
+ usePathname: vi.fn(),
+}));
+
+import { usePathname } from 'next/navigation';
const ITEMS: NavItem[] = [
{ id: 'bio', label: 'Bio', number: '01' },
{ id: 'work', label: 'Work', number: '02' },
-]
-
-beforeEach(() => {
- global.IntersectionObserver = vi.fn(function () {
- return {
- observe: vi.fn(),
- disconnect: vi.fn(),
- unobserve: vi.fn(),
- }
- }) as unknown as typeof IntersectionObserver
-})
+];
describe('SidebarNav', () => {
describe('rendering', () => {
+ beforeEach(() => {
+ vi.mocked(usePathname).mockReturnValue('/bio');
+ });
+
it('renders a nav element', () => {
- render()
- expect(screen.getByRole('navigation')).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByRole('navigation')).toBeInTheDocument();
+ });
it('renders "Index" heading', () => {
- render()
- expect(screen.getByText('Index')).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByText('Index')).toBeInTheDocument();
+ });
it('renders "Digital Monograph" subtitle', () => {
- render()
- expect(screen.getByText('Digital Monograph')).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByText('Digital Monograph')).toBeInTheDocument();
+ });
it('renders each item label and number', () => {
- render()
- expect(screen.getByText('Bio')).toBeInTheDocument()
- expect(screen.getByText('01')).toBeInTheDocument()
- expect(screen.getByText('Work')).toBeInTheDocument()
- expect(screen.getByText('02')).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByText('Bio')).toBeInTheDocument();
+ expect(screen.getByText('01')).toBeInTheDocument();
+ expect(screen.getByText('Work')).toBeInTheDocument();
+ expect(screen.getByText('02')).toBeInTheDocument();
+ });
it('renders "Quick Links" section', () => {
- render()
- expect(screen.getByText('Quick Links')).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByText('Quick Links')).toBeInTheDocument();
+ });
it('renders Email quick link', () => {
- render()
- expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument();
+ });
- it('renders a button for each item', () => {
- render()
- const buttons = screen.getAllByRole('button')
- expect(buttons.length).toBeGreaterThanOrEqual(ITEMS.length)
- })
- })
-})
+ it('renders a link for each nav item', () => {
+ render();
+ expect(screen.getByRole('link', { name: /Bio/i })).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: /Work/i })).toBeInTheDocument();
+ });
+ });
+
+ describe('active state', () => {
+ it('marks matching pathname item as active (no opacity-40)', () => {
+ vi.mocked(usePathname).mockReturnValue('/bio');
+ render();
+ const activeLink = screen.getByRole('link', { name: /Bio/i });
+ expect(activeLink).not.toHaveClass('opacity-40');
+ });
+
+ it('marks non-matching item as inactive (opacity-40)', () => {
+ vi.mocked(usePathname).mockReturnValue('/bio');
+ render();
+ const inactiveLink = screen.getByRole('link', { name: /Work/i });
+ expect(inactiveLink).toHaveClass('opacity-40');
+ });
+
+ it('marks first item active at root path', () => {
+ vi.mocked(usePathname).mockReturnValue('/');
+ render();
+ const firstLink = screen.getByRole('link', { name: /Bio/i });
+ expect(firstLink).not.toHaveClass('opacity-40');
+ });
+ });
+});
diff --git a/src/widgets/Navigation/ui/SidebarNav.tsx b/src/widgets/Navigation/ui/SidebarNav.tsx
index 9962b03..d48543d 100644
--- a/src/widgets/Navigation/ui/SidebarNav.tsx
+++ b/src/widgets/Navigation/ui/SidebarNav.tsx
@@ -1,55 +1,43 @@
-'use client'
+'use client';
-import { useState, useEffect } from 'react'
-import { cn } from '$shared/lib'
-import type { NavItem } from '../model/types'
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+import { CONTACT_LINKS, cn } from '$shared/lib';
+import type { NavItem } from '../model/types';
+/**
+ * Props for SidebarNav.
+ */
interface Props {
/**
* Navigation items to render
*/
- items: NavItem[]
+ items: NavItem[];
}
/**
* Fixed sidebar navigation, visible on lg+ screens.
+ * Active section determined by current URL pathname.
*/
export function SidebarNav({ items }: Props) {
- const [activeSection, setActiveSection] = useState('bio')
-
- useEffect(() => {
- const observer = new IntersectionObserver(
- entries => {
- entries.forEach(entry => {
- if (entry.isIntersecting) {
- setActiveSection(entry.target.id)
- }
- })
- },
- { rootMargin: '-20% 0px -70% 0px', threshold: 0 },
- )
-
- items.forEach(item => {
- const el = document.getElementById(item.id)
- if (el) observer.observe(el)
- })
-
- return () => observer.disconnect()
- }, [items])
+ const pathname = usePathname();
/**
- * Scrolls to the section by id with a 40px offset.
+ * An item is active when its slug matches the current pathname,
+ * or when the pathname is root and it is the first item.
*/
- function scrollToSection(id: string) {
- const el = document.getElementById(id)
- if (el) {
- const top = el.getBoundingClientRect().top + window.scrollY - 40
- window.scrollTo({ top, behavior: 'smooth' })
+ function isActive(item: NavItem): boolean {
+ if (pathname === `/${item.id}`) {
+ return true;
}
+ if (pathname === '/' && items[0]?.id === item.id) {
+ return true;
+ }
+ return false;
}
return (
-
- )
+ );
}
diff --git a/src/widgets/Navigation/ui/UtilityBar.stories.tsx b/src/widgets/Navigation/ui/UtilityBar.stories.tsx
index 997a3d5..df7cf2b 100644
--- a/src/widgets/Navigation/ui/UtilityBar.stories.tsx
+++ b/src/widgets/Navigation/ui/UtilityBar.stories.tsx
@@ -1,5 +1,5 @@
-import type { Meta, StoryObj } from '@storybook/nextjs-vite'
-import { UtilityBar } from './UtilityBar'
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import { UtilityBar } from './UtilityBar';
const meta: Meta = {
title: 'Widgets/UtilityBar',
@@ -11,10 +11,10 @@ const meta: Meta = {
),
],
-}
+};
-export default meta
+export default meta;
-type Story = StoryObj
+type Story = StoryObj;
-export const Default: Story = {}
+export const Default: Story = {};
diff --git a/src/widgets/Navigation/ui/UtilityBar.test.tsx b/src/widgets/Navigation/ui/UtilityBar.test.tsx
index 5e4b99f..d2d0ecf 100644
--- a/src/widgets/Navigation/ui/UtilityBar.test.tsx
+++ b/src/widgets/Navigation/ui/UtilityBar.test.tsx
@@ -1,30 +1,29 @@
-import { describe, it, expect } from 'vitest'
-import { render, screen } from '@testing-library/react'
-import { UtilityBar } from './UtilityBar'
+import { render, screen } from '@testing-library/react';
+import { UtilityBar } from './UtilityBar';
describe('UtilityBar', () => {
describe('rendering', () => {
it('renders "Contact" label', () => {
- render()
- expect(screen.getByText('Contact')).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByText('Contact')).toBeInTheDocument();
+ });
it('renders email link with correct href', () => {
- render()
- const link = screen.getByRole('link', { name: 'hello@allmy.work' })
- expect(link).toBeInTheDocument()
- expect(link).toHaveAttribute('href', 'mailto:hello@allmy.work')
- })
+ render();
+ const link = screen.getByRole('link', { name: 'hello@allmy.work' });
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute('href', 'mailto:hello@allmy.work');
+ });
it('renders "Download CV" button', () => {
- render()
- expect(screen.getByRole('button', { name: /download cv/i })).toBeInTheDocument()
- })
+ render();
+ expect(screen.getByRole('button', { name: /download cv/i })).toBeInTheDocument();
+ });
it('Download CV button has primary variant class', () => {
- render()
- const btn = screen.getByRole('button', { name: /download cv/i })
- expect(btn).toHaveClass('bg-burnt-oxide')
- })
- })
-})
+ render();
+ const btn = screen.getByRole('button', { name: /download cv/i });
+ expect(btn).toHaveClass('bg-blue');
+ });
+ });
+});
diff --git a/src/widgets/Navigation/ui/UtilityBar.tsx b/src/widgets/Navigation/ui/UtilityBar.tsx
index a66bb40..3e93030 100644
--- a/src/widgets/Navigation/ui/UtilityBar.tsx
+++ b/src/widgets/Navigation/ui/UtilityBar.tsx
@@ -1,6 +1,7 @@
-'use client'
+'use client';
-import { Button } from '$shared/ui'
+import { CONTACT_LINKS } from '$shared/lib';
+import { Button } from '$shared/ui';
/**
* Fixed bottom utility bar with contact info and CV download.
@@ -10,19 +11,16 @@ export function UtilityBar() {
* Handles CV download action.
*/
function handleDownloadCV() {
- console.log('Downloading CV...')
+ console.log('Downloading CV...');
}
return (
-
+
@@ -30,5 +28,5 @@ export function UtilityBar() {
- )
+ );
}
diff --git a/src/widgets/ProjectsSection/index.ts b/src/widgets/ProjectsSection/index.ts
new file mode 100644
index 0000000..b837da7
--- /dev/null
+++ b/src/widgets/ProjectsSection/index.ts
@@ -0,0 +1 @@
+export { default as ProjectsSection } from './ui/ProjectsSection/ProjectsSection';
diff --git a/src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.test.tsx b/src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.test.tsx
new file mode 100644
index 0000000..e4a79f2
--- /dev/null
+++ b/src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.test.tsx
@@ -0,0 +1,88 @@
+vi.mock('$shared/api', () => ({
+ getCollection: vi.fn(),
+}));
+
+import { render, screen } from '@testing-library/react';
+import { getCollection } from '$shared/api';
+import ProjectsSection from './ProjectsSection';
+
+const mockItems = [
+ {
+ id: '1',
+ collectionId: 'col1',
+ collectionName: 'projects',
+ created: '',
+ updated: '',
+ title: 'My App',
+ year: '2024',
+ role: 'Lead Developer',
+ description: 'A cool app.',
+ details: ['Feature A'],
+ stack: ['React', 'TypeScript'],
+ image: '',
+ order: 1,
+ },
+ {
+ id: '2',
+ collectionId: 'col1',
+ collectionName: 'projects',
+ created: '',
+ updated: '',
+ title: 'Other Project',
+ year: '2023',
+ role: 'Developer',
+ description: 'Another thing.',
+ details: [],
+ stack: ['Go'],
+ image: '',
+ order: 2,
+ },
+];
+
+const listResponse = (items: typeof mockItems) => ({
+ items,
+ page: 1,
+ perPage: 50,
+ totalItems: items.length,
+ totalPages: 1,
+});
+
+describe('ProjectsSection', () => {
+ beforeEach(() => {
+ vi.mocked(getCollection).mockResolvedValue(listResponse(mockItems) as never);
+ });
+
+ describe('rendering', () => {
+ it('renders a card for each project', async () => {
+ render(await ProjectsSection());
+ expect(screen.getByText('My App')).toBeInTheDocument();
+ expect(screen.getByText('Other Project')).toBeInTheDocument();
+ });
+
+ it('renders project descriptions', async () => {
+ render(await ProjectsSection());
+ expect(screen.getByText('A cool app.')).toBeInTheDocument();
+ });
+
+ it('renders tech stack tags', async () => {
+ render(await ProjectsSection());
+ expect(screen.getByText('React')).toBeInTheDocument();
+ expect(screen.getByText('TypeScript')).toBeInTheDocument();
+ expect(screen.getByText('Go')).toBeInTheDocument();
+ });
+
+ it('renders year badge for each project', async () => {
+ render(await ProjectsSection());
+ expect(screen.getByText('2024')).toBeInTheDocument();
+ expect(screen.getByText('2023')).toBeInTheDocument();
+ });
+ });
+
+ describe('empty state', () => {
+ it('renders empty grid when no items', async () => {
+ vi.mocked(getCollection).mockResolvedValue(listResponse([]) as never);
+ const { container } = render(await ProjectsSection());
+ expect(container.firstChild).toBeEmptyDOMElement();
+ });
+ });
+});
diff --git a/src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.tsx b/src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.tsx
new file mode 100644
index 0000000..f71d3e0
--- /dev/null
+++ b/src/widgets/ProjectsSection/ui/ProjectsSection/ProjectsSection.tsx
@@ -0,0 +1,31 @@
+import { ProjectCard } from '$entities/project';
+import type { ProjectRecord } from '$shared/api';
+import { getCollection } from '$shared/api';
+import { buildFileUrl } from '$shared/lib';
+
+/**
+ * Projects section component.
+ * Displays portfolio projects in a two-column grid, sorted by order field.
+ */
+export default async function ProjectsSection() {
+ const { items } = await getCollection
('projects', {
+ sort: 'order',
+ tags: ['projects'],
+ });
+
+ return (
+
+ {items.map((project) => (
+
+ ))}
+
+ );
+}
diff --git a/src/widgets/SectionFactory/index.ts b/src/widgets/SectionFactory/index.ts
new file mode 100644
index 0000000..4a2bd6b
--- /dev/null
+++ b/src/widgets/SectionFactory/index.ts
@@ -0,0 +1,2 @@
+export type { SectionFactoryProps } from './ui/SectionFactory/SectionFactory';
+export { SectionFactory } from './ui/SectionFactory/SectionFactory';
diff --git a/src/widgets/SectionFactory/ui/SectionFactory/SectionFactory.tsx b/src/widgets/SectionFactory/ui/SectionFactory/SectionFactory.tsx
new file mode 100644
index 0000000..9bb4892
--- /dev/null
+++ b/src/widgets/SectionFactory/ui/SectionFactory/SectionFactory.tsx
@@ -0,0 +1,40 @@
+import { notFound } from 'next/navigation';
+import type { ComponentType } from 'react';
+import BioSection from '../../../BioSection/ui/BioSection/BioSection';
+import ExperienceSection from '../../../ExperienceSection/ui/ExperienceSection/ExperienceSection';
+import IntroSection from '../../../IntroSection/ui/IntroSection/IntroSection';
+import ProjectsSection from '../../../ProjectsSection/ui/ProjectsSection/ProjectsSection';
+import SkillsSection from '../../../SkillsSection/ui/SkillsSection/SkillsSection';
+
+/**
+ * Props for the SectionFactory widget.
+ */
+export type SectionFactoryProps = {
+ /**
+ * Section slug to render
+ */
+ slug: string;
+};
+
+// biome-ignore lint/suspicious/noExplicitAny: registry holds heterogeneous RSC components
+const SECTIONS: Record> = {
+ intro: IntroSection,
+ bio: BioSection,
+ skills: SkillsSection,
+ experience: ExperienceSection,
+ projects: ProjectsSection,
+};
+
+/**
+ * Renders the correct section widget for a given slug.
+ * Uses a static registry — React resolves async RSC children internally.
+ */
+export function SectionFactory({ slug }: SectionFactoryProps) {
+ const Component = SECTIONS[slug];
+
+ if (!Component) {
+ notFound();
+ }
+
+ return ;
+}
diff --git a/src/widgets/SectionsAccordion/index.ts b/src/widgets/SectionsAccordion/index.ts
new file mode 100644
index 0000000..4a468c4
--- /dev/null
+++ b/src/widgets/SectionsAccordion/index.ts
@@ -0,0 +1 @@
+export { SectionsAccordion } from './ui/SectionsAccordion/SectionsAccordion';
diff --git a/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx b/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx
new file mode 100644
index 0000000..918c46b
--- /dev/null
+++ b/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx
@@ -0,0 +1,97 @@
+import { render, screen } from '@testing-library/react';
+import type { SectionRecord } from '$entities/Section';
+import { SectionsAccordion } from './SectionsAccordion';
+
+const baseRecord = { collectionId: 'c1', collectionName: 'sections', created: '', updated: '' };
+
+const sections: SectionRecord[] = [
+ { ...baseRecord, id: '1', slug: 'intro', title: 'Intro', order: 1 },
+ { ...baseRecord, id: '2', slug: 'bio', title: 'Bio', order: 2 },
+ { ...baseRecord, id: '3', slug: 'skills', title: 'Skills', order: 3 },
+];
+
+describe('SectionsAccordion', () => {
+ describe('active section rendering', () => {
+ it('renders the active section as an h1', () => {
+ render(
+
+ Intro content
+ Bio content
+ Skills content
+ ,
+ );
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('01. Intro');
+ });
+
+ it('renders inactive sections as links', () => {
+ render(
+
+ Intro content
+ Bio content
+ Skills content
+ ,
+ );
+ const links = screen.getAllByRole('link');
+ expect(links).toHaveLength(2);
+ });
+
+ it('inactive section links point to correct hrefs', () => {
+ render(
+
+ Intro content
+ Bio content
+ Skills content
+ ,
+ );
+ expect(screen.getByRole('link', { name: /02.*Bio/i })).toHaveAttribute('href', '/bio');
+ expect(screen.getByRole('link', { name: /03.*Skills/i })).toHaveAttribute('href', '/skills');
+ });
+
+ it('renders the correct active section for a given activeSlug', () => {
+ render(
+
+ Intro content
+ Bio content
+ Skills content
+ ,
+ );
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('02. Bio');
+ });
+
+ it('only one section is active at a time', () => {
+ render(
+
+ Intro content
+ Bio content
+ Skills content
+ ,
+ );
+ expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
+ });
+ });
+
+ describe('content slots', () => {
+ it('shows active section content', () => {
+ render(
+
+ Intro content
+ Bio content
+ Skills content
+ ,
+ );
+ expect(screen.getByText('Intro content')).toBeInTheDocument();
+ });
+
+ it('does not show inactive section content', () => {
+ render(
+
+ Intro content
+ Bio content
+ Skills content
+ ,
+ );
+ expect(screen.queryByText('Bio content')).not.toBeInTheDocument();
+ expect(screen.queryByText('Skills content')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx b/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx
new file mode 100644
index 0000000..c195e02
--- /dev/null
+++ b/src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx
@@ -0,0 +1,46 @@
+import type { ReactNode } from 'react';
+import { Children } from 'react';
+import type { SectionRecord } from '$entities/Section';
+import { SectionAccordion } from '$entities/Section';
+
+type Props = {
+ /**
+ * Ordered section metadata — drives navigation labels and IDs
+ */
+ sections: SectionRecord[];
+ /**
+ * Slug of the currently active section.
+ * Must match one of the slugs in the sections array.
+ */
+ activeSlug: string;
+ /**
+ * Pre-rendered RSC content slots, one per section, matched by index
+ */
+ children: ReactNode;
+};
+
+/**
+ * Renders all portfolio sections as an accordion list.
+ * Active section is determined by the URL (activeSlug prop); inactive sections
+ * render as navigation links so the browser handles routing.
+ */
+export function SectionsAccordion({ sections, activeSlug, children }: Props) {
+ const slots = Children.toArray(children);
+
+ return (
+
+ {sections.map((section, i) => (
+
+ {slots[i]}
+
+ ))}
+
+ );
+}
diff --git a/src/widgets/SkillsSection/ui/SkillsSection/SkillsSection.tsx b/src/widgets/SkillsSection/ui/SkillsSection/SkillsSection.tsx
new file mode 100644
index 0000000..9c729c5
--- /dev/null
+++ b/src/widgets/SkillsSection/ui/SkillsSection/SkillsSection.tsx
@@ -0,0 +1,37 @@
+import { notFound } from 'next/navigation';
+import type { SkillRecord } from '$shared/api';
+import { getCollection } from '$shared/api';
+import { groupByKey } from '$shared/lib';
+import { Badge } from '$shared/ui';
+
+/**
+ * Skills section component.
+ * Displays technology skills grouped by category.
+ */
+export default async function SkillsSection() {
+ const data = await getCollection('skills', {
+ sort: 'category,order',
+ tags: ['skills'],
+ });
+
+ if (!data.items.length) {
+ notFound();
+ }
+
+ const categories = groupByKey(data.items, 'category');
+
+ return (
+
+ {Object.entries(categories).map(([category, items]) => (
+
+
{category}
+
+ {items.map((skill) => (
+ {skill.name}
+ ))}
+
+
+ ))}
+
+ );
+}
diff --git a/src/widgets/index.ts b/src/widgets/index.ts
index f25c564..71ffc2e 100644
--- a/src/widgets/index.ts
+++ b/src/widgets/index.ts
@@ -1 +1,2 @@
-export * from './Navigation'
+export * from './Footer';
+export * from './Navigation';
diff --git a/tsconfig.json b/tsconfig.json
index f19c003..de58819 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -18,6 +18,7 @@
"name": "next"
}
],
+ "types": ["vitest/globals", "react/canary"],
"paths": {
"@/*": ["./*"],
"$shared/*": ["./src/shared/*"],
diff --git a/vitest.config.ts b/vitest.config.ts
index 7eee458..71a8ef2 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,6 +1,6 @@
-import { defineConfig } from 'vitest/config'
-import react from '@vitejs/plugin-react'
-import path from 'path'
+import react from '@vitejs/plugin-react';
+import path from 'path';
+import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react()],
@@ -12,13 +12,13 @@ export default defineConfig({
},
resolve: {
alias: {
- '$shared': path.resolve(__dirname, './src/shared'),
- '$entities': path.resolve(__dirname, './src/entities'),
- '$widgets': path.resolve(__dirname, './src/widgets'),
- '$features': path.resolve(__dirname, './src/features'),
- '$app': path.resolve(__dirname, './src/app'),
- '$routes': path.resolve(__dirname, './src/routes'),
+ $shared: path.resolve(__dirname, './src/shared'),
+ $entities: path.resolve(__dirname, './src/entities'),
+ $widgets: path.resolve(__dirname, './src/widgets'),
+ $features: path.resolve(__dirname, './src/features'),
+ $app: path.resolve(__dirname, './src/app'),
+ $routes: path.resolve(__dirname, './src/routes'),
'@': path.resolve(__dirname, '.'),
},
},
-})
+});
diff --git a/vitest.shims.d.ts b/vitest.shims.d.ts
new file mode 100644
index 0000000..03b1801
--- /dev/null
+++ b/vitest.shims.d.ts
@@ -0,0 +1 @@
+///
diff --git a/yarn.lock b/yarn.lock
index 3d91ea8..bb38caf 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -248,6 +248,97 @@ __metadata:
languageName: node
linkType: hard
+"@biomejs/biome@npm:2.4.13":
+ version: 2.4.13
+ resolution: "@biomejs/biome@npm:2.4.13"
+ dependencies:
+ "@biomejs/cli-darwin-arm64": "npm:2.4.13"
+ "@biomejs/cli-darwin-x64": "npm:2.4.13"
+ "@biomejs/cli-linux-arm64": "npm:2.4.13"
+ "@biomejs/cli-linux-arm64-musl": "npm:2.4.13"
+ "@biomejs/cli-linux-x64": "npm:2.4.13"
+ "@biomejs/cli-linux-x64-musl": "npm:2.4.13"
+ "@biomejs/cli-win32-arm64": "npm:2.4.13"
+ "@biomejs/cli-win32-x64": "npm:2.4.13"
+ dependenciesMeta:
+ "@biomejs/cli-darwin-arm64":
+ optional: true
+ "@biomejs/cli-darwin-x64":
+ optional: true
+ "@biomejs/cli-linux-arm64":
+ optional: true
+ "@biomejs/cli-linux-arm64-musl":
+ optional: true
+ "@biomejs/cli-linux-x64":
+ optional: true
+ "@biomejs/cli-linux-x64-musl":
+ optional: true
+ "@biomejs/cli-win32-arm64":
+ optional: true
+ "@biomejs/cli-win32-x64":
+ optional: true
+ bin:
+ biome: bin/biome
+ checksum: 10c0/a8c09d7c05d834243a76704e31bda05346d2a06a75e90e6de2ef0d4edc33bd7d382b380bad9275ddd379e9e44ceaea9907a9c0de2156859b36b057c155f20a0e
+ languageName: node
+ linkType: hard
+
+"@biomejs/cli-darwin-arm64@npm:2.4.13":
+ version: 2.4.13
+ resolution: "@biomejs/cli-darwin-arm64@npm:2.4.13"
+ conditions: os=darwin & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@biomejs/cli-darwin-x64@npm:2.4.13":
+ version: 2.4.13
+ resolution: "@biomejs/cli-darwin-x64@npm:2.4.13"
+ conditions: os=darwin & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@biomejs/cli-linux-arm64-musl@npm:2.4.13":
+ version: 2.4.13
+ resolution: "@biomejs/cli-linux-arm64-musl@npm:2.4.13"
+ conditions: os=linux & cpu=arm64 & libc=musl
+ languageName: node
+ linkType: hard
+
+"@biomejs/cli-linux-arm64@npm:2.4.13":
+ version: 2.4.13
+ resolution: "@biomejs/cli-linux-arm64@npm:2.4.13"
+ conditions: os=linux & cpu=arm64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"@biomejs/cli-linux-x64-musl@npm:2.4.13":
+ version: 2.4.13
+ resolution: "@biomejs/cli-linux-x64-musl@npm:2.4.13"
+ conditions: os=linux & cpu=x64 & libc=musl
+ languageName: node
+ linkType: hard
+
+"@biomejs/cli-linux-x64@npm:2.4.13":
+ version: 2.4.13
+ resolution: "@biomejs/cli-linux-x64@npm:2.4.13"
+ conditions: os=linux & cpu=x64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"@biomejs/cli-win32-arm64@npm:2.4.13":
+ version: 2.4.13
+ resolution: "@biomejs/cli-win32-arm64@npm:2.4.13"
+ conditions: os=win32 & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@biomejs/cli-win32-x64@npm:2.4.13":
+ version: 2.4.13
+ resolution: "@biomejs/cli-win32-x64@npm:2.4.13"
+ conditions: os=win32 & cpu=x64
+ languageName: node
+ linkType: hard
+
"@blazediff/core@npm:1.9.1":
version: 1.9.1
resolution: "@blazediff/core@npm:1.9.1"
@@ -3148,6 +3239,44 @@ __metadata:
languageName: node
linkType: hard
+"dom-serializer@npm:^3.0.0":
+ version: 3.1.1
+ resolution: "dom-serializer@npm:3.1.1"
+ dependencies:
+ domelementtype: "npm:^3.0.0"
+ domhandler: "npm:^6.0.0"
+ entities: "npm:^8.0.0"
+ checksum: 10c0/dc700204f0ef4a4c5a344bd8773703d5476dcca1a4af8b2d3fd9bcbbace833439b6ea3d3c48c4b387fa0b2456dd839caca354eed7f7c7f17bc47da8e217742ca
+ languageName: node
+ linkType: hard
+
+"domelementtype@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "domelementtype@npm:3.0.0"
+ checksum: 10c0/26e8ef992769c4f9bce941eb0cff7ce2ba3f1b3bf77710bb4b029055030625892e83da326cc36b1e444cf3bfdea7d1954791ee2227746387465da9929d16d954
+ languageName: node
+ linkType: hard
+
+"domhandler@npm:6.0.1, domhandler@npm:^6.0.0":
+ version: 6.0.1
+ resolution: "domhandler@npm:6.0.1"
+ dependencies:
+ domelementtype: "npm:^3.0.0"
+ checksum: 10c0/8655204dd9612b55813d5880e3e87e134d6dfb2de4bd80f342b3c97f41b167576a8c66c0449c2423999953aedfcda290f7be253a6f9bf71e815afa85f939d44e
+ languageName: node
+ linkType: hard
+
+"domutils@npm:^4.0.2":
+ version: 4.0.2
+ resolution: "domutils@npm:4.0.2"
+ dependencies:
+ dom-serializer: "npm:^3.0.0"
+ domelementtype: "npm:^3.0.0"
+ domhandler: "npm:^6.0.0"
+ checksum: 10c0/59827827ecf15ed1f43f4cb8db374484b6089bf40e32cb41c8e381525aeb5ef5d029e4f9d5f74a418bf3217b87a6cbabdf5b4ebed0a018bc533bd6349c46a739
+ languageName: node
+ linkType: hard
+
"dunder-proto@npm:^1.0.0, dunder-proto@npm:^1.0.1":
version: 1.0.1
resolution: "dunder-proto@npm:1.0.1"
@@ -3197,6 +3326,13 @@ __metadata:
languageName: node
linkType: hard
+"entities@npm:^8.0.0":
+ version: 8.0.0
+ resolution: "entities@npm:8.0.0"
+ checksum: 10c0/938e631664c19451823344a351aeeafd74fae2d5fa51e4d5b6ff635afaefd4bacf0f609989888c04c42733f46ffdac15211608267ebb02488005891a4793e94d
+ languageName: node
+ linkType: hard
+
"env-paths@npm:^2.2.0":
version: 2.2.1
resolution: "env-paths@npm:2.2.1"
@@ -4200,6 +4336,16 @@ __metadata:
languageName: node
linkType: hard
+"html-dom-parser@npm:7.1.0":
+ version: 7.1.0
+ resolution: "html-dom-parser@npm:7.1.0"
+ dependencies:
+ domhandler: "npm:6.0.1"
+ htmlparser2: "npm:12.0.0"
+ checksum: 10c0/e73b0c2e8bbe809ff877bf2483f6547f4797ee55c1c6d0f486d54ce7310e799c36986328f11dde1ce99608939a06efdf1d02c45a0abd0ec40b405b230c3dffdf
+ languageName: node
+ linkType: hard
+
"html-encoding-sniffer@npm:^6.0.0":
version: 6.0.0
resolution: "html-encoding-sniffer@npm:6.0.0"
@@ -4216,6 +4362,36 @@ __metadata:
languageName: node
linkType: hard
+"html-react-parser@npm:^6.1.0":
+ version: 6.1.0
+ resolution: "html-react-parser@npm:6.1.0"
+ dependencies:
+ domhandler: "npm:6.0.1"
+ html-dom-parser: "npm:7.1.0"
+ react-property: "npm:2.0.2"
+ style-to-js: "npm:1.1.21"
+ peerDependencies:
+ "@types/react": 0.14 || 15 || 16 || 17 || 18 || 19
+ react: 0.14 || 15 || 16 || 17 || 18 || 19
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ checksum: 10c0/1626454d3e3edf01b8e626b6f4a150b9ab013b4d379e5038506c93c3ce7cfb09a78abff079512ecbd4dd6d840c0bbcd55f722ee3302a70400c760e9109891b49
+ languageName: node
+ linkType: hard
+
+"htmlparser2@npm:12.0.0":
+ version: 12.0.0
+ resolution: "htmlparser2@npm:12.0.0"
+ dependencies:
+ domelementtype: "npm:^3.0.0"
+ domhandler: "npm:^6.0.0"
+ domutils: "npm:^4.0.2"
+ entities: "npm:^8.0.0"
+ checksum: 10c0/3fcdce24c06fc4c9c42c8142d6c139104a2c30f901ce046cb0bdeaa8678445294aaf4506569464a5c853c8b1d89609f7306ea133efd966bf703f574a394dcff9
+ languageName: node
+ linkType: hard
+
"http-cache-semantics@npm:^4.1.1":
version: 4.2.0
resolution: "http-cache-semantics@npm:4.2.0"
@@ -4299,6 +4475,13 @@ __metadata:
languageName: node
linkType: hard
+"inline-style-parser@npm:0.2.7":
+ version: 0.2.7
+ resolution: "inline-style-parser@npm:0.2.7"
+ checksum: 10c0/d884d76f84959517430ae6c22f0bda59bb3f58f539f99aac75a8d786199ec594ed648c6ab4640531f9fc244b0ed5cd8c458078e592d016ef06de793beb1debff
+ languageName: node
+ linkType: hard
+
"internal-slot@npm:^1.1.0":
version: 1.1.0
resolution: "internal-slot@npm:1.1.0"
@@ -4826,6 +5009,117 @@ __metadata:
languageName: node
linkType: hard
+"lefthook-darwin-arm64@npm:2.1.6":
+ version: 2.1.6
+ resolution: "lefthook-darwin-arm64@npm:2.1.6"
+ conditions: os=darwin & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"lefthook-darwin-x64@npm:2.1.6":
+ version: 2.1.6
+ resolution: "lefthook-darwin-x64@npm:2.1.6"
+ conditions: os=darwin & cpu=x64
+ languageName: node
+ linkType: hard
+
+"lefthook-freebsd-arm64@npm:2.1.6":
+ version: 2.1.6
+ resolution: "lefthook-freebsd-arm64@npm:2.1.6"
+ conditions: os=freebsd & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"lefthook-freebsd-x64@npm:2.1.6":
+ version: 2.1.6
+ resolution: "lefthook-freebsd-x64@npm:2.1.6"
+ conditions: os=freebsd & cpu=x64
+ languageName: node
+ linkType: hard
+
+"lefthook-linux-arm64@npm:2.1.6":
+ version: 2.1.6
+ resolution: "lefthook-linux-arm64@npm:2.1.6"
+ conditions: os=linux & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"lefthook-linux-x64@npm:2.1.6":
+ version: 2.1.6
+ resolution: "lefthook-linux-x64@npm:2.1.6"
+ conditions: os=linux & cpu=x64
+ languageName: node
+ linkType: hard
+
+"lefthook-openbsd-arm64@npm:2.1.6":
+ version: 2.1.6
+ resolution: "lefthook-openbsd-arm64@npm:2.1.6"
+ conditions: os=openbsd & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"lefthook-openbsd-x64@npm:2.1.6":
+ version: 2.1.6
+ resolution: "lefthook-openbsd-x64@npm:2.1.6"
+ conditions: os=openbsd & cpu=x64
+ languageName: node
+ linkType: hard
+
+"lefthook-windows-arm64@npm:2.1.6":
+ version: 2.1.6
+ resolution: "lefthook-windows-arm64@npm:2.1.6"
+ conditions: os=win32 & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"lefthook-windows-x64@npm:2.1.6":
+ version: 2.1.6
+ resolution: "lefthook-windows-x64@npm:2.1.6"
+ conditions: os=win32 & cpu=x64
+ languageName: node
+ linkType: hard
+
+"lefthook@npm:^2.1.6":
+ version: 2.1.6
+ resolution: "lefthook@npm:2.1.6"
+ dependencies:
+ lefthook-darwin-arm64: "npm:2.1.6"
+ lefthook-darwin-x64: "npm:2.1.6"
+ lefthook-freebsd-arm64: "npm:2.1.6"
+ lefthook-freebsd-x64: "npm:2.1.6"
+ lefthook-linux-arm64: "npm:2.1.6"
+ lefthook-linux-x64: "npm:2.1.6"
+ lefthook-openbsd-arm64: "npm:2.1.6"
+ lefthook-openbsd-x64: "npm:2.1.6"
+ lefthook-windows-arm64: "npm:2.1.6"
+ lefthook-windows-x64: "npm:2.1.6"
+ dependenciesMeta:
+ lefthook-darwin-arm64:
+ optional: true
+ lefthook-darwin-x64:
+ optional: true
+ lefthook-freebsd-arm64:
+ optional: true
+ lefthook-freebsd-x64:
+ optional: true
+ lefthook-linux-arm64:
+ optional: true
+ lefthook-linux-x64:
+ optional: true
+ lefthook-openbsd-arm64:
+ optional: true
+ lefthook-openbsd-x64:
+ optional: true
+ lefthook-windows-arm64:
+ optional: true
+ lefthook-windows-x64:
+ optional: true
+ bin:
+ lefthook: bin/index.js
+ checksum: 10c0/3ccbe60951ebf59e35e02ca10dc8942a4455ec106f0f14a5fed2e40f000b5b57190594f3be87715d5a9a8b0cf93de33902cbd3c94688e116ffc07ad1760cfe9e
+ languageName: node
+ linkType: hard
+
"levn@npm:^0.4.1":
version: 0.4.1
resolution: "levn@npm:0.4.1"
@@ -5636,6 +5930,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "portfolio@workspace:."
dependencies:
+ "@biomejs/biome": "npm:2.4.13"
"@chromatic-com/storybook": "npm:^5.1.2"
"@storybook/addon-a11y": "npm:^10.3.5"
"@storybook/addon-docs": "npm:^10.3.5"
@@ -5656,7 +5951,9 @@ __metadata:
eslint: "npm:^9"
eslint-config-next: "npm:16.2.4"
eslint-plugin-storybook: "npm:^10.3.5"
+ html-react-parser: "npm:^6.1.0"
jsdom: "npm:^29.0.2"
+ lefthook: "npm:^2.1.6"
next: "npm:16.2.4"
playwright: "npm:^1.59.1"
react: "npm:19.2.4"
@@ -5812,6 +6109,13 @@ __metadata:
languageName: node
linkType: hard
+"react-property@npm:2.0.2":
+ version: 2.0.2
+ resolution: "react-property@npm:2.0.2"
+ checksum: 10c0/27a3dfa68d29d45fc3582552715203291d26c6f1b228fdb6775e7ca19b10753141dbe98a0aa3a4da745b39fcd7427dc2d623055e63742062231ee18692a6f0fa
+ languageName: node
+ linkType: hard
+
"react@npm:19.2.4":
version: 19.2.4
resolution: "react@npm:19.2.4"
@@ -6548,6 +6852,24 @@ __metadata:
languageName: node
linkType: hard
+"style-to-js@npm:1.1.21":
+ version: 1.1.21
+ resolution: "style-to-js@npm:1.1.21"
+ dependencies:
+ style-to-object: "npm:1.0.14"
+ checksum: 10c0/94231aa80f58f442c3a5ae01a21d10701e5d62f96b4b3e52eab3499077ee52df203cc0df4a1a870707f5e99470859136ea8657b782a5f4ca7934e0ffe662a588
+ languageName: node
+ linkType: hard
+
+"style-to-object@npm:1.0.14":
+ version: 1.0.14
+ resolution: "style-to-object@npm:1.0.14"
+ dependencies:
+ inline-style-parser: "npm:0.2.7"
+ checksum: 10c0/854d9e9b77afc336e6d7b09348e7939f2617b34eb0895824b066d8cd1790284cb6d8b2ba36be88025b2595d715dba14b299ae76e4628a366541106f639e13679
+ languageName: node
+ linkType: hard
+
"styled-jsx@npm:5.1.6":
version: 5.1.6
resolution: "styled-jsx@npm:5.1.6"