From 759f579695e3ba8c63a5aa1d5be863934ecc0e80 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 23 Apr 2026 20:35:32 +0300 Subject: [PATCH 01/74] fix: storybook font rendering and shared fonts module --- .gitignore | 11 +++++++++++ .storybook/preview.tsx | 30 ++++++++++++++++++++++++++++++ app/layout.tsx | 19 +------------------ src/shared/lib/fonts.ts | 18 ++++++++++++++++++ src/shared/lib/index.ts | 1 + src/shared/styles/theme.css | 3 +++ 6 files changed, 64 insertions(+), 18 deletions(-) create mode 100644 .storybook/preview.tsx create mode 100644 src/shared/lib/fonts.ts diff --git a/.gitignore b/.gitignore index 1834d9d..2fc9084 100644 --- a/.gitignore +++ b/.gitignore @@ -43,5 +43,16 @@ next-env.d.ts *.md !README.md +# hidden files (allow some, ignore others) +.** +!/.storybook +!/.yarn +!/yarnrc.yml +!/.claude +!/.vscode +!/.gitattributes +!/.gitignore +!/biome.json + *storybook.log storybook-static diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 0000000..d1ab09b --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import type { Preview } from '@storybook/nextjs-vite' +import { fraunces, publicSans } from '../src/shared/lib' +import '../app/globals.css' + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + a11y: { + // 'todo' - show a11y violations in the test UI only + // 'error' - fail CI on a11y violations + // 'off' - skip a11y checks entirely + test: 'todo', + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +export default preview diff --git a/app/layout.tsx b/app/layout.tsx index 6b624cd..00879ef 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,24 +1,7 @@ import type { Metadata } from 'next' -import { Fraunces, Public_Sans } from 'next/font/google' +import { fraunces, publicSans } from '$shared/lib' import './globals.css' -/** - * Heading font — variable axes for brutalist variation settings - */ -const fraunces = Fraunces({ - subsets: ['latin'], - variable: '--font-fraunces', - axes: ['opsz', 'SOFT', 'WONK'], -}) - -/** - * Body font - */ -const publicSans = Public_Sans({ - subsets: ['latin'], - variable: '--font-public-sans', -}) - export const metadata: Metadata = { title: 'Portfolio', description: 'Portfolio', diff --git a/src/shared/lib/fonts.ts b/src/shared/lib/fonts.ts new file mode 100644 index 0000000..c73f10f --- /dev/null +++ b/src/shared/lib/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..59e9088 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -1,2 +1,3 @@ export { cn } from './cn' export type { ClassValue } from 'clsx' +export * from './fonts' diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index c78dcda..b3ce4ba 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -80,6 +80,9 @@ } @theme inline { + --font-heading: var(--font-fraunces); + --font-body: var(--font-public-sans); + --color-ochre-clay: var(--ochre-clay); --color-slate-indigo: var(--slate-indigo); --color-burnt-oxide: var(--burnt-oxide); From 9c139adbf5a5715a793830615fd85113ddf70e86 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 23 Apr 2026 20:35:46 +0300 Subject: [PATCH 02/74] feat: define PocketBase types and fetch client --- src/shared/api/client.ts | 72 ++++++++++++++++ src/shared/api/index.ts | 2 + src/shared/api/types.ts | 173 +++++++++++++++++++++++++++++++++++++++ src/shared/index.ts | 1 + 4 files changed, 248 insertions(+) create mode 100644 src/shared/api/client.ts create mode 100644 src/shared/api/index.ts create mode 100644 src/shared/api/types.ts diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts new file mode 100644 index 0000000..1efafbb --- /dev/null +++ b/src/shared/api/client.ts @@ -0,0 +1,72 @@ +import type { ListResponse } from './types' + +/** + * Native fetch wrapper for PocketBase API requests. + */ +const PB_URL = process.env.NEXT_PUBLIC_PB_URL || 'http://127.0.0.1:8090' + +/** + * 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 revalidation time 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, revalidate = 3600 } = options + + const params = new URLSearchParams() + if (sort) params.set('sort', sort) + if (filter) params.set('filter', filter) + if (expand) params.set('expand', expand) + + const url = `${PB_URL}/api/collections/${collection}/records?${params.toString()}` + + const res = await fetch(url, { + next: { revalidate }, + }) + + if (!res.ok) { + throw new Error(`Failed to fetch collection: ${collection}`) + } + + return res.json() +} + +/** + * Fetch a single record from a PocketBase collection by ID or filter. + */ +export async function getFirstRecord( + collection: string, + options: PBFetchOptions = {} +): Promise { + const data = await getCollection(collection, { + ...options, + // PocketBase convention for "first" or "singleton" patterns + filter: options.filter, + }) + + return data.items[0] || null +} diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts new file mode 100644 index 0000000..886d07b --- /dev/null +++ b/src/shared/api/index.ts @@ -0,0 +1,2 @@ +export * from './types' +export * from './client' diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts new file mode 100644 index 0000000..204fb2d --- /dev/null +++ b/src/shared/api/types.ts @@ -0,0 +1,173 @@ +/** + * 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 site sections and routing. + */ +export type SectionRecord = BaseRecord & { + /** + * URL-friendly identifier used for routing + */ + slug: string + /** + * Display name of the section + */ + title: string + /** + * Visual numbering prefix (e.g., "01") + */ + number: string + /** + * Sorting weight for section order + */ + order: number +} + +/** + * PocketBase collection for simple text blocks (Intro, Bio). + */ +export type PageContentRecord = BaseRecord & { + /** + * Slug corresponding to the parent section + */ + slug: string + /** + * 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 + /** + * 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 + /** + * Short summary of the project + */ + 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 + /** + * Sorting weight for the project list + */ + order: number +} + +/** + * 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..f715fb8 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -1,2 +1,3 @@ export * from './ui' export * from './lib' +export * from './api' From 8aff27f8aca213f9bb79210006941afb2d0c51f7 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 23 Apr 2026 20:40:11 +0300 Subject: [PATCH 03/74] chore: configure biome for linting and formatting --- biome.json | 57 +++++++++++++++++++++++++++++ package.json | 8 ++++- vitest.shims.d.ts | 1 + yarn.lock | 92 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 biome.json create mode 100644 vitest.shims.d.ts diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..79ec14a --- /dev/null +++ b/biome.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.13/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false, + "includes": ["src/**/*", "app/**/*"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 120 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "noUnusedVariables": "warn" + }, + "style": { + "noNonNullAssertion": "warn", + "useBlockStatements": "error" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "jsxQuoteStyle": "double", + "semicolons": "always", + "trailingCommas": "all" + } + }, + "json": { + "formatter": { + "trailingCommas": "none" + } + }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/package.json b/package.json index 3724585..e246627 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,12 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint", + "lint": "biome lint --write .", + "format": "biome format --write .", + "check": "biome check --write .", + "lint:ci": "biome lint .", + "format:ci": "biome format .", + "check:ci": "biome check .", "test": "vitest run", "test:watch": "vitest", "storybook": "storybook dev -p 6006", @@ -20,6 +25,7 @@ "tailwind-merge": "^3.5.0" }, "devDependencies": { + "@biomejs/biome": "2.4.13", "@chromatic-com/storybook": "^5.1.2", "@storybook/addon-a11y": "^10.3.5", "@storybook/addon-docs": "^10.3.5", diff --git a/vitest.shims.d.ts b/vitest.shims.d.ts new file mode 100644 index 0000000..7782f28 --- /dev/null +++ b/vitest.shims.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 3d91ea8..3d8bb68 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" @@ -5636,6 +5727,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" From 1d333fd94530bf5118a73253beef3ccac7637b35 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 23 Apr 2026 20:52:43 +0300 Subject: [PATCH 04/74] chore: format codebase and move SectionAccordion to entities/Section --- .storybook/preview.ts | 21 --- app/layout.tsx | 14 +- app/page.tsx | 27 +-- src/entities/Section/index.ts | 2 + src/entities/Section/model/types.ts | 23 +++ .../SectionAccordion.stories.tsx | 0 .../SectionAccordion.test.tsx | 0 .../ui/SectionAccordion}/SectionAccordion.tsx | 0 .../Section/ui/SectionAccordion/index.ts | 1 + src/entities/Section/ui/index.ts | 1 + src/entities/experience/index.ts | 2 +- .../experience/ui/ExperienceCard.stories.tsx | 24 ++- .../experience/ui/ExperienceCard.test.tsx | 84 ++++----- src/entities/experience/ui/ExperienceCard.tsx | 20 +-- src/entities/index.ts | 4 +- src/entities/project/index.ts | 6 +- .../ui/DetailedProjectCard.stories.tsx | 19 ++- .../project/ui/DetailedProjectCard.test.tsx | 110 ++++++------ .../project/ui/DetailedProjectCard.tsx | 38 ++--- .../project/ui/ProjectCard.stories.tsx | 14 +- src/entities/project/ui/ProjectCard.test.tsx | 101 ++++++----- src/entities/project/ui/ProjectCard.tsx | 22 +-- .../project/ui/ProjectMetadata.stories.tsx | 12 +- .../project/ui/ProjectMetadata.test.tsx | 118 ++++++------- src/entities/project/ui/ProjectMetadata.tsx | 18 +- src/shared/api/client.ts | 46 +++-- src/shared/api/index.ts | 4 +- src/shared/api/types.ts | 95 ++++------- src/shared/index.ts | 6 +- src/shared/lib/cn.test.ts | 42 ++--- src/shared/lib/cn.ts | 6 +- src/shared/lib/fonts.ts | 6 +- src/shared/lib/index.ts | 6 +- src/shared/styles/theme.css | 91 +++++++--- src/shared/ui/Badge/index.ts | 4 +- src/shared/ui/Badge/ui/Badge.stories.tsx | 12 +- src/shared/ui/Badge/ui/Badge.test.tsx | 64 +++---- src/shared/ui/Badge/ui/Badge.tsx | 22 +-- src/shared/ui/Button/index.ts | 4 +- src/shared/ui/Button/ui/Button.stories.tsx | 44 +++-- src/shared/ui/Button/ui/Button.test.tsx | 96 +++++------ src/shared/ui/Button/ui/Button.tsx | 29 ++-- src/shared/ui/Card/index.ts | 4 +- src/shared/ui/Card/ui/Card.stories.tsx | 20 +-- src/shared/ui/Card/ui/Card.test.tsx | 110 ++++++------ src/shared/ui/Card/ui/Card.tsx | 32 ++-- src/shared/ui/Input/index.ts | 2 +- src/shared/ui/Input/ui/Input.stories.tsx | 22 +-- src/shared/ui/Input/ui/Input.test.tsx | 160 +++++++++--------- src/shared/ui/Input/ui/Input.tsx | 57 ++++--- src/shared/ui/Section/index.ts | 4 +- src/shared/ui/Section/ui/Section.stories.tsx | 34 ++-- src/shared/ui/Section/ui/Section.test.tsx | 132 ++++++++------- src/shared/ui/Section/ui/Section.tsx | 46 ++--- src/shared/ui/SectionAccordion/index.ts | 1 - src/shared/ui/TechStack/index.ts | 2 +- .../ui/TechStack/ui/TechStack.stories.tsx | 14 +- src/shared/ui/TechStack/ui/TechStack.test.tsx | 84 ++++----- src/shared/ui/TechStack/ui/TechStack.tsx | 19 +-- src/shared/ui/index.ts | 22 +-- src/test/setup.ts | 2 +- src/widgets/Navigation/index.ts | 8 +- src/widgets/Navigation/model/types.ts | 8 +- .../Navigation/ui/MobileNav.stories.tsx | 12 +- src/widgets/Navigation/ui/MobileNav.test.tsx | 62 +++---- src/widgets/Navigation/ui/MobileNav.tsx | 26 +-- .../Navigation/ui/SidebarNav.stories.tsx | 12 +- src/widgets/Navigation/ui/SidebarNav.test.tsx | 70 ++++---- src/widgets/Navigation/ui/SidebarNav.tsx | 64 ++++--- .../Navigation/ui/UtilityBar.stories.tsx | 12 +- src/widgets/Navigation/ui/UtilityBar.test.tsx | 40 ++--- src/widgets/Navigation/ui/UtilityBar.tsx | 13 +- src/widgets/index.ts | 2 +- 73 files changed, 1201 insertions(+), 1153 deletions(-) delete mode 100644 .storybook/preview.ts create mode 100644 src/entities/Section/index.ts create mode 100644 src/entities/Section/model/types.ts rename src/{shared/ui/SectionAccordion/ui => entities/Section/ui/SectionAccordion}/SectionAccordion.stories.tsx (100%) rename src/{shared/ui/SectionAccordion/ui => entities/Section/ui/SectionAccordion}/SectionAccordion.test.tsx (100%) rename src/{shared/ui/SectionAccordion/ui => entities/Section/ui/SectionAccordion}/SectionAccordion.tsx (100%) create mode 100644 src/entities/Section/ui/SectionAccordion/index.ts create mode 100644 src/entities/Section/ui/index.ts delete mode 100644 src/shared/ui/SectionAccordion/index.ts diff --git a/.storybook/preview.ts b/.storybook/preview.ts deleted file mode 100644 index 133286b..0000000 --- a/.storybook/preview.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Preview } from '@storybook/nextjs-vite' -import '../app/globals.css' - -const preview: Preview = { - parameters: { - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i, - }, - }, - a11y: { - // 'todo' - show a11y violations in the test UI only - // 'error' - fail CI on a11y violations - // 'off' - skip a11y checks entirely - test: 'todo', - }, - }, -} - -export default preview diff --git a/app/layout.tsx b/app/layout.tsx index 00879ef..6ae4ca5 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,11 +1,11 @@ -import type { Metadata } from 'next' -import { fraunces, publicSans } from '$shared/lib' -import './globals.css' +import type { Metadata } from 'next'; +import { fraunces, publicSans } from '$shared/lib'; +import './globals.css'; export const metadata: Metadata = { title: 'Portfolio', description: 'Portfolio', -} +}; /** * Root layout — injects font CSS variables used by theme.css @@ -13,9 +13,7 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - - {children} - + {children} - ) + ); } diff --git a/app/page.tsx b/app/page.tsx index 3f36f7c..419da6b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,36 +1,29 @@ -import Image from "next/image"; +import Image from 'next/image'; export default function Home() { return (
- Next.js logo + Next.js logo

To get started, edit the page.tsx file.

- Looking for a starting point or more instructions? Head over to{" "} + Looking for a starting point or more instructions? Head over to{' '} Templates - {" "} - or the{" "} + {' '} + or the{' '} Learning - {" "} + {' '} center.

@@ -41,13 +34,7 @@ export default function Home() { target="_blank" rel="noopener noreferrer" > - Vercel logomark + Vercel logomark Deploy Now = { title: 'Entities/ExperienceCard', @@ -11,30 +11,28 @@ const meta: Meta = {
), ], -} +}; -export default meta +export default meta; -type Story = StoryObj +type Story = StoryObj; const baseArgs = { title: 'Senior Frontend Engineer', company: 'Acme Corp', period: '2021 – 2024', - description: 'Led frontend development for the core product, established design system practices, and mentored junior engineers across two distributed teams.', -} + description: + 'Led frontend development for the core product, established design system practices, and mentored junior engineers across two distributed teams.', +}; export const Default: Story = { args: baseArgs, -} +}; export const SlateBackground: Story = { render: () => (
- +
), -} +}; diff --git a/src/entities/experience/ui/ExperienceCard.test.tsx b/src/entities/experience/ui/ExperienceCard.test.tsx index f8257e4..fd53874 100644 --- a/src/entities/experience/ui/ExperienceCard.test.tsx +++ b/src/entities/experience/ui/ExperienceCard.test.tsx @@ -1,72 +1,72 @@ -import { describe, it, expect } from 'vitest' -import { render, screen } from '@testing-library/react' -import { ExperienceCard } from './ExperienceCard' +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ExperienceCard } from './ExperienceCard'; const DEFAULT_PROPS = { title: 'Senior Developer', company: 'Acme Corp', period: '2021 – 2024', description: 'Built scalable frontend systems.', -} +}; describe('ExperienceCard', () => { describe('rendering', () => { it('renders the job title', () => { - render() - expect(screen.getByText('Senior Developer')).toBeInTheDocument() - }) + render(); + expect(screen.getByText('Senior Developer')).toBeInTheDocument(); + }); it('renders the company name', () => { - render() - expect(screen.getByText('Acme Corp')).toBeInTheDocument() - }) + render(); + expect(screen.getByText('Acme Corp')).toBeInTheDocument(); + }); it('renders the period badge', () => { - render() - expect(screen.getByText('2021 – 2024')).toBeInTheDocument() - }) + render(); + expect(screen.getByText('2021 – 2024')).toBeInTheDocument(); + }); it('renders the description', () => { - render() - expect(screen.getByText('Built scalable frontend systems.')).toBeInTheDocument() - }) - }) + render(); + expect(screen.getByText('Built scalable frontend systems.')).toBeInTheDocument(); + }); + }); describe('structure', () => { it('title is rendered as an h4', () => { - render() - expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent('Senior Developer') - }) + render(); + expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent('Senior Developer'); + }); it('period badge has brutal-border, bg-carbon-black, text-ochre-clay, text-sm', () => { - render() - const badge = screen.getByText('2021 – 2024') - expect(badge).toHaveClass('brutal-border', 'bg-carbon-black', 'text-ochre-clay', 'text-sm') - }) + render(); + const badge = screen.getByText('2021 – 2024'); + expect(badge).toHaveClass('brutal-border', 'bg-carbon-black', 'text-ochre-clay', 'text-sm'); + }); it('company paragraph has opacity-80', () => { - render() - const company = screen.getByText('Acme Corp') - expect(company.tagName).toBe('P') - expect(company).toHaveClass('opacity-80') - }) + render(); + const company = screen.getByText('Acme Corp'); + expect(company.tagName).toBe('P'); + expect(company).toHaveClass('opacity-80'); + }); it('description paragraph has text-base and max-w-[700px]', () => { - render() - const desc = screen.getByText('Built scalable frontend systems.') - expect(desc).toHaveClass('text-base', 'max-w-[700px]') - }) + render(); + const desc = screen.getByText('Built scalable frontend systems.'); + expect(desc).toHaveClass('text-base', 'max-w-[700px]'); + }); it('card has brutal-border class (from Card component)', () => { - const { container } = render() - expect(container.firstChild).toHaveClass('brutal-border') - }) - }) + const { container } = render(); + expect(container.firstChild).toHaveClass('brutal-border'); + }); + }); describe('className passthrough', () => { it('forwards className to the card', () => { - const { container } = render() - expect(container.firstChild).toHaveClass('custom-class') - }) - }) -}) + const { container } = render(); + expect(container.firstChild).toHaveClass('custom-class'); + }); + }); +}); diff --git a/src/entities/experience/ui/ExperienceCard.tsx b/src/entities/experience/ui/ExperienceCard.tsx index b9e77df..3a26c32 100644 --- a/src/entities/experience/ui/ExperienceCard.tsx +++ b/src/entities/experience/ui/ExperienceCard.tsx @@ -1,27 +1,27 @@ -import { Card } from '$shared/ui' +import { Card } from '$shared/ui'; type Props = { /** * Job title */ - title: string + title: string; /** * Company name */ - company: string + company: string; /** * Employment period (e.g. "2021 – 2024") */ - period: string + period: string; /** * Description of responsibilities and achievements */ - description: string + description: string; /** * Additional CSS classes forwarded to the card */ - className?: string -} + className?: string; +}; /** * Work experience card with title, company, period, and description. @@ -34,11 +34,9 @@ export function ExperienceCard({ title, company, period, description, className

{title}

{company}

- - {period} - + {period}

{description}

- ) + ); } diff --git a/src/entities/index.ts b/src/entities/index.ts index 46d2f42..6f47d12 100644 --- a/src/entities/index.ts +++ b/src/entities/index.ts @@ -1,2 +1,2 @@ -export * from './project' -export * from './experience' +export * from './project'; +export * from './experience'; diff --git a/src/entities/project/index.ts b/src/entities/project/index.ts index 07d5dce..705e072 100644 --- a/src/entities/project/index.ts +++ b/src/entities/project/index.ts @@ -1,3 +1,3 @@ -export { ProjectMetadata } from './ui/ProjectMetadata' -export { ProjectCard } from './ui/ProjectCard' -export { DetailedProjectCard } from './ui/DetailedProjectCard' +export { ProjectMetadata } from './ui/ProjectMetadata'; +export { ProjectCard } from './ui/ProjectCard'; +export { DetailedProjectCard } from './ui/DetailedProjectCard'; diff --git a/src/entities/project/ui/DetailedProjectCard.stories.tsx b/src/entities/project/ui/DetailedProjectCard.stories.tsx index 4299636..40c0571 100644 --- a/src/entities/project/ui/DetailedProjectCard.stories.tsx +++ b/src/entities/project/ui/DetailedProjectCard.stories.tsx @@ -1,5 +1,5 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { DetailedProjectCard } from './DetailedProjectCard' +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { DetailedProjectCard } from './DetailedProjectCard'; const meta: Meta = { title: 'Entities/DetailedProjectCard', @@ -11,32 +11,33 @@ const meta: Meta = { ), ], -} +}; -export default meta +export default meta; -type Story = StoryObj +type Story = StoryObj; const baseArgs = { title: 'Design System', year: '2024', role: 'Lead Frontend Engineer', stack: ['React', 'TypeScript', 'Tailwind CSS', 'Storybook'], - description: 'A comprehensive design system built for a large-scale SaaS product, covering components, tokens, and documentation.', + description: + 'A comprehensive design system built for a large-scale SaaS product, covering components, tokens, and documentation.', details: [ 'Established token system covering color, spacing, and typography.', 'Built 40+ accessible components with full test coverage.', 'Integrated Storybook for visual regression testing and documentation.', ], -} +}; export const Default: Story = { args: baseArgs, -} +}; export const WithImage: Story = { args: { ...baseArgs, imageUrl: 'https://placehold.co/800x450/3B4A59/D9B48F?text=Project', }, -} +}; diff --git a/src/entities/project/ui/DetailedProjectCard.test.tsx b/src/entities/project/ui/DetailedProjectCard.test.tsx index bc3cbae..4f86de2 100644 --- a/src/entities/project/ui/DetailedProjectCard.test.tsx +++ b/src/entities/project/ui/DetailedProjectCard.test.tsx @@ -1,6 +1,6 @@ -import { describe, it, expect } from 'vitest' -import { render, screen } from '@testing-library/react' -import { DetailedProjectCard } from './DetailedProjectCard' +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { DetailedProjectCard } from './DetailedProjectCard'; const DEFAULT_PROPS = { title: 'Big Project', @@ -9,83 +9,83 @@ const DEFAULT_PROPS = { stack: ['Vue', 'Go'], description: 'A detailed project description', details: ['First detail point', 'Second detail point'], -} +}; describe('DetailedProjectCard', () => { describe('rendering', () => { it('renders the project title', () => { - render() - expect(screen.getByText('Big Project')).toBeInTheDocument() - }) + render(); + expect(screen.getByText('Big Project')).toBeInTheDocument(); + }); it('renders the description', () => { - render() - expect(screen.getByText('A detailed project description')).toBeInTheDocument() - }) + render(); + expect(screen.getByText('A detailed project description')).toBeInTheDocument(); + }); it('renders each detail item', () => { - render() - expect(screen.getByText('First detail point')).toBeInTheDocument() - expect(screen.getByText('Second detail point')).toBeInTheDocument() - }) + render(); + expect(screen.getByText('First detail point')).toBeInTheDocument(); + expect(screen.getByText('Second detail point')).toBeInTheDocument(); + }); it('renders ProjectMetadata with year, role, and stack', () => { - render() - expect(screen.getByText('2023')).toBeInTheDocument() - expect(screen.getByText('Lead Dev')).toBeInTheDocument() - expect(screen.getByText('Vue')).toBeInTheDocument() - expect(screen.getByText('Go')).toBeInTheDocument() - }) - }) + render(); + expect(screen.getByText('2023')).toBeInTheDocument(); + expect(screen.getByText('Lead Dev')).toBeInTheDocument(); + expect(screen.getByText('Vue')).toBeInTheDocument(); + expect(screen.getByText('Go')).toBeInTheDocument(); + }); + }); describe('structure', () => { it('outer grid has grid-cols-1 and lg:grid-cols-12', () => { - const { container } = render() - expect(container.firstChild).toHaveClass('grid', 'grid-cols-1', 'lg:grid-cols-12') - }) + const { container } = render(); + expect(container.firstChild).toHaveClass('grid', 'grid-cols-1', 'lg:grid-cols-12'); + }); it('title is rendered as an h3', () => { - render() - expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Big Project') - }) + render(); + expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Big Project'); + }); it('detail items are rendered as

tags with text-base', () => { - render() - const detail = screen.getByText('First detail point') - expect(detail.tagName).toBe('P') - expect(detail).toHaveClass('text-base') - }) + render(); + const detail = screen.getByText('First detail point'); + expect(detail.tagName).toBe('P'); + expect(detail).toHaveClass('text-base'); + }); it('details list has brutal-border-top and pt-6', () => { - render() - const detail = screen.getByText('First detail point') - const detailList = detail.parentElement - expect(detailList).toHaveClass('brutal-border-top', 'pt-6') - }) + render(); + const detail = screen.getByText('First detail point'); + const detailList = detail.parentElement; + expect(detailList).toHaveClass('brutal-border-top', 'pt-6'); + }); it('description has text-lg and mb-6', () => { - render() - const desc = screen.getByText('A detailed project description') - expect(desc).toHaveClass('text-lg', 'mb-6') - }) - }) + render(); + const desc = screen.getByText('A detailed project description'); + expect(desc).toHaveClass('text-lg', 'mb-6'); + }); + }); describe('conditional image rendering', () => { it('does not render image when imageUrl is absent', () => { - const { container } = render() - expect(container.querySelector('img')).toBeNull() - }) + const { container } = render(); + expect(container.querySelector('img')).toBeNull(); + }); it('renders image when imageUrl is provided', () => { - render() - const img = screen.getByRole('img') - expect(img).toHaveAttribute('src', '/detail.jpg') - }) + render(); + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('src', '/detail.jpg'); + }); it('image wrapper has aspect-video and brutal-border when imageUrl is provided', () => { - const { container } = render() - const imgWrapper = container.querySelector('img')!.parentElement - expect(imgWrapper).toHaveClass('aspect-video', 'brutal-border') - }) - }) -}) + const { container } = render(); + const imgWrapper = container.querySelector('img')!.parentElement; + expect(imgWrapper).toHaveClass('aspect-video', 'brutal-border'); + }); + }); +}); diff --git a/src/entities/project/ui/DetailedProjectCard.tsx b/src/entities/project/ui/DetailedProjectCard.tsx index 53a2663..25c0ed6 100644 --- a/src/entities/project/ui/DetailedProjectCard.tsx +++ b/src/entities/project/ui/DetailedProjectCard.tsx @@ -1,54 +1,46 @@ -import { Card } from '$shared/ui' -import { ProjectMetadata } from './ProjectMetadata' +import { Card } from '$shared/ui'; +import { ProjectMetadata } from './ProjectMetadata'; type Props = { /** * Project name */ - title: string + title: string; /** * Year the project was completed */ - year: string + year: string; /** * Developer role on the project */ - role: string + role: string; /** * Technology stack list */ - stack: string[] + stack: string[]; /** * Project description paragraph */ - description: string + description: string; /** * Bullet-style detail points listed below the description */ - details: string[] + details: string[]; /** * Optional hero image URL */ - imageUrl?: string + imageUrl?: string; /** * Reverse layout (reserved for future use) * @default false */ - reverse?: boolean -} + reverse?: boolean; +}; /** * Full-width detailed project card with metadata sidebar. */ -export function DetailedProjectCard({ - title, - year, - role, - stack, - description, - details, - imageUrl, -}: Props) { +export function DetailedProjectCard({ title, year, role, stack, description, details, imageUrl }: Props) { return (

@@ -68,11 +60,13 @@ export function DetailedProjectCard({
{details.map((detail, index) => ( -

{detail}

+

+ {detail} +

))}
- ) + ); } diff --git a/src/entities/project/ui/ProjectCard.stories.tsx b/src/entities/project/ui/ProjectCard.stories.tsx index f8c3f76..398451f 100644 --- a/src/entities/project/ui/ProjectCard.stories.tsx +++ b/src/entities/project/ui/ProjectCard.stories.tsx @@ -1,5 +1,5 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { ProjectCard } from './ProjectCard' +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { ProjectCard } from './ProjectCard'; const meta: Meta = { title: 'Entities/ProjectCard', @@ -11,11 +11,11 @@ const meta: Meta = { ), ], -} +}; -export default meta +export default meta; -type Story = StoryObj +type Story = StoryObj; export const Default: Story = { args: { @@ -24,7 +24,7 @@ export const Default: Story = { description: 'A brutalist portfolio site built with Next.js and Tailwind CSS.', tags: ['React', 'TypeScript', 'Next.js'], }, -} +}; export const WithImage: Story = { args: { @@ -34,4 +34,4 @@ export const WithImage: Story = { tags: ['React', 'TypeScript', 'Next.js'], imageUrl: 'https://placehold.co/800x450/3B4A59/D9B48F?text=Project', }, -} +}; diff --git a/src/entities/project/ui/ProjectCard.test.tsx b/src/entities/project/ui/ProjectCard.test.tsx index 42d579a..d757241 100644 --- a/src/entities/project/ui/ProjectCard.test.tsx +++ b/src/entities/project/ui/ProjectCard.test.tsx @@ -1,79 +1,86 @@ -import { describe, it, expect } from 'vitest' -import { render, screen } from '@testing-library/react' -import { ProjectCard } from './ProjectCard' +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ProjectCard } from './ProjectCard'; const DEFAULT_PROPS = { title: 'My Project', year: '2024', description: 'A cool project description', tags: ['React', 'Node'], -} +}; describe('ProjectCard', () => { describe('rendering', () => { it('renders the project title', () => { - render() - expect(screen.getByText('My Project')).toBeInTheDocument() - }) + render(); + expect(screen.getByText('My Project')).toBeInTheDocument(); + }); it('renders the year badge', () => { - render() - expect(screen.getByText('2024')).toBeInTheDocument() - }) + render(); + expect(screen.getByText('2024')).toBeInTheDocument(); + }); it('renders the description', () => { - render() - expect(screen.getByText('A cool project description')).toBeInTheDocument() - }) + render(); + expect(screen.getByText('A cool project description')).toBeInTheDocument(); + }); it('renders each tag', () => { - render() - expect(screen.getByText('React')).toBeInTheDocument() - expect(screen.getByText('Node')).toBeInTheDocument() - }) + render(); + expect(screen.getByText('React')).toBeInTheDocument(); + expect(screen.getByText('Node')).toBeInTheDocument(); + }); it('renders the View Project button', () => { - render() - expect(screen.getByRole('button', { name: /view project/i })).toBeInTheDocument() - }) - }) + render(); + expect(screen.getByRole('button', { name: /view project/i })).toBeInTheDocument(); + }); + }); describe('structure', () => { it('card has hover transition classes', () => { - const { container } = render() - const card = container.firstChild as HTMLElement - expect(card).toHaveClass('group', 'transition-all', 'duration-300') - }) + const { container } = render(); + const card = container.firstChild as HTMLElement; + expect(card).toHaveClass('group', 'transition-all', 'duration-300'); + }); it('year badge has correct classes', () => { - render() - const yearBadge = screen.getByText('2024') - expect(yearBadge).toHaveClass('brutal-border', 'bg-carbon-black', 'text-ochre-clay', 'text-sm') - }) + render(); + const yearBadge = screen.getByText('2024'); + expect(yearBadge).toHaveClass('brutal-border', 'bg-carbon-black', 'text-ochre-clay', 'text-sm'); + }); it('tags have correct classes', () => { - render() - const tag = screen.getByText('React') - expect(tag).toHaveClass('brutal-border', 'bg-white', 'text-carbon-black', 'text-sm', 'uppercase', 'tracking-wide') - }) - }) + render(); + const tag = screen.getByText('React'); + expect(tag).toHaveClass( + 'brutal-border', + 'bg-white', + 'text-carbon-black', + 'text-sm', + 'uppercase', + 'tracking-wide', + ); + }); + }); describe('conditional image rendering', () => { it('does not render image when imageUrl is absent', () => { - const { container } = render() - expect(container.querySelector('img')).toBeNull() - }) + const { container } = render(); + expect(container.querySelector('img')).toBeNull(); + }); it('renders image when imageUrl is provided', () => { - render() - const img = screen.getByRole('img') - expect(img).toHaveAttribute('src', '/project.jpg') - }) + render(); + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('src', '/project.jpg'); + }); it('image wrapper has aspect-video and overflow-hidden when imageUrl is provided', () => { - const { container } = render() - const imgWrapper = container.querySelector('img')!.parentElement - expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border') - }) - }) -}) + const { container } = render(); + const imgWrapper = container.querySelector('img')!.parentElement; + expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border'); + }); + }); +}); diff --git a/src/entities/project/ui/ProjectCard.tsx b/src/entities/project/ui/ProjectCard.tsx index c718b0f..a41b588 100644 --- a/src/entities/project/ui/ProjectCard.tsx +++ b/src/entities/project/ui/ProjectCard.tsx @@ -1,28 +1,28 @@ -import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, Button } from '$shared/ui' -import { cn } from '$shared/lib' +import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, Button } from '$shared/ui'; +import { cn } from '$shared/lib'; type Props = { /** * Project name */ - title: string + title: string; /** * Year the project was completed */ - year: string + year: string; /** * Short project description */ - description: string + description: string; /** * Technology or category tags */ - tags: string[] + tags: string[]; /** * Optional preview image URL */ - imageUrl?: string -} + imageUrl?: string; +}; /** * Compact project card for grid/list display. @@ -61,8 +61,10 @@ export function ProjectCard({ title, year, description, tags, imageUrl }: Props) - + - ) + ); } diff --git a/src/entities/project/ui/ProjectMetadata.stories.tsx b/src/entities/project/ui/ProjectMetadata.stories.tsx index 2773220..0850c18 100644 --- a/src/entities/project/ui/ProjectMetadata.stories.tsx +++ b/src/entities/project/ui/ProjectMetadata.stories.tsx @@ -1,5 +1,5 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { ProjectMetadata } from './ProjectMetadata' +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { ProjectMetadata } from './ProjectMetadata'; const meta: Meta = { title: 'Entities/ProjectMetadata', @@ -11,11 +11,11 @@ const meta: Meta = { ), ], -} +}; -export default meta +export default meta; -type Story = StoryObj +type Story = StoryObj; export const Default: Story = { args: { @@ -23,4 +23,4 @@ export const Default: Story = { role: 'Lead Frontend Engineer', stack: ['React', 'TypeScript', 'Next.js', 'Tailwind'], }, -} +}; diff --git a/src/entities/project/ui/ProjectMetadata.test.tsx b/src/entities/project/ui/ProjectMetadata.test.tsx index 2efc055..3f4161f 100644 --- a/src/entities/project/ui/ProjectMetadata.test.tsx +++ b/src/entities/project/ui/ProjectMetadata.test.tsx @@ -1,96 +1,96 @@ -import { describe, it, expect } from 'vitest' -import { render, screen } from '@testing-library/react' -import { ProjectMetadata } from './ProjectMetadata' +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ProjectMetadata } from './ProjectMetadata'; const DEFAULT_PROPS = { year: '2024', role: 'Frontend Engineer', stack: ['React', 'TypeScript', 'Tailwind'], -} +}; describe('ProjectMetadata', () => { describe('rendering', () => { it('renders the year value', () => { - render() - expect(screen.getByText('2024')).toBeInTheDocument() - }) + render(); + expect(screen.getByText('2024')).toBeInTheDocument(); + }); it('renders the YEAR label', () => { - render() - expect(screen.getByText('YEAR')).toBeInTheDocument() - }) + render(); + expect(screen.getByText('YEAR')).toBeInTheDocument(); + }); it('renders the role value', () => { - render() - expect(screen.getByText('Frontend Engineer')).toBeInTheDocument() - }) + render(); + expect(screen.getByText('Frontend Engineer')).toBeInTheDocument(); + }); it('renders the ROLE label', () => { - render() - expect(screen.getByText('ROLE')).toBeInTheDocument() - }) + render(); + expect(screen.getByText('ROLE')).toBeInTheDocument(); + }); it('renders the STACK label', () => { - render() - expect(screen.getByText('STACK')).toBeInTheDocument() - }) + render(); + expect(screen.getByText('STACK')).toBeInTheDocument(); + }); it('renders each stack technology', () => { - render() - expect(screen.getByText('React')).toBeInTheDocument() - expect(screen.getByText('TypeScript')).toBeInTheDocument() - expect(screen.getByText('Tailwind')).toBeInTheDocument() - }) - }) + render(); + expect(screen.getByText('React')).toBeInTheDocument(); + expect(screen.getByText('TypeScript')).toBeInTheDocument(); + expect(screen.getByText('Tailwind')).toBeInTheDocument(); + }); + }); describe('structure', () => { it('outer div has space-y-6', () => { - const { container } = render() - expect(container.firstChild).toHaveClass('space-y-6') - }) + const { container } = render(); + expect(container.firstChild).toHaveClass('space-y-6'); + }); it('year section has no brutal-border-top (first section)', () => { - const { container } = render() - const sections = container.firstChild!.childNodes - expect(sections[0]).not.toHaveClass('brutal-border-top') - }) + const { container } = render(); + const sections = container.firstChild!.childNodes; + expect(sections[0]).not.toHaveClass('brutal-border-top'); + }); it('role section has brutal-border-top and pt-6', () => { - const { container } = render() - const sections = container.firstChild!.childNodes - expect(sections[1]).toHaveClass('brutal-border-top', 'pt-6') - }) + const { container } = render(); + const sections = container.firstChild!.childNodes; + expect(sections[1]).toHaveClass('brutal-border-top', 'pt-6'); + }); it('stack section has brutal-border-top and pt-6', () => { - const { container } = render() - const sections = container.firstChild!.childNodes - expect(sections[2]).toHaveClass('brutal-border-top', 'pt-6') - }) + const { container } = render(); + const sections = container.firstChild!.childNodes; + expect(sections[2]).toHaveClass('brutal-border-top', 'pt-6'); + }); it('label has text-xs uppercase tracking-wider opacity-60', () => { - render() - const yearLabel = screen.getByText('YEAR') - expect(yearLabel).toHaveClass('text-xs', 'uppercase', 'tracking-wider', 'opacity-60') - }) + render(); + const yearLabel = screen.getByText('YEAR'); + expect(yearLabel).toHaveClass('text-xs', 'uppercase', 'tracking-wider', 'opacity-60'); + }); it('year value has text-base font-bold', () => { - render() - const yearValue = screen.getByText('2024') - expect(yearValue).toHaveClass('text-base', 'font-bold') - }) + render(); + const yearValue = screen.getByText('2024'); + expect(yearValue).toHaveClass('text-base', 'font-bold'); + }); it('each stack tech is rendered as a

with text-sm', () => { - render() - const techEl = screen.getByText('React') - expect(techEl.tagName).toBe('P') - expect(techEl).toHaveClass('text-sm') - }) - }) + render(); + const techEl = screen.getByText('React'); + expect(techEl.tagName).toBe('P'); + expect(techEl).toHaveClass('text-sm'); + }); + }); describe('className passthrough', () => { it('merges custom className onto outer div', () => { - const { container } = render() - expect(container.firstChild).toHaveClass('my-custom') - }) - }) -}) + const { container } = render(); + expect(container.firstChild).toHaveClass('my-custom'); + }); + }); +}); diff --git a/src/entities/project/ui/ProjectMetadata.tsx b/src/entities/project/ui/ProjectMetadata.tsx index f1b73f5..9722b83 100644 --- a/src/entities/project/ui/ProjectMetadata.tsx +++ b/src/entities/project/ui/ProjectMetadata.tsx @@ -1,23 +1,23 @@ -import { cn } from '$shared/lib' +import { cn } from '$shared/lib'; type Props = { /** * Project year */ - year: string + year: string; /** * Developer role on the project */ - role: string + role: string; /** * Technology stack list */ - stack: string[] + stack: string[]; /** * Additional CSS classes */ - className?: string -} + className?: string; +}; /** * Sidebar metadata display for a project: year, role, and stack. @@ -36,9 +36,11 @@ export function ProjectMetadata({ year, role, stack, className }: Props) {

STACK

{stack.map((tech) => ( -

{tech}

+

+ {tech} +

))}
- ) + ); } diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts index 1efafbb..b1f988a 100644 --- a/src/shared/api/client.ts +++ b/src/shared/api/client.ts @@ -1,9 +1,9 @@ -import type { ListResponse } from './types' +import type { ListResponse } from './types'; /** * Native fetch wrapper for PocketBase API requests. */ -const PB_URL = process.env.NEXT_PUBLIC_PB_URL || 'http://127.0.0.1:8090' +const PB_URL = process.env.NEXT_PUBLIC_PB_URL || 'http://127.0.0.1:8090'; /** * Options for PocketBase collection fetching. @@ -12,61 +12,55 @@ export type PBFetchOptions = { /** * Sorting criteria (e.g., "-created,order") */ - sort?: string + sort?: string; /** * Filter query string */ - filter?: string + filter?: string; /** * Fields to expand (e.g., "stack") */ - expand?: string + expand?: string; /** * Cache revalidation time in seconds * @default 3600 */ - revalidate?: number -} + revalidate?: number; +}; /** * Fetch a list of records from a PocketBase collection. */ -export async function getCollection( - collection: string, - options: PBFetchOptions = {} -): Promise> { - const { sort, filter, expand, revalidate = 3600 } = options +export async function getCollection(collection: string, options: PBFetchOptions = {}): Promise> { + const { sort, filter, expand, revalidate = 3600 } = options; - const params = new URLSearchParams() - if (sort) params.set('sort', sort) - if (filter) params.set('filter', filter) - if (expand) params.set('expand', expand) + const params = new URLSearchParams(); + if (sort) params.set('sort', sort); + if (filter) params.set('filter', filter); + if (expand) params.set('expand', expand); - const url = `${PB_URL}/api/collections/${collection}/records?${params.toString()}` + const url = `${PB_URL}/api/collections/${collection}/records?${params.toString()}`; const res = await fetch(url, { next: { revalidate }, - }) + }); if (!res.ok) { - throw new Error(`Failed to fetch collection: ${collection}`) + throw new Error(`Failed to fetch collection: ${collection}`); } - return res.json() + return res.json(); } /** * Fetch a single record from a PocketBase collection by ID or filter. */ -export async function getFirstRecord( - collection: string, - options: PBFetchOptions = {} -): Promise { +export async function getFirstRecord(collection: string, options: PBFetchOptions = {}): Promise { const data = await getCollection(collection, { ...options, // PocketBase convention for "first" or "singleton" patterns filter: options.filter, - }) + }); - return data.items[0] || null + return data.items[0] || null; } diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index 886d07b..70f3c79 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -1,2 +1,2 @@ -export * from './types' -export * from './client' +export * from './types'; +export * from './client'; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index 204fb2d..a640d9b 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -5,60 +5,39 @@ export type BaseRecord = { /** * Unique record ID */ - id: string + id: string; /** * ID of the collection this record belongs to */ - collectionId: string + collectionId: string; /** * Name of the collection this record belongs to */ - collectionName: string + collectionName: string; /** * Record creation timestamp (ISO 8601) */ - created: string + created: string; /** * Record last update timestamp (ISO 8601) */ - updated: string -} + updated: string; + }; -/** - * PocketBase collection for site sections and routing. - */ -export type SectionRecord = BaseRecord & { /** - * URL-friendly identifier used for routing + * PocketBase collection for simple text blocks (Intro, Bio). */ - slug: string - /** - * Display name of the section - */ - title: string - /** - * Visual numbering prefix (e.g., "01") - */ - number: string - /** - * Sorting weight for section order - */ - order: number -} + export type PageContentRecord = BaseRecord & { -/** - * PocketBase collection for simple text blocks (Intro, Bio). - */ -export type PageContentRecord = BaseRecord & { /** * Slug corresponding to the parent section */ - slug: string + slug: string; /** * HTML or Markdown content string */ - content: string -} + content: string; +}; /** * PocketBase collection for technology skills. @@ -67,16 +46,16 @@ export type SkillRecord = BaseRecord & { /** * Name of the technology or tool */ - name: string + name: string; /** * Grouping category (e.g., 'Frontend', 'Backend') */ - category: 'Frontend' | 'Backend' | 'Tools' | 'Design' | string + category: 'Frontend' | 'Backend' | 'Tools' | 'Design' | string; /** * Sorting weight within the category */ - order: number -} + order: number; +}; /** * PocketBase collection for work experience history. @@ -85,28 +64,28 @@ export type ExperienceRecord = BaseRecord & { /** * Name of the organization */ - company: string + company: string; /** * Professional title held */ - role: string + role: string; /** * Start date of the tenure */ - start_date: string + start_date: string; /** * End date of the tenure, or null if currently employed */ - end_date: string | null + end_date: string | null; /** * Rich text description of responsibilities and achievements */ - description: string + description: string; /** * Sorting weight for chronological display */ - order: number -} + order: number; +}; /** * PocketBase collection for portfolio projects. @@ -115,36 +94,36 @@ export type ProjectRecord = BaseRecord & { /** * Full title of the project */ - title: string + title: string; /** * Completion or duration year (e.g., "2024") */ - year: string + year: string; /** * Role performed on the project */ - role: string + role: string; /** * Short summary of the project */ - description: string + description: string; /** * List of specific feature or achievement points */ - details: string[] + details: string[]; /** * List of SkillRecord IDs used in the project */ - stack: string[] + stack: string[]; /** * Primary thumbnail or hero image filename */ - image: string + image: string; /** * Sorting weight for the project list */ - order: number -} + order: number; +}; /** * Generic response for a list of PocketBase records. @@ -153,21 +132,21 @@ export type ListResponse = { /** * Current page index */ - page: number + page: number; /** * Number of items per page */ - perPage: number + perPage: number; /** * Total number of items across all pages */ - totalItems: number + totalItems: number; /** * Total number of pages available */ - totalPages: number + totalPages: number; /** * Array of records for the current page */ - items: T[] -} + items: T[]; +}; diff --git a/src/shared/index.ts b/src/shared/index.ts index f715fb8..bdcbcdb 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -1,3 +1,3 @@ -export * from './ui' -export * from './lib' -export * from './api' +export * from './ui'; +export * from './lib'; +export * from './api'; diff --git a/src/shared/lib/cn.test.ts b/src/shared/lib/cn.test.ts index 1615ec5..d6163cf 100644 --- a/src/shared/lib/cn.test.ts +++ b/src/shared/lib/cn.test.ts @@ -1,40 +1,40 @@ -import { describe, it, expect } from 'vitest' -import { cn } from './cn' +import { describe, it, expect } from 'vitest'; +import { cn } from './cn'; describe('cn', () => { describe('basic merging', () => { it('returns single class unchanged', () => { - expect(cn('foo')).toBe('foo') - }) + expect(cn('foo')).toBe('foo'); + }); it('joins multiple classes', () => { - expect(cn('foo', 'bar')).toBe('foo bar') - }) - }) + expect(cn('foo', 'bar')).toBe('foo bar'); + }); + }); describe('conditional classes', () => { it('includes truthy conditional', () => { - expect(cn('foo', true && 'bar')).toBe('foo bar') - }) + expect(cn('foo', true && 'bar')).toBe('foo bar'); + }); it('excludes falsy conditional', () => { - expect(cn('foo', false && 'bar')).toBe('foo') - }) - }) + expect(cn('foo', false && 'bar')).toBe('foo'); + }); + }); describe('object syntax', () => { it('includes classes with truthy object values', () => { - expect(cn({ foo: true, bar: false })).toBe('foo') - }) - }) + expect(cn({ foo: true, bar: false })).toBe('foo'); + }); + }); describe('tailwind conflict resolution', () => { it('last padding wins', () => { - expect(cn('px-2', 'px-4')).toBe('px-4') - }) + expect(cn('px-2', 'px-4')).toBe('px-4'); + }); it('last text color wins', () => { - expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500') - }) - }) -}) + expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500'); + }); + }); +}); diff --git a/src/shared/lib/cn.ts b/src/shared/lib/cn.ts index 269848b..e5aba12 100644 --- a/src/shared/lib/cn.ts +++ b/src/shared/lib/cn.ts @@ -1,9 +1,9 @@ -import { clsx, type ClassValue } from 'clsx' -import { twMerge } from 'tailwind-merge' +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; /** * Merges Tailwind classes, resolving conflicts in favor of the last value. */ export function cn(...inputs: ClassValue[]): string { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); } diff --git a/src/shared/lib/fonts.ts b/src/shared/lib/fonts.ts index c73f10f..63d193c 100644 --- a/src/shared/lib/fonts.ts +++ b/src/shared/lib/fonts.ts @@ -1,4 +1,4 @@ -import { Fraunces, Public_Sans } from 'next/font/google' +import { Fraunces, Public_Sans } from 'next/font/google'; /** * Heading font — variable axes for brutalist variation settings @@ -7,7 +7,7 @@ export const fraunces = Fraunces({ subsets: ['latin'], variable: '--font-fraunces', axes: ['opsz', 'SOFT', 'WONK'], -}) +}); /** * Body font @@ -15,4 +15,4 @@ export const fraunces = Fraunces({ export const publicSans = Public_Sans({ subsets: ['latin'], variable: '--font-public-sans', -}) +}); diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index 59e9088..a914abd 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -1,3 +1,3 @@ -export { cn } from './cn' -export type { ClassValue } from 'clsx' -export * from './fonts' +export { cn } from './cn'; +export type { ClassValue } from 'clsx'; +export * from './fonts'; diff --git a/src/shared/styles/theme.css b/src/shared/styles/theme.css index b3ce4ba..beeb111 100644 --- a/src/shared/styles/theme.css +++ b/src/shared/styles/theme.css @@ -2,7 +2,7 @@ /* === TYPOGRAPHY SCALE (Augmented Fourth 1.414) === */ --font-size: 16px; --text-xs: 0.707rem; - --text-sm: 0.840rem; + --text-sm: 0.84rem; --text-base: 1rem; --text-lg: 1.414rem; --text-xl: 2rem; @@ -29,9 +29,9 @@ --fraunces-soft: 0; /* === COLOR PALETTE === */ - --ochre-clay: #D9B48F; - --slate-indigo: #3B4A59; - --burnt-oxide: #A64B35; + --ochre-clay: #d9b48f; + --slate-indigo: #3b4a59; + --burnt-oxide: #a64b35; --carbon-black: #121212; /* === SEMANTIC COLORS === */ @@ -126,31 +126,48 @@ /* Paper grain texture */ body::before { - content: ''; + content: ""; position: fixed; inset: 0; background-image: - repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.02) 2px, rgba(0,0,0,0.02) 4px), - repeating-linear-gradient(90deg, transparent, transparent 2px, rgba(0,0,0,0.02) 2px, rgba(0,0,0,0.02) 4px); + repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0, 0, 0, 0.02) 2px, rgba(0, 0, 0, 0.02) 4px), + repeating-linear-gradient(90deg, transparent, transparent 2px, rgba(0, 0, 0, 0.02) 2px, rgba(0, 0, 0, 0.02) 4px); opacity: 0.4; display: block; pointer-events: none; z-index: 1; } - h1, h2, h3, h4, h5, h6 { + h1, + h2, + h3, + h4, + h5, + h6 { font-family: var(--font-heading); font-weight: var(--font-weight-heading); line-height: var(--line-height-tight); - font-variation-settings: 'WONK' var(--fraunces-wonk), 'SOFT' var(--fraunces-soft); + font-variation-settings: + "WONK" var(--fraunces-wonk), + "SOFT" var(--fraunces-soft); color: var(--carbon-black); } - h1 { font-size: var(--text-4xl); } - h2 { font-size: var(--text-3xl); } - h3 { font-size: var(--text-2xl); } - h4 { font-size: var(--text-xl); } - h5 { font-size: var(--text-lg); } + h1 { + font-size: var(--text-4xl); + } + h2 { + font-size: var(--text-3xl); + } + h3 { + font-size: var(--text-2xl); + } + h4 { + font-size: var(--text-xl); + } + h5 { + font-size: var(--text-lg); + } p { font-family: var(--font-body); @@ -180,18 +197,42 @@ } /* Brutalist utility classes */ -.brutal-shadow { box-shadow: var(--shadow-brutal); } -.brutal-shadow-sm { box-shadow: var(--shadow-brutal-sm); } -.brutal-shadow-lg { box-shadow: var(--shadow-brutal-lg); } -.brutal-border { border: var(--border-width) solid var(--carbon-black); } -.brutal-border-top { border-top: var(--border-width) solid var(--carbon-black); } -.brutal-border-bottom { border-bottom: var(--border-width) solid var(--carbon-black); } -.brutal-border-left { border-left: var(--border-width) solid var(--carbon-black); } -.brutal-border-right { border-right: var(--border-width) solid var(--carbon-black); } +.brutal-shadow { + box-shadow: var(--shadow-brutal); +} +.brutal-shadow-sm { + box-shadow: var(--shadow-brutal-sm); +} +.brutal-shadow-lg { + box-shadow: var(--shadow-brutal-lg); +} +.brutal-border { + border: var(--border-width) solid var(--carbon-black); +} +.brutal-border-top { + border-top: var(--border-width) solid var(--carbon-black); +} +.brutal-border-bottom { + border-bottom: var(--border-width) solid var(--carbon-black); +} +.brutal-border-left { + border-left: var(--border-width) solid var(--carbon-black); +} +.brutal-border-right { + border-right: var(--border-width) solid var(--carbon-black); +} /* Animations */ @keyframes fadeIn { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +.animate-fadeIn { + animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1); } -.animate-fadeIn { animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1); } diff --git a/src/shared/ui/Badge/index.ts b/src/shared/ui/Badge/index.ts index 5c05677..fc21fb9 100644 --- a/src/shared/ui/Badge/index.ts +++ b/src/shared/ui/Badge/index.ts @@ -1,2 +1,2 @@ -export { Badge } from './ui/Badge' -export type { BadgeVariant } from './ui/Badge' +export { Badge } from './ui/Badge'; +export type { BadgeVariant } from './ui/Badge'; diff --git a/src/shared/ui/Badge/ui/Badge.stories.tsx b/src/shared/ui/Badge/ui/Badge.stories.tsx index e3150bc..392926e 100644 --- a/src/shared/ui/Badge/ui/Badge.stories.tsx +++ b/src/shared/ui/Badge/ui/Badge.stories.tsx @@ -1,14 +1,14 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { Badge } from './Badge' +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { Badge } from './Badge'; const meta: Meta = { title: 'Shared/Badge', component: Badge, -} +}; -export default meta +export default meta; -type Story = StoryObj +type Story = StoryObj; export const AllVariants: Story = { render: () => ( @@ -19,4 +19,4 @@ export const AllVariants: Story = { Outline ), -} +}; diff --git a/src/shared/ui/Badge/ui/Badge.test.tsx b/src/shared/ui/Badge/ui/Badge.test.tsx index 67f130c..1dda3cd 100644 --- a/src/shared/ui/Badge/ui/Badge.test.tsx +++ b/src/shared/ui/Badge/ui/Badge.test.tsx @@ -1,52 +1,52 @@ -import { describe, it, expect } from 'vitest' -import { render, screen } from '@testing-library/react' -import { Badge } from './Badge' +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { Badge } from './Badge'; describe('Badge', () => { describe('rendering', () => { it('renders children', () => { - render(React) - expect(screen.getByText('React')).toBeInTheDocument() - }) + render(React); + expect(screen.getByText('React')).toBeInTheDocument(); + }); it('renders as inline span', () => { - render(Tag) - expect(screen.getByText('Tag').tagName).toBe('SPAN') - }) - }) + render(Tag); + expect(screen.getByText('Tag').tagName).toBe('SPAN'); + }); + }); describe('variants', () => { it('applies default variant classes', () => { - render(Tag) - const el = screen.getByText('Tag') - expect(el).toHaveClass('bg-carbon-black', 'text-ochre-clay') - }) + render(Tag); + const el = screen.getByText('Tag'); + expect(el).toHaveClass('bg-carbon-black', 'text-ochre-clay'); + }); it('applies primary variant classes', () => { - render(Tag) - expect(screen.getByText('Tag')).toHaveClass('bg-burnt-oxide') - }) + render(Tag); + expect(screen.getByText('Tag')).toHaveClass('bg-burnt-oxide'); + }); it('applies secondary variant classes', () => { - render(Tag) - expect(screen.getByText('Tag')).toHaveClass('bg-slate-indigo') - }) + render(Tag); + expect(screen.getByText('Tag')).toHaveClass('bg-slate-indigo'); + }); it('applies outline variant classes', () => { - render(Tag) - expect(screen.getByText('Tag')).toHaveClass('bg-transparent') - }) + render(Tag); + expect(screen.getByText('Tag')).toHaveClass('bg-transparent'); + }); it('defaults to default variant when unspecified', () => { - render(Tag) - expect(screen.getByText('Tag')).toHaveClass('bg-carbon-black') - }) - }) + render(Tag); + expect(screen.getByText('Tag')).toHaveClass('bg-carbon-black'); + }); + }); describe('className passthrough', () => { it('merges custom className', () => { - render(Tag) - expect(screen.getByText('Tag')).toHaveClass('mt-4') - }) - }) -}) + render(Tag); + expect(screen.getByText('Tag')).toHaveClass('mt-4'); + }); + }); +}); diff --git a/src/shared/ui/Badge/ui/Badge.tsx b/src/shared/ui/Badge/ui/Badge.tsx index 4f5bbd5..5e8e037 100644 --- a/src/shared/ui/Badge/ui/Badge.tsx +++ b/src/shared/ui/Badge/ui/Badge.tsx @@ -1,30 +1,30 @@ -import type { ReactNode } from 'react' -import { cn } from '$shared/lib' +import type { ReactNode } from 'react'; +import { cn } from '$shared/lib'; -export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'outline' +export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'outline'; interface Props { /** * Badge content */ - children: ReactNode + children: ReactNode; /** * Visual variant * @default 'default' */ - variant?: BadgeVariant + variant?: BadgeVariant; /** * Additional CSS classes */ - className?: string + className?: string; } const VARIANTS: Record = { - default: 'brutal-border bg-carbon-black text-ochre-clay', - primary: 'brutal-border bg-burnt-oxide text-ochre-clay', + default: 'brutal-border bg-carbon-black text-ochre-clay', + primary: 'brutal-border bg-burnt-oxide text-ochre-clay', secondary: 'brutal-border bg-slate-indigo text-ochre-clay', - outline: 'brutal-border bg-transparent text-carbon-black', -} + outline: 'brutal-border bg-transparent text-carbon-black', +}; /** * Small label for categorization or status. @@ -34,5 +34,5 @@ export function Badge({ children, variant = 'default', className }: Props) { {children} - ) + ); } diff --git a/src/shared/ui/Button/index.ts b/src/shared/ui/Button/index.ts index ee20067..5ba8d18 100644 --- a/src/shared/ui/Button/index.ts +++ b/src/shared/ui/Button/index.ts @@ -1,2 +1,2 @@ -export { Button } from './ui/Button' -export type { ButtonVariant, ButtonSize } from './ui/Button' +export { Button } from './ui/Button'; +export type { ButtonVariant, ButtonSize } from './ui/Button'; diff --git a/src/shared/ui/Button/ui/Button.stories.tsx b/src/shared/ui/Button/ui/Button.stories.tsx index 702c81c..26225f2 100644 --- a/src/shared/ui/Button/ui/Button.stories.tsx +++ b/src/shared/ui/Button/ui/Button.stories.tsx @@ -1,35 +1,49 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { Button } from './Button' +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { Button } from './Button'; const meta: Meta = { title: 'Shared/Button', component: Button, -} +}; -export default meta +export default meta; -type Story = StoryObj +type Story = StoryObj; export const AllVariants: Story = { render: () => (
- - - - + + + +
), -} +}; 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..ffa2d81 100644 --- a/src/shared/ui/Button/ui/Button.test.tsx +++ b/src/shared/ui/Button/ui/Button.test.tsx @@ -1,67 +1,67 @@ -import { describe, it, expect, vi } from 'vitest' -import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { Button } from './Button' +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Button } from './Button'; describe('Button', () => { describe('rendering', () => { it('renders children', () => { - render() - 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-burnt-oxide'); + }); it('applies secondary variant', () => { - render() - expect(screen.getByRole('button')).toHaveClass('bg-slate-indigo') - }) + render(); + expect(screen.getByRole('button')).toHaveClass('bg-slate-indigo'); + }); 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-ochre-clay'); + }); + }); 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'); + }); + }); +}); diff --git a/src/shared/ui/Button/ui/Button.tsx b/src/shared/ui/Button/ui/Button.tsx index c1f256c..2fcabae 100644 --- a/src/shared/ui/Button/ui/Button.tsx +++ b/src/shared/ui/Button/ui/Button.tsx @@ -1,40 +1,41 @@ -import type { ButtonHTMLAttributes, ReactNode } from 'react' -import { cn } from '$shared/lib' +import type { ButtonHTMLAttributes, ReactNode } from 'react'; +import { cn } from '$shared/lib'; -export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' -export type ButtonSize = 'sm' | 'md' | 'lg' +export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost'; +export type ButtonSize = 'sm' | 'md' | 'lg'; interface Props extends ButtonHTMLAttributes { /** * Visual variant * @default 'primary' */ - variant?: ButtonVariant + variant?: ButtonVariant; /** * Size preset * @default 'md' */ - size?: ButtonSize + size?: ButtonSize; /** * Button content */ - children: ReactNode + children: ReactNode; } const VARIANTS: Record = { - primary: 'bg-burnt-oxide text-ochre-clay', + primary: 'bg-burnt-oxide text-ochre-clay', secondary: 'bg-slate-indigo text-ochre-clay', - outline: 'bg-transparent text-carbon-black border-carbon-black', - ghost: 'bg-ochre-clay text-carbon-black border-carbon-black', -} + outline: 'bg-transparent text-carbon-black border-carbon-black', + ghost: 'bg-ochre-clay text-carbon-black border-carbon-black', +}; const SIZES: Record = { sm: 'px-4 py-2 text-sm', md: 'px-6 py-3 text-base', lg: 'px-8 py-4 text-lg', -} +}; -const BASE = 'brutal-border brutal-shadow transition-all duration-200 hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[6px_6px_0_var(--carbon-black)] active:translate-x-[4px] active:translate-y-[4px] active:shadow-[4px_4px_0_var(--carbon-black)] uppercase tracking-wider' +const BASE = + 'brutal-border brutal-shadow transition-all duration-200 hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[6px_6px_0_var(--carbon-black)] active:translate-x-[4px] active:translate-y-[4px] active:shadow-[4px_4px_0_var(--carbon-black)] uppercase tracking-wider'; /** * Brutalist button with variants and sizes. @@ -44,5 +45,5 @@ export function Button({ variant = 'primary', size = 'md', className, children, - ) + ); } diff --git a/src/shared/ui/Card/index.ts b/src/shared/ui/Card/index.ts index b5c2138..4c0afdc 100644 --- a/src/shared/ui/Card/index.ts +++ b/src/shared/ui/Card/index.ts @@ -1,2 +1,2 @@ -export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './ui/Card' -export type { CardBackground } from './ui/Card' +export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './ui/Card'; +export type { CardBackground } from './ui/Card'; diff --git a/src/shared/ui/Card/ui/Card.stories.tsx b/src/shared/ui/Card/ui/Card.stories.tsx index 345ebe3..6aa8002 100644 --- a/src/shared/ui/Card/ui/Card.stories.tsx +++ b/src/shared/ui/Card/ui/Card.stories.tsx @@ -1,14 +1,14 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card' +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'; const meta: Meta = { title: 'Shared/Card', component: Card, -} +}; -export default meta +export default meta; -type Story = StoryObj +type Story = StoryObj; export const AllBackgrounds: Story = { render: () => ( @@ -36,19 +36,17 @@ export const AllBackgrounds: Story = { ), -} +}; export const NoPadding: Story = { render: () => (
-
- Image placeholder -
+
Image placeholder
), -} +}; export const FullComposition: Story = { render: () => ( @@ -67,4 +65,4 @@ export const FullComposition: Story = { ), -} +}; diff --git a/src/shared/ui/Card/ui/Card.test.tsx b/src/shared/ui/Card/ui/Card.test.tsx index 1d49f3d..3148804 100644 --- a/src/shared/ui/Card/ui/Card.test.tsx +++ b/src/shared/ui/Card/ui/Card.test.tsx @@ -1,79 +1,79 @@ -import { describe, it, expect } from 'vitest' -import { render, screen } from '@testing-library/react' -import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card' +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'; describe('Card', () => { describe('rendering', () => { it('renders children', () => { - render(Content) - expect(screen.getByText('Content')).toBeInTheDocument() - }) + render(Content); + expect(screen.getByText('Content')).toBeInTheDocument(); + }); it('has brutal-border and brutal-shadow classes', () => { - const { container } = render(Content) - expect(container.firstChild).toHaveClass('brutal-border', 'brutal-shadow') - }) - }) + const { container } = render(Content); + expect(container.firstChild).toHaveClass('brutal-border', 'brutal-shadow'); + }); + }); describe('background variants', () => { it('defaults to ochre background', () => { - const { container } = render(Content) - expect(container.firstChild).toHaveClass('bg-ochre-clay') - }) + const { container } = render(Content); + expect(container.firstChild).toHaveClass('bg-ochre-clay'); + }); it('applies slate background', () => { - const { container } = render(Content) - expect(container.firstChild).toHaveClass('bg-slate-indigo') - }) + const { container } = render(Content); + expect(container.firstChild).toHaveClass('bg-slate-indigo'); + }); it('applies white background', () => { - const { container } = render(Content) - expect(container.firstChild).toHaveClass('bg-white') - }) - }) + const { container } = render(Content); + expect(container.firstChild).toHaveClass('bg-white'); + }); + }); describe('padding', () => { it('has default padding', () => { - const { container } = render(Content) - expect(container.firstChild).toHaveClass('p-6') - }) + const { container } = render(Content); + expect(container.firstChild).toHaveClass('p-6'); + }); it('removes padding when noPadding is true', () => { - const { container } = render(Content) - expect(container.firstChild).not.toHaveClass('p-6') - }) - }) + const { container } = render(Content); + expect(container.firstChild).not.toHaveClass('p-6'); + }); + }); describe('className passthrough', () => { it('merges custom className', () => { - const { container } = render(Content) - expect(container.firstChild).toHaveClass('group') - }) - }) -}) + const { container } = render(Content); + expect(container.firstChild).toHaveClass('group'); + }); + }); +}); describe('CardHeader', () => { it('renders children with bottom margin', () => { - render(Header) - expect(screen.getByText('Header')).toHaveClass('mb-4') - }) -}) + render(Header); + expect(screen.getByText('Header')).toHaveClass('mb-4'); + }); +}); describe('CardTitle', () => { it('renders children as h3', () => { - render(Title) - expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Title') - }) -}) + render(Title); + expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Title'); + }); +}); describe('CardDescription', () => { it('renders children as paragraph with opacity', () => { - render(Desc) - const el = screen.getByText('Desc') - expect(el.tagName).toBe('P') - expect(el).toHaveClass('opacity-80') - }) -}) + render(Desc); + const el = screen.getByText('Desc'); + expect(el.tagName).toBe('P'); + expect(el).toHaveClass('opacity-80'); + }); +}); describe('CardContent', () => { it('renders children in a div', () => { - render(Body) - expect(screen.getByText('Body')).toBeInTheDocument() - }) -}) + render(Body); + expect(screen.getByText('Body')).toBeInTheDocument(); + }); +}); describe('CardFooter', () => { it('renders children with top border', () => { - render(Footer) - const el = screen.getByText('Footer') - expect(el).toHaveClass('brutal-border-top', 'mt-6', 'pt-6') - }) -}) + render(Footer); + const el = screen.getByText('Footer'); + expect(el).toHaveClass('brutal-border-top', 'mt-6', 'pt-6'); + }); +}); diff --git a/src/shared/ui/Card/ui/Card.tsx b/src/shared/ui/Card/ui/Card.tsx index cadf0d1..a637124 100644 --- a/src/shared/ui/Card/ui/Card.tsx +++ b/src/shared/ui/Card/ui/Card.tsx @@ -1,34 +1,34 @@ -import type { ReactNode } from 'react' -import { cn } from '$shared/lib' +import type { ReactNode } from 'react'; +import { cn } from '$shared/lib'; -export type CardBackground = 'ochre' | 'slate' | 'white' +export type CardBackground = 'ochre' | 'slate' | 'white'; interface CardProps { /** * Card content */ - children: ReactNode + children: ReactNode; /** * Additional CSS classes */ - className?: string + className?: string; /** * Background color preset * @default 'ochre' */ - background?: CardBackground + background?: CardBackground; /** * Remove default padding * @default false */ - noPadding?: boolean + noPadding?: boolean; } const BG: Record = { ochre: 'bg-ochre-clay', slate: 'bg-slate-indigo text-ochre-clay', white: 'bg-white', -} +}; /** * Brutalist card container with background and padding variants. @@ -38,51 +38,51 @@ export function Card({ children, className, background = 'ochre', noPadding = fa
{children}
- ) + ); } interface SlotProps { /** * Slot content */ - children: ReactNode + children: ReactNode; /** * Additional CSS classes */ - className?: string + className?: string; } /** * Card header wrapper — adds bottom margin. */ export function CardHeader({ children, className }: SlotProps) { - return
{children}
+ return
{children}
; } /** * Card title — renders as h3. */ export function CardTitle({ children, className }: SlotProps) { - return

{children}

+ return

{children}

; } /** * Card description — muted paragraph below the title. */ export function CardDescription({ children, className }: SlotProps) { - return

{children}

+ return

{children}

; } /** * Card body content area. */ export function CardContent({ children, className }: SlotProps) { - return
{children}
+ return
{children}
; } /** * Card footer — separated by a brutal border-top. */ export function CardFooter({ children, className }: SlotProps) { - return
{children}
+ return
{children}
; } diff --git a/src/shared/ui/Input/index.ts b/src/shared/ui/Input/index.ts index a538399..12065cf 100644 --- a/src/shared/ui/Input/index.ts +++ b/src/shared/ui/Input/index.ts @@ -1 +1 @@ -export { Input, Textarea } from './ui/Input' +export { Input, Textarea } from './ui/Input'; diff --git a/src/shared/ui/Input/ui/Input.stories.tsx b/src/shared/ui/Input/ui/Input.stories.tsx index e89be1b..4700cde 100644 --- a/src/shared/ui/Input/ui/Input.stories.tsx +++ b/src/shared/ui/Input/ui/Input.stories.tsx @@ -1,5 +1,5 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { Input, Textarea } from './Input' +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import { Input, Textarea } from './Input'; const meta: Meta = { title: 'Shared/Input', @@ -11,35 +11,35 @@ const meta: Meta = { ), ], -} +}; -export default meta +export default meta; -type Story = StoryObj +type Story = StoryObj; export const Default: Story = { args: {}, -} +}; export const WithLabel: Story = { args: { label: 'Email address', }, -} +}; export const WithError: Story = { args: { label: 'Email', error: 'This field is required', }, -} +}; export const WithPlaceholder: Story = { args: { placeholder: 'Enter your email', type: 'email', }, -} +}; export const TextareaStory: Story = { name: 'Textarea', @@ -48,7 +48,7 @@ export const TextareaStory: Story = {