Compare commits

...

14 Commits

Author SHA1 Message Date
Ilia Mashkov 0090718869 fix: add outline to primary and secondary button variants 2026-05-12 13:58:29 +03:00
Ilia Mashkov 301e7a2555 feat: RichText component for safe PocketBase HTML rendering
Add html-react-parser-backed RichText component that converts HTML
strings from PocketBase rich-text fields into React elements without
dangerouslySetInnerHTML. Replace raw <p> render in IntroSection and
BioSection, and drop the invalid slug filters those collections lacked.
2026-05-12 13:58:17 +03:00
Ilia Mashkov 0a99a37bca fix: remove underline from collapsed section title links
Global a { border-bottom } was leaking onto the inactive section
nav links. Override with border-b-0 hover:border-b-0.
2026-05-12 13:57:39 +03:00
Ilia Mashkov e8bf8b502e fix: align PocketBase type definitions with actual schema
Remove slug field from PageContentRecord (intro/bio collections have none).
Remove number field from SectionRecord (not stored in PocketBase); derive
zero-padded display number from the order field at render time.
2026-05-12 13:57:25 +03:00
Ilia Mashkov 30f8e4be95 design: two-color palette — rename all tokens to --cream / --blue
Replace ochre-clay, carbon-black, burnt-oxide, slate-indigo with clean
two-color system: --cream (#f4f0e8) and --blue (#041cf3). Update every
component, utility class, and test assertion.
2026-05-11 12:59:32 +03:00
Ilia Mashkov fed9c97ddb feat: URL-driven catchall routing, drop sidebar nav, split export build
- app/[[...slug]]/page.tsx replaces app/page.tsx; activeSlug from URL params
- SidebarNav and MobileNav removed from main layout (sections accordion is the nav)
- next.config.ts: output:export controlled by STATIC_EXPORT env var instead of NODE_ENV
- package.json: yarn build is standard Next.js build; yarn export is STATIC_EXPORT=true
- Mock API route: force-static + generateStaticParams for output:export compatibility
2026-05-11 11:12:21 +03:00
Ilia Mashkov af165ec356 feat: MobileNav section items use Link, close menu on pathname change 2026-05-11 11:11:53 +03:00
Ilia Mashkov 1dfa9a62a2 design: update color palette from ochre-clay to white/blue scheme 2026-05-11 11:11:29 +03:00
Ilia Mashkov b4bda4b8f7 chore: biome format vitest config, add Props JSDoc to SidebarNav 2026-05-11 11:11:24 +03:00
Ilia Mashkov f9cdb06632 refactor: SidebarNav uses usePathname and Link instead of IntersectionObserver 2026-05-07 12:54:53 +03:00
Ilia Mashkov 9fa2156ee8 feat: SectionAccordion inactive state uses Link href instead of button onClick 2026-05-07 12:52:54 +03:00
Ilia Mashkov ced77f6f07 fix: make output export build-only so dev route handlers work 2026-05-07 12:28:42 +03:00
Ilia Mashkov f163b750b2 docs: add URL-driven section routing implementation plan 2026-05-07 11:48:55 +03:00
Ilia Mashkov 41d1a37352 docs: add URL-driven section routing design 2026-05-07 11:45:54 +03:00
46 changed files with 1538 additions and 362 deletions
+50
View File
@@ -0,0 +1,50 @@
import { notFound } from 'next/navigation';
import type { SectionRecord } from '$entities/Section';
import { getCollection } from '$shared/api';
import { SectionFactory } from '$widgets/SectionFactory';
import { SectionsAccordion } from '$widgets/SectionsAccordion';
/**
* Optional catchall: `/` → first section, `/:slug` → that section.
*/
export async function generateStaticParams() {
const { items: sections } = await getCollection<SectionRecord>('sections', {
sort: 'order',
});
return [{}, ...sections.map((s) => ({ slug: [s.slug] }))];
}
type Props = {
params: Promise<{ slug?: string[] }>;
};
/**
* Portfolio page — one route per section, sections list always visible.
*/
export default async function SectionPage({ params }: Props) {
const { slug } = await params;
const { items: sections } = await getCollection<SectionRecord>('sections', {
sort: 'order',
});
if (sections.length === 0) {
notFound();
}
const activeSlug = slug?.[0] ?? sections[0].slug;
if (!sections.some((s) => s.slug === activeSlug)) {
notFound();
}
return (
<main className="px-8 py-12 lg:py-16 lg:px-16">
<SectionsAccordion sections={sections} activeSlug={activeSlug}>
{sections.map((s) => (
<SectionFactory key={s.slug} slug={s.slug} />
))}
</SectionsAccordion>
</main>
);
}
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export const dynamic = 'force-static';
const base = { created: '', updated: '' };
@@ -241,6 +241,10 @@ const FIXTURES: Record<string, unknown[]> = {
],
};
export function generateStaticParams() {
return Object.keys(FIXTURES).map((collection) => ({ collection }));
}
/**
* Mock API route handler for PocketBase collection records.
* Returns fixture data shaped as a PocketBase list response.
-38
View File
@@ -1,38 +0,0 @@
import type { SectionRecord } from '$entities/Section';
import { getCollection } from '$shared/api';
import type { NavItem } from '$widgets/Navigation';
import { MobileNav, SidebarNav } from '$widgets/Navigation';
import { SectionFactory } from '$widgets/SectionFactory';
import { SectionsAccordion } from '$widgets/SectionsAccordion';
/**
* Portfolio home page.
*
* Fetches all sections at build time (SSG). Renders a fixed sidebar with
* section navigation and a scrollable main column with accordion sections.
*/
export default async function Home() {
const { items: sections } = await getCollection<SectionRecord>('sections', {
sort: 'order',
});
const navItems: NavItem[] = sections.map((s) => ({
id: s.slug,
label: s.title,
number: s.number,
}));
return (
<div className="min-h-screen lg:flex">
<SidebarNav items={navItems} />
<main className="flex-1 lg:ml-[33.333%] px-8 py-12 lg:py-16 lg:px-16">
<MobileNav items={navItems} />
<SectionsAccordion sections={sections}>
{sections.map((s) => (
<SectionFactory key={s.slug} slug={s.slug} />
))}
</SectionsAccordion>
</main>
</div>
);
}
+1 -1
View File
@@ -7,7 +7,7 @@
},
"files": {
"ignoreUnknown": false,
"includes": ["src/**/*", "app/**/*"]
"includes": ["src/**/*", "app/**/*", "*.ts", "*.tsx", "*.js", "*.jsx"]
},
"formatter": {
"enabled": true,
@@ -0,0 +1,86 @@
# URL-Driven Section Routing — Design
**Date:** 2026-05-07
**Status:** Approved
## Goal
Replace the single-page client-state accordion with multi-page URL-driven routing. Each portfolio section gets its own static URL. The sections list remains visible at all times; clicking a section heading navigates to its page.
## Route Structure
Delete `app/page.tsx`. Create `app/[[...slug]]/page.tsx` (optional catchall).
| URL | Active section |
|---|---|
| `/` | `sections[0].slug` (first section, URL stays `/`) |
| `/intro` | `intro` |
| `/bio` | `bio` |
| `/skills` | `skills` |
| `/experience` | `experience` |
| `/projects` | `projects` |
`generateStaticParams` emits one entry per section plus the root:
```ts
[{}, { slug: ['intro'] }, { slug: ['bio'] }, ...]
```
## Component Changes
### `SectionAccordion` (entity)
- Replace `onClick: () => void` prop with `href: string`
- Inactive state: render `<Link href={href}>` instead of `<button onClick>`
- No `'use client'` needed (already a server component)
### `SectionsAccordion` (widget)
- Remove `'use client'` directive and `useState`
- Add `activeSlug: string` prop (passed from page server component)
- Pass `href={`/${section.slug}`}` to each `SectionAccordion`
- Keep `children` slot pattern for RSC content
### `SidebarNav` (widget)
- Remove `IntersectionObserver` and `scrollToSection`
- Add `usePathname()` hook for active detection
- Active rule: `pathname === `/${item.id}`` or `(pathname === '/' && item is first)`
- Items become `<Link href={`/${item.id}`}>` instead of `<button onClick>`
- Keep `'use client'` (required for `usePathname`)
### `MobileNav` (widget)
- Section items become `<Link>` that also close the menu on navigate
- Use `usePathname` in a `useEffect` to close menu on route change (replaces manual close-on-click)
## Data Flow
```
[[...slug]]/page.tsx (RSC)
├─ fetch sections[]
├─ activeSlug = params?.slug?.[0] ?? sections[0].slug
├─ notFound() if activeSlug not in sections
├─ SidebarNav items={navItems} ← usePathname for active state
└─ SectionsAccordion sections activeSlug
├─ SectionAccordion href="/" isActive=true → SectionFactory content
├─ SectionAccordion href="/bio" → Link
└─ SectionAccordion href="/skills" → Link
```
No client state in the section list. `SidebarNav` remains client-only for `usePathname`.
## Error Handling
- Unknown slug → `notFound()` at page level (404 static page)
- Empty sections list → `notFound()` at page level
## Testing
- `SectionsAccordion`: drop interaction (click/activate) tests; replace with prop-driven assertions — correct `isActive` and `href` per section given `activeSlug`
- `SidebarNav`: drop `IntersectionObserver` mock; mock `usePathname`; assert active link class
- `MobileNav`: items become links; assert close-on-navigate via `usePathname` effect
- `[[...slug]]/page.tsx`: no unit tests (pure orchestration of tested components)
## No New Dependencies
`next/link` and `next/navigation` already present.
@@ -0,0 +1,882 @@
# URL-Driven Section Routing Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace the single-page client-state accordion with URL-driven routing — each section gets its own static URL (`/intro`, `/bio`, etc.), clicking a section heading navigates between pages.
**Architecture:** `app/[[...slug]]/page.tsx` is the single RSC that handles all routes. It resolves the active slug from URL params (defaulting to first section at `/`), then passes `activeSlug` down to `SectionsAccordion` (now a server component). `SectionAccordion` entity renders inactive sections as `<Link>` elements. `SidebarNav` uses `usePathname()` for active state.
**Tech Stack:** Next.js 16 App Router, React 19, Vitest + RTL, TypeScript strict, Biome, `next/link`, `next/navigation`.
---
## Task 0: Commit next.config.ts fix
The `output: 'export'` was already made conditional on `NODE_ENV === 'production'` to allow route handlers in dev. Commit it.
**Files:**
- Already modified: `next.config.ts`
**Step 1: Verify file is correct**
`next.config.ts` should read:
```ts
import type { NextConfig } from 'next';
const isExport = process.env.NODE_ENV === 'production';
const nextConfig: NextConfig = {
/* output: 'export' only applies at build time — enabling it in dev mode
* breaks route handlers (incompatible with force-dynamic in Next.js 16) */
...(isExport ? { output: 'export' } : {}),
images: { unoptimized: true },
};
export default nextConfig;
```
**Step 2: Commit**
```bash
git add next.config.ts
git commit -m "fix: make output export build-only so dev route handlers work"
```
---
## Task 1: Update SectionAccordion entity — onClick → href (TDD)
Replace the inactive `<button onClick>` with `<Link href>`. The entity already has tests — update them first.
**Files:**
- Modify: `src/entities/Section/ui/SectionAccordion/SectionAccordion.test.tsx`
- Modify: `src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx`
**Step 1: Update the tests**
Replace `src/entities/Section/ui/SectionAccordion/SectionAccordion.test.tsx` entirely:
```tsx
import { render, screen } from '@testing-library/react';
import { SectionAccordion } from './SectionAccordion';
const defaultProps = {
number: '01',
title: 'About',
id: 'about',
isActive: false,
href: '/about',
children: <p>Content here</p>,
};
describe('SectionAccordion', () => {
describe('collapsed state (isActive=false)', () => {
it('renders a section element with the given id', () => {
const { container } = render(<SectionAccordion {...defaultProps} />);
expect(container.querySelector('section#about')).toBeInTheDocument();
});
it('renders a link with number and title', () => {
render(<SectionAccordion {...defaultProps} />);
expect(screen.getByRole('link', { name: /01.*About/i })).toBeInTheDocument();
});
it('link points to the correct href', () => {
render(<SectionAccordion {...defaultProps} />);
expect(screen.getByRole('link', { name: /01.*About/i })).toHaveAttribute('href', '/about');
});
it('does not render children', () => {
render(<SectionAccordion {...defaultProps} />);
expect(screen.queryByText('Content here')).not.toBeInTheDocument();
});
it('does not render a button', () => {
render(<SectionAccordion {...defaultProps} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});
describe('active state (isActive=true)', () => {
const activeProps = { ...defaultProps, isActive: true };
it('renders an h1 with number and title', () => {
render(<SectionAccordion {...activeProps} />);
expect(screen.getByRole('heading', { level: 1, name: /01.*About/i })).toBeInTheDocument();
});
it('renders children', () => {
render(<SectionAccordion {...activeProps} />);
expect(screen.getByText('Content here')).toBeInTheDocument();
});
it('does not render a link', () => {
render(<SectionAccordion {...activeProps} />);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('content wrapper has animate-fadeIn class', () => {
const { container } = render(<SectionAccordion {...activeProps} />);
expect(container.querySelector('.animate-fadeIn')).toBeInTheDocument();
});
});
});
```
**Step 2: Run tests — verify they fail**
```bash
yarn test src/entities/Section/ui/SectionAccordion --run
```
Expected: FAIL — tests expecting `link` role but component still renders `button`.
**Step 3: Update the component**
Replace `src/entities/Section/ui/SectionAccordion/SectionAccordion.tsx`:
```tsx
import Link from 'next/link';
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;
/**
* Navigation URL for the collapsed heading link
*/
href: string;
/**
* Section content, shown when active
*/
children: ReactNode;
}
/**
* Accordion-style section that collapses to a navigation link when inactive.
*/
export function SectionAccordion({ number, title, id, isActive, href, children }: SectionAccordionProps) {
return (
<section id={id} className="scroll-mt-8">
{isActive ? (
<div className="mb-12">
<div className="mb-16">
<h1
className="font-heading font-black text-5xl leading-[1.2] mb-0"
style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }}
>
{number}. {title}
</h1>
</div>
<div className="animate-fadeIn">{children}</div>
</div>
) : (
<Link
href={href}
className="block w-full text-left mb-3 py-3 transition-all duration-200 hover:opacity-60 group"
>
<h2
className="font-heading font-black text-2xl sm:text-3xl opacity-30 group-hover:opacity-50 transition-opacity"
style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }}
>
{number}. {title}
</h2>
</Link>
)}
</section>
);
}
```
**Step 4: Run tests — verify they pass**
```bash
yarn test src/entities/Section/ui/SectionAccordion --run
```
Expected: all PASS.
**Step 5: Commit**
```bash
git add src/entities/Section/ui/SectionAccordion/
git commit -m "feat: SectionAccordion inactive state uses Link href instead of button onClick"
```
---
## Task 2: Update SectionsAccordion widget — drop client state, add activeSlug prop (TDD)
The widget becomes a server component. `activeSlug` is passed as a prop from the page. `children` is a single RSC slot for the active section content only.
**Files:**
- Modify: `src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx`
- Modify: `src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx`
**Step 1: Rewrite the tests**
Replace `src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.test.tsx`:
```tsx
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', number: '01', order: 1 },
{ ...baseRecord, id: '2', slug: 'bio', title: 'Bio', number: '02', order: 2 },
{ ...baseRecord, id: '3', slug: 'skills', title: 'Skills', number: '03', order: 3 },
];
describe('SectionsAccordion', () => {
describe('active section rendering', () => {
it('renders the active section as h1', () => {
render(
<SectionsAccordion sections={sections} activeSlug="bio">
<div>Bio content</div>
</SectionsAccordion>,
);
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('02. Bio');
});
it('renders active section children', () => {
render(
<SectionsAccordion sections={sections} activeSlug="bio">
<div>Bio content</div>
</SectionsAccordion>,
);
expect(screen.getByText('Bio content')).toBeInTheDocument();
});
});
describe('inactive section rendering', () => {
it('renders inactive sections as links', () => {
render(
<SectionsAccordion sections={sections} activeSlug="bio">
<div>Bio content</div>
</SectionsAccordion>,
);
const links = screen.getAllByRole('link');
expect(links).toHaveLength(2);
});
it('inactive links point to correct hrefs', () => {
render(
<SectionsAccordion sections={sections} activeSlug="bio">
<div>Bio content</div>
</SectionsAccordion>,
);
expect(screen.getByRole('link', { name: /01.*Intro/i })).toHaveAttribute('href', '/intro');
expect(screen.getByRole('link', { name: /03.*Skills/i })).toHaveAttribute('href', '/skills');
});
it('does not render children for inactive sections', () => {
render(
<SectionsAccordion sections={sections} activeSlug="bio">
<div>Bio content</div>
</SectionsAccordion>,
);
expect(screen.getAllByText('Bio content')).toHaveLength(1);
});
});
describe('first section default', () => {
it('shows first section as active when activeSlug matches first', () => {
render(
<SectionsAccordion sections={sections} activeSlug="intro">
<div>Intro content</div>
</SectionsAccordion>,
);
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('01. Intro');
});
});
});
```
**Step 2: Run tests — verify they fail**
```bash
yarn test src/widgets/SectionsAccordion --run
```
Expected: FAIL — component still uses `useState` and doesn't accept `activeSlug` prop.
**Step 3: Rewrite the component**
Replace `src/widgets/SectionsAccordion/ui/SectionsAccordion/SectionsAccordion.tsx`:
```tsx
import type { ReactNode } 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
*/
activeSlug: string;
/**
* Content for the active section — rendered inside the expanded accordion item
*/
children: ReactNode;
};
/**
* Renders all portfolio sections as an accordion list.
* Active section is determined by URL (activeSlug from page params).
* Inactive sections render as navigation links.
*/
export function SectionsAccordion({ sections, activeSlug, children }: Props) {
return (
<div className="space-y-2">
{sections.map((section) => (
<SectionAccordion
key={section.slug}
id={section.slug}
number={section.number}
title={section.title}
isActive={activeSlug === section.slug}
href={`/${section.slug}`}
>
{activeSlug === section.slug ? children : null}
</SectionAccordion>
))}
</div>
);
}
```
**Step 4: Run tests — verify they pass**
```bash
yarn test src/widgets/SectionsAccordion --run
```
Expected: all PASS.
**Step 5: Commit**
```bash
git add src/widgets/SectionsAccordion/
git commit -m "refactor: SectionsAccordion server component, activeSlug prop replaces useState"
```
---
## Task 3: Update SidebarNav — IntersectionObserver → usePathname (TDD)
Items become `<Link>` elements. Active state driven by `usePathname()`. The first section is also active at `/`.
**Files:**
- Modify: `src/widgets/Navigation/ui/SidebarNav.test.tsx`
- Modify: `src/widgets/Navigation/ui/SidebarNav.tsx`
**Step 1: Rewrite the tests**
Replace `src/widgets/Navigation/ui/SidebarNav.test.tsx`:
```tsx
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' },
];
describe('SidebarNav', () => {
describe('rendering', () => {
beforeEach(() => {
vi.mocked(usePathname).mockReturnValue('/bio');
});
it('renders a nav element', () => {
render(<SidebarNav items={ITEMS} />);
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
it('renders "Index" heading', () => {
render(<SidebarNav items={ITEMS} />);
expect(screen.getByText('Index')).toBeInTheDocument();
});
it('renders "Digital Monograph" subtitle', () => {
render(<SidebarNav items={ITEMS} />);
expect(screen.getByText('Digital Monograph')).toBeInTheDocument();
});
it('renders each item label and number', () => {
render(<SidebarNav items={ITEMS} />);
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(<SidebarNav items={ITEMS} />);
expect(screen.getByText('Quick Links')).toBeInTheDocument();
});
it('renders Email quick link', () => {
render(<SidebarNav items={ITEMS} />);
expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument();
});
it('renders a link for each item', () => {
render(<SidebarNav items={ITEMS} />);
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', () => {
vi.mocked(usePathname).mockReturnValue('/bio');
render(<SidebarNav items={ITEMS} />);
const activeLink = screen.getByRole('link', { name: /Bio/i });
expect(activeLink).not.toHaveClass('opacity-40');
});
it('marks non-matching item as inactive', () => {
vi.mocked(usePathname).mockReturnValue('/bio');
render(<SidebarNav items={ITEMS} />);
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(<SidebarNav items={ITEMS} />);
const firstLink = screen.getByRole('link', { name: /Bio/i });
expect(firstLink).not.toHaveClass('opacity-40');
});
});
});
```
**Step 2: Run tests — verify they fail**
```bash
yarn test src/widgets/Navigation/ui/SidebarNav.test.tsx --run
```
Expected: FAIL — component still uses `IntersectionObserver` and renders buttons, not links.
**Step 3: Rewrite the component**
Replace `src/widgets/Navigation/ui/SidebarNav.tsx`:
```tsx
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { CONTACT_LINKS, cn } from '$shared/lib';
import type { NavItem } from '../model/types';
interface Props {
/**
* Navigation items to render
*/
items: NavItem[];
}
/**
* Fixed sidebar navigation, visible on lg+ screens.
* Active section determined by current URL pathname.
*/
export function SidebarNav({ items }: Props) {
const pathname = usePathname();
/**
* An item is active when its slug matches the current pathname,
* or when the pathname is root and it is the first item.
*/
function isActive(item: NavItem): boolean {
if (pathname === `/${item.id}`) return true;
if (pathname === '/' && items[0]?.id === item.id) return true;
return false;
}
return (
<nav className="fixed top-0 left-0 h-screen w-full lg:w-1/3 bg-ochre-clay brutal-border-right hidden lg:block overflow-y-auto z-50">
<div className="px-8 py-12 space-y-2">
<div className="mb-12">
<h2>Index</h2>
<div className="brutal-border-top pt-4">
<p className="text-sm opacity-60">Digital Monograph</p>
</div>
</div>
{items.map((item) => (
<Link
key={item.id}
href={`/${item.id}`}
className={cn(
'block w-full text-left brutal-border bg-ochre-clay px-6 py-4 transition-all duration-300',
isActive(item)
? 'shadow-[12px_12px_0_var(--carbon-black)] opacity-100 translate-x-0'
: 'opacity-40 shadow-none hover:opacity-60',
)}
>
<div className="flex items-baseline gap-4">
<span className="text-sm opacity-60">{item.number}</span>
<span className="font-heading text-xl font-black">{item.label}</span>
</div>
</Link>
))}
<div className="mt-12 pt-12 brutal-border-top">
<p className="text-sm uppercase tracking-wider mb-4 opacity-60">Quick Links</p>
<div className="space-y-3">
<a href={`mailto:${CONTACT_LINKS.email}`} className="block">
Email
</a>
<a href={CONTACT_LINKS.linkedin} className="block">
LinkedIn
</a>
<a href={CONTACT_LINKS.instagram} className="block">
Instagram
</a>
<a href={CONTACT_LINKS.arena} className="block">
Are.na
</a>
</div>
</div>
</div>
</nav>
);
}
```
**Step 4: Run tests — verify they pass**
```bash
yarn test src/widgets/Navigation/ui/SidebarNav.test.tsx --run
```
Expected: all PASS.
**Step 5: Commit**
```bash
git add src/widgets/Navigation/ui/SidebarNav.tsx src/widgets/Navigation/ui/SidebarNav.test.tsx
git commit -m "refactor: SidebarNav uses usePathname and Link instead of IntersectionObserver"
```
---
## Task 4: Update MobileNav — section buttons → Link (TDD)
Section items become `<Link>` elements that close the menu `onClick`. The `scrollToSection` function is removed.
**Files:**
- Modify: `src/widgets/Navigation/ui/MobileNav.test.tsx`
- Modify: `src/widgets/Navigation/ui/MobileNav.tsx`
**Step 1: Update the tests**
Replace `src/widgets/Navigation/ui/MobileNav.test.tsx`:
```tsx
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' }];
describe('MobileNav', () => {
describe('rendering', () => {
it('renders title "allmy.work"', () => {
render(<MobileNav items={ITEMS} />);
expect(screen.getByText('allmy.work')).toBeInTheDocument();
});
it('renders toggle button with text "Menu" initially', () => {
render(<MobileNav items={ITEMS} />);
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
});
it('menu items are hidden initially', () => {
render(<MobileNav items={ITEMS} />);
expect(screen.queryByRole('link', { name: /About/i })).not.toBeInTheDocument();
});
});
describe('interactions', () => {
it('click toggle shows item links and changes label to "Close"', async () => {
render(<MobileNav items={ITEMS} />);
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /About/i })).toBeInTheDocument();
});
it('item links point to correct href', async () => {
render(<MobileNav items={ITEMS} />);
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
expect(screen.getByRole('link', { name: /About/i })).toHaveAttribute('href', '/about');
});
it('click item link closes the menu', async () => {
render(<MobileNav items={ITEMS} />);
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
await userEvent.click(screen.getByRole('link', { name: /About/i }));
expect(screen.queryByText('Close')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
});
});
});
```
**Step 2: Run tests — verify they fail**
```bash
yarn test src/widgets/Navigation/ui/MobileNav.test.tsx --run
```
Expected: FAIL — component still uses `button` for items, not `link`.
**Step 3: Update the component**
Replace `src/widgets/Navigation/ui/MobileNav.tsx`:
```tsx
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { cn } from '$shared/lib';
import type { NavItem } from '../model/types';
interface Props {
/**
* Navigation items to render
*/
items: NavItem[];
}
/**
* Mobile navigation overlay, hidden on lg+ screens.
* Section items are links that close the menu on navigate.
*/
export function MobileNav({ items }: Props) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="lg:hidden fixed top-0 left-0 right-0 bg-ochre-clay brutal-border-bottom z-50">
<div className="px-6 py-4 flex items-center justify-between">
<h4>allmy.work</h4>
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay"
>
{isOpen ? 'Close' : 'Menu'}
</button>
</div>
{isOpen && (
<div className="px-6 py-6 brutal-border-top space-y-2 max-h-[80vh] overflow-y-auto">
{items.map((item) => (
<Link
key={item.id}
href={`/${item.id}`}
onClick={() => setIsOpen(false)}
className="block w-full text-left brutal-border bg-ochre-clay px-4 py-3"
>
<div className={cn('flex items-baseline gap-3')}>
<span className="text-sm opacity-60 font-body">{item.number}</span>
<span
className="font-heading text-lg font-black"
style={{ fontVariationSettings: '"WONK" 1, "SOFT" 0' }}
>
{item.label}
</span>
</div>
</Link>
))}
</div>
)}
</div>
);
}
```
**Step 4: Run tests — verify they pass**
```bash
yarn test src/widgets/Navigation/ui/MobileNav.test.tsx --run
```
Expected: all PASS.
**Step 5: Commit**
```bash
git add src/widgets/Navigation/ui/MobileNav.tsx src/widgets/Navigation/ui/MobileNav.test.tsx
git commit -m "refactor: MobileNav section items use Link instead of scrollToSection"
```
---
## Task 5: Create [[...slug]]/page.tsx and delete app/page.tsx
Wire everything together with `generateStaticParams` for SSG.
**Files:**
- Create: `app/[[...slug]]/page.tsx`
- Delete: `app/page.tsx`
**Step 1: Create the route file**
Create `app/[[...slug]]/page.tsx`:
```tsx
import { notFound } from 'next/navigation';
import type { SectionRecord } from '$entities/Section';
import { getCollection } from '$shared/api';
import type { NavItem } from '$widgets/Navigation';
import { MobileNav, SidebarNav } from '$widgets/Navigation';
import { SectionFactory } from '$widgets/SectionFactory';
import { SectionsAccordion } from '$widgets/SectionsAccordion';
/**
* Generates static params for all section pages plus the root.
*/
export async function generateStaticParams() {
const { items: sections } = await getCollection<SectionRecord>('sections', {
sort: 'order',
});
return [
{},
...sections.map((s) => ({ slug: [s.slug] })),
];
}
/**
* Portfolio page — handles all section routes via optional catchall.
*
* `/` → first section shown as active
* `/{slug}` → that section shown as active
*/
export default async function SectionPage({
params,
}: {
params: Promise<{ slug?: string[] }>;
}) {
const { slug } = await params;
const { items: sections } = await getCollection<SectionRecord>('sections', {
sort: 'order',
});
if (!sections.length) notFound();
const activeSlug = slug?.[0] ?? sections[0].slug;
if (!sections.find((s) => s.slug === activeSlug)) notFound();
const navItems: NavItem[] = sections.map((s) => ({
id: s.slug,
label: s.title,
number: s.number,
}));
return (
<div className="min-h-screen lg:flex">
<SidebarNav items={navItems} />
<main className="flex-1 lg:ml-[33.333%] px-8 py-12 lg:py-16 lg:px-16">
<MobileNav items={navItems} />
<SectionsAccordion sections={sections} activeSlug={activeSlug}>
<SectionFactory slug={activeSlug} />
</SectionsAccordion>
</main>
</div>
);
}
```
**Step 2: Delete app/page.tsx**
```bash
rm app/page.tsx
```
**Step 3: TypeScript check**
```bash
yarn tsc --noEmit
```
Expected: no errors.
**Step 4: Commit**
```bash
git add app/
git commit -m "feat: URL-driven section routing via optional catchall with generateStaticParams"
```
---
## Task 6: Final Verification
**Step 1: Full test suite**
```bash
yarn test --run
```
Expected: all tests PASS.
**Step 2: TypeScript check**
```bash
yarn tsc --noEmit
```
Expected: no errors.
**Step 3: Lint check**
```bash
yarn check
```
Expected: no errors. If auto-fixable issues: `yarn check:fix` then re-run.
**Step 4: Dev server smoke test**
```bash
yarn dev
```
Open `http://localhost:3000` — should show first section (intro) as active, others as links. Clicking a section link should change the URL and show that section's content.
+9 -4
View File
@@ -1,8 +1,13 @@
import type { NextConfig } from 'next'
import type { NextConfig } from 'next';
/* output: 'export' is opt-in via STATIC_EXPORT=true.
* Set this in CI/deploy — not locally — so the mock API route works
* during development and local builds. */
const isExport = process.env.STATIC_EXPORT === 'true';
const nextConfig: NextConfig = {
output: 'export',
...(isExport ? { output: 'export' } : {}),
images: { unoptimized: true },
}
};
export default nextConfig
export default nextConfig;
+2
View File
@@ -5,6 +5,7 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"export": "STATIC_EXPORT=true next build",
"start": "next start",
"lint": "biome lint --write .",
"format": "biome format --write .",
@@ -19,6 +20,7 @@
},
"dependencies": {
"clsx": "^2.1.1",
"html-react-parser": "^6.1.0",
"next": "16.2.4",
"react": "19.2.4",
"react-dom": "19.2.4",
-4
View File
@@ -12,10 +12,6 @@ export type SectionRecord = BaseRecord & {
* Display name of the section
*/
title: string;
/**
* Visual numbering prefix (e.g., "01")
*/
number: string;
/**
* Sorting weight for section order
*/
@@ -23,7 +23,7 @@ export const Active: Story = {
title: 'Biography',
id: 'bio',
isActive: true,
onClick: () => {},
href: '/bio',
children: <p>This is the expanded section content. It is visible because isActive is true.</p>,
},
};
@@ -34,7 +34,7 @@ export const Collapsed: Story = {
title: 'Work',
id: 'work',
isActive: false,
onClick: () => console.log('section clicked'),
href: '/work',
children: <p>This content is hidden in collapsed state.</p>,
},
};
@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SectionAccordion } from './SectionAccordion';
const defaultProps = {
@@ -7,7 +6,7 @@ const defaultProps = {
title: 'About',
id: 'about',
isActive: false,
onClick: vi.fn(),
href: '/about',
children: <p>Content here</p>,
};
@@ -17,19 +16,25 @@ describe('SectionAccordion', () => {
const { container } = render(<SectionAccordion {...defaultProps} />);
expect(container.querySelector('section#about')).toBeInTheDocument();
});
it('renders a button with number and title', () => {
it('renders a link with number and title', () => {
render(<SectionAccordion {...defaultProps} />);
expect(screen.getByRole('button', { name: /01.*About/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /01.*About/i })).toBeInTheDocument();
});
it('link points to the correct href', () => {
render(<SectionAccordion {...defaultProps} />);
expect(screen.getByRole('link', { name: /01.*About/i })).toHaveAttribute('href', '/about');
});
it('does not render children', () => {
render(<SectionAccordion {...defaultProps} />);
expect(screen.queryByText('Content here')).not.toBeInTheDocument();
});
it('calls onClick when button is clicked', async () => {
const onClick = vi.fn();
render(<SectionAccordion {...defaultProps} onClick={onClick} />);
await userEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledOnce();
it('does not render a button', () => {
render(<SectionAccordion {...defaultProps} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});
@@ -40,14 +45,17 @@ describe('SectionAccordion', () => {
render(<SectionAccordion {...activeProps} />);
expect(screen.getByRole('heading', { level: 1, name: /01.*About/i })).toBeInTheDocument();
});
it('renders children', () => {
render(<SectionAccordion {...activeProps} />);
expect(screen.getByText('Content here')).toBeInTheDocument();
});
it('does not render a button', () => {
it('does not render a link', () => {
render(<SectionAccordion {...activeProps} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('content wrapper has animate-fadeIn class', () => {
const { container } = render(<SectionAccordion {...activeProps} />);
expect(container.querySelector('.animate-fadeIn')).toBeInTheDocument();
@@ -1,3 +1,4 @@
import Link from 'next/link';
import type { ReactNode } from 'react';
interface SectionAccordionProps {
@@ -18,9 +19,9 @@ interface SectionAccordionProps {
*/
isActive: boolean;
/**
* Called when the collapsed header is clicked
* Navigation URL for the collapsed heading link
*/
onClick: () => void;
href: string;
/**
* Section content, shown when active
*/
@@ -28,9 +29,9 @@ interface SectionAccordionProps {
}
/**
* Accordion-style section that collapses to a heading button when inactive.
* Accordion-style section that collapses to a navigation link when inactive.
*/
export function SectionAccordion({ number, title, id, isActive, onClick, children }: SectionAccordionProps) {
export function SectionAccordion({ number, title, id, isActive, href, children }: SectionAccordionProps) {
return (
<section id={id} className="scroll-mt-8">
{isActive ? (
@@ -46,10 +47,9 @@ export function SectionAccordion({ number, title, id, isActive, onClick, childre
<div className="animate-fadeIn">{children}</div>
</div>
) : (
<button
type="button"
onClick={onClick}
className="w-full text-left mb-3 py-3 transition-all duration-200 hover:opacity-60 group"
<Link
href={href}
className="block w-full text-left mb-3 py-3 transition-all duration-200 hover:opacity-60 group border-b-0 hover:border-b-0"
>
<h2
className="font-heading font-black text-2xl sm:text-3xl opacity-30 group-hover:opacity-50 transition-opacity"
@@ -57,7 +57,7 @@ export function SectionAccordion({ number, title, id, isActive, onClick, childre
>
{number}. {title}
</h2>
</button>
</Link>
)}
</section>
);
@@ -37,10 +37,10 @@ describe('ExperienceCard', () => {
expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent('Senior Developer');
});
it('period badge has brutal-border, bg-carbon-black, text-ochre-clay, text-sm', () => {
it('period badge has brutal-border, bg-blue, text-cream, text-sm', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />);
const badge = screen.getByText('2021 2024');
expect(badge).toHaveClass('brutal-border', 'bg-carbon-black', 'text-ochre-clay', 'text-sm');
expect(badge).toHaveClass('brutal-border', 'bg-blue', 'text-cream', 'text-sm');
});
it('company paragraph has opacity-80', () => {
@@ -34,7 +34,7 @@ export function ExperienceCard({ title, company, period, description, className
<h4>{title}</h4>
<p className="text-base opacity-80">{company}</p>
</div>
<span className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay text-sm self-start">{period}</span>
<span className="brutal-border px-4 py-2 bg-blue text-cream text-sm self-start">{period}</span>
</div>
<p className="text-base max-w-[700px]">{description}</p>
</Card>
@@ -49,12 +49,12 @@ export function DetailedProjectCard({ title, year, role, stack, description, det
</div>
<div className="lg:col-span-10 order-1 lg:order-2">
<Card background="white">
<Card>
<h3>{title}</h3>
<p className="text-lg mb-6">{description}</p>
{imageUrl && (
<div className="brutal-border aspect-video bg-slate-indigo overflow-hidden relative">
<div className="brutal-border aspect-video bg-blue overflow-hidden relative">
<Image src={imageUrl} alt={title} fill className="object-cover" />
</div>
)}
+2 -9
View File
@@ -47,20 +47,13 @@ describe('ProjectCard', () => {
it('year badge has correct classes', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
const yearBadge = screen.getByText('2024');
expect(yearBadge).toHaveClass('brutal-border', 'bg-carbon-black', 'text-ochre-clay', 'text-sm');
expect(yearBadge).toHaveClass('brutal-border', 'bg-blue', 'text-cream', 'text-sm');
});
it('tags have correct classes', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
const tag = screen.getByText('React');
expect(tag).toHaveClass(
'brutal-border',
'bg-white',
'text-carbon-black',
'text-sm',
'uppercase',
'tracking-wide',
);
expect(tag).toHaveClass('brutal-border', 'bg-cream', 'text-blue', 'text-sm', 'uppercase', 'tracking-wide');
});
});
+4 -7
View File
@@ -33,29 +33,26 @@ export function ProjectCard({ title, year, description, tags, imageUrl }: Props)
<Card
className={cn(
'group hover:translate-x-[2px] hover:translate-y-[2px]',
'hover:shadow-[10px_10px_0_var(--carbon-black)] transition-all duration-300',
'hover:shadow-[10px_10px_0_var(--blue)] transition-all duration-300',
)}
>
<CardHeader>
<div className="flex flex-row justify-between items-start mb-3">
<CardTitle className="flex-1">{title}</CardTitle>
<span className="brutal-border px-3 py-1 bg-carbon-black text-ochre-clay text-sm">{year}</span>
<span className="brutal-border px-3 py-1 bg-blue text-cream text-sm">{year}</span>
</div>
<CardDescription>{description}</CardDescription>
</CardHeader>
{imageUrl && (
<div className="brutal-border my-6 aspect-video bg-slate-indigo overflow-hidden relative">
<div className="brutal-border my-6 aspect-video bg-blue overflow-hidden relative">
<Image src={imageUrl} alt={title} fill className="object-cover" />
</div>
)}
<CardContent className="flex flex-wrap gap-2">
{tags.map((tag) => (
<span
key={tag}
className="brutal-border px-3 py-1 bg-white text-carbon-black text-sm uppercase tracking-wide"
>
<span key={tag} className="brutal-border px-3 py-1 bg-cream text-blue text-sm uppercase tracking-wide">
{tag}
</span>
))}
+1 -4
View File
@@ -26,12 +26,9 @@ export type BaseRecord = {
/**
* PocketBase collection for simple text blocks (Intro, Bio).
* Each collection is named after its section no slug field.
*/
export type PageContentRecord = BaseRecord & {
/**
* Slug corresponding to the parent section
*/
slug: string;
/**
* HTML or Markdown content string
*/
+49 -41
View File
@@ -28,28 +28,26 @@
--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,9 +69,9 @@
--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: 8px 8px 0 var(--blue);
--shadow-brutal-sm: 4px 4px 0 var(--blue);
--shadow-brutal-lg: 12px 12px 0 var(--blue);
/* === GRID === */
--grid-gap: var(--space-3);
@@ -83,10 +81,8 @@
--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);
--color-carbon-black: var(--carbon-black);
--color-cream: var(--cream);
--color-blue: var(--blue);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
@@ -124,15 +120,27 @@
overflow-x: hidden;
}
/* Paper grain texture */
/* Subtle blue-tinted grain on parchment */
body::before {
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;
@@ -150,7 +158,7 @@
font-variation-settings:
"WONK" var(--fraunces-wonk),
"SOFT" var(--fraunces-soft);
color: var(--carbon-black);
color: var(--blue);
}
h1 {
@@ -173,13 +181,13 @@
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;
}
@@ -190,7 +198,7 @@
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;
}
@@ -207,19 +215,19 @@
box-shadow: var(--shadow-brutal-lg);
}
.brutal-border {
border: var(--border-width) solid var(--carbon-black);
border: var(--border-width) solid var(--blue);
}
.brutal-border-top {
border-top: var(--border-width) solid var(--carbon-black);
border-top: var(--border-width) solid var(--blue);
}
.brutal-border-bottom {
border-bottom: var(--border-width) solid var(--carbon-black);
border-bottom: var(--border-width) solid var(--blue);
}
.brutal-border-left {
border-left: var(--border-width) solid var(--carbon-black);
border-left: var(--border-width) solid var(--blue);
}
.brutal-border-right {
border-right: var(--border-width) solid var(--carbon-black);
border-right: var(--border-width) solid var(--blue);
}
/* Animations */
+4 -4
View File
@@ -18,17 +18,17 @@ describe('Badge', () => {
it('applies default variant classes', () => {
render(<Badge variant="default">Tag</Badge>);
const el = screen.getByText('Tag');
expect(el).toHaveClass('bg-carbon-black', 'text-ochre-clay');
expect(el).toHaveClass('bg-blue', 'text-cream');
});
it('applies primary variant classes', () => {
render(<Badge variant="primary">Tag</Badge>);
expect(screen.getByText('Tag')).toHaveClass('bg-burnt-oxide');
expect(screen.getByText('Tag')).toHaveClass('bg-blue');
});
it('applies secondary variant classes', () => {
render(<Badge variant="secondary">Tag</Badge>);
expect(screen.getByText('Tag')).toHaveClass('bg-slate-indigo');
expect(screen.getByText('Tag')).toHaveClass('bg-blue');
});
it('applies outline variant classes', () => {
@@ -38,7 +38,7 @@ describe('Badge', () => {
it('defaults to default variant when unspecified', () => {
render(<Badge>Tag</Badge>);
expect(screen.getByText('Tag')).toHaveClass('bg-carbon-black');
expect(screen.getByText('Tag')).toHaveClass('bg-blue');
});
});
+4 -4
View File
@@ -20,10 +20,10 @@ interface Props {
}
const VARIANTS: Record<BadgeVariant, string> = {
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',
};
/**
+3 -3
View File
@@ -16,11 +16,11 @@ describe('Button', () => {
describe('variants', () => {
it('applies primary variant by default', () => {
render(<Button>Go</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-burnt-oxide');
expect(screen.getByRole('button')).toHaveClass('bg-blue');
});
it('applies secondary variant', () => {
render(<Button variant="secondary">Go</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-slate-indigo');
expect(screen.getByRole('button')).toHaveClass('bg-blue');
});
it('applies outline variant', () => {
render(<Button variant="outline">Go</Button>);
@@ -28,7 +28,7 @@ describe('Button', () => {
});
it('applies ghost variant', () => {
render(<Button variant="ghost">Go</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-ochre-clay');
expect(screen.getByRole('button')).toHaveClass('bg-cream');
});
});
describe('sizes', () => {
+5 -5
View File
@@ -22,10 +22,10 @@ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
}
const VARIANTS: Record<ButtonVariant, string> = {
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',
primary: 'bg-blue text-cream outline-[3px] outline-cream',
secondary: 'bg-blue text-cream outline-[3px] outline-cream',
outline: 'bg-transparent text-blue border-blue',
ghost: 'bg-cream text-blue border-blue',
};
const SIZES: Record<ButtonSize, string> = {
@@ -35,7 +35,7 @@ const SIZES: Record<ButtonSize, string> = {
};
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';
'brutal-border brutal-shadow transition-all duration-200 hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[6px_6px_0_var(--blue)] active:translate-x-[4px] active:translate-y-[4px] active:shadow-[4px_4px_0_var(--blue)] uppercase tracking-wider';
/**
* Brutalist button with variants and sizes.
+5 -9
View File
@@ -13,17 +13,13 @@ describe('Card', () => {
});
});
describe('background variants', () => {
it('defaults to ochre background', () => {
it('defaults to cream background', () => {
const { container } = render(<Card>Content</Card>);
expect(container.firstChild).toHaveClass('bg-ochre-clay');
expect(container.firstChild).toHaveClass('bg-cream');
});
it('applies slate background', () => {
const { container } = render(<Card background="slate">Content</Card>);
expect(container.firstChild).toHaveClass('bg-slate-indigo');
});
it('applies white background', () => {
const { container } = render(<Card background="white">Content</Card>);
expect(container.firstChild).toHaveClass('bg-white');
it('applies blue background', () => {
const { container } = render(<Card background="blue">Content</Card>);
expect(container.firstChild).toHaveClass('bg-blue');
});
});
describe('padding', () => {
+5 -6
View File
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react';
import { cn } from '$shared/lib';
export type CardBackground = 'ochre' | 'slate' | 'white';
export type CardBackground = 'cream' | 'blue';
interface CardProps {
/**
@@ -14,7 +14,7 @@ interface CardProps {
className?: string;
/**
* Background color preset
* @default 'ochre'
* @default 'cream'
*/
background?: CardBackground;
/**
@@ -25,15 +25,14 @@ interface CardProps {
}
const BG: Record<CardBackground, string> = {
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 (
<div className={cn('brutal-border brutal-shadow', BG[background], !noPadding && 'p-6 md:p-8', className)}>
{children}
+5 -5
View File
@@ -13,7 +13,7 @@ interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
}
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';
'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.
@@ -26,7 +26,7 @@ export function Input({ label, error, className, id, ...props }: InputProps) {
return (
<div className="flex flex-col gap-2">
{label && (
<label htmlFor={inputId} className="text-carbon-black">
<label htmlFor={inputId} className="text-blue">
{label}
</label>
)}
@@ -37,7 +37,7 @@ export function Input({ label, error, className, id, ...props }: InputProps) {
{...props}
/>
{error && (
<span id={errorId} className="text-sm text-burnt-oxide">
<span id={errorId} className="text-sm text-blue">
{error}
</span>
)}
@@ -72,7 +72,7 @@ export function Textarea({ label, error, rows = 4, className, id, ...props }: Te
return (
<div className="flex flex-col gap-2">
{label && (
<label htmlFor={textareaId} className="text-carbon-black">
<label htmlFor={textareaId} className="text-blue">
{label}
</label>
)}
@@ -84,7 +84,7 @@ export function Textarea({ label, error, rows = 4, className, id, ...props }: Te
{...props}
/>
{error && (
<span id={errorId} className="text-sm text-burnt-oxide">
<span id={errorId} className="text-sm text-blue">
{error}
</span>
)}
+1
View File
@@ -0,0 +1 @@
export { RichText } from './ui/RichText';
@@ -0,0 +1,45 @@
import { render, screen } from '@testing-library/react';
import { RichText } from './RichText';
describe('RichText', () => {
describe('rendering', () => {
it('renders a paragraph from <p> tag', () => {
render(<RichText html="<p>Hello world</p>" />);
expect(screen.getByText('Hello world').tagName).toBe('P');
});
it('renders bold text from <strong> tag', () => {
render(<RichText html="<strong>Bold</strong>" />);
expect(screen.getByText('Bold').tagName).toBe('STRONG');
});
it('renders a link from <a> tag', () => {
render(<RichText html='<a href="https://example.com">Link</a>' />);
const link = screen.getByRole('link', { name: 'Link' });
expect(link).toHaveAttribute('href', 'https://example.com');
});
it('renders nested tags', () => {
render(<RichText html="<p>Text with <em>emphasis</em></p>" />);
expect(screen.getByText('emphasis').tagName).toBe('EM');
});
it('renders nothing for empty string', () => {
const { container } = render(<RichText html="" />);
expect(container.firstChild).toBeNull();
});
it('renders multiple sibling elements', () => {
render(<RichText html="<p>First</p><p>Second</p>" />);
expect(screen.getByText('First')).toBeInTheDocument();
expect(screen.getByText('Second')).toBeInTheDocument();
});
});
describe('className passthrough', () => {
it('applies className to the wrapper', () => {
const { container } = render(<RichText html="<p>text</p>" className="prose" />);
expect(container.firstChild).toHaveClass('prose');
});
});
});
+29
View File
@@ -0,0 +1,29 @@
import parse from 'html-react-parser';
type Props = {
/**
* HTML string from PocketBase rich-text editor
*/
html: string;
/**
* CSS classes applied to the wrapper div
*/
className?: string;
};
/**
* Renders a PocketBase rich-text HTML string as React elements.
*/
export function RichText({ html, className }: Props) {
if (!html) {
return null;
}
const parsed = parse(html);
if (className) {
return <div className={className}>{parsed}</div>;
}
return <>{parsed}</>;
}
+5 -9
View File
@@ -18,17 +18,13 @@ describe('Section', () => {
});
describe('background variants', () => {
it('defaults to ochre background', () => {
it('defaults to cream background', () => {
const { container } = render(<Section>x</Section>);
expect(container.querySelector('section')).toHaveClass('bg-ochre-clay', 'text-carbon-black');
expect(container.querySelector('section')).toHaveClass('bg-cream', 'text-blue');
});
it('applies slate background', () => {
const { container } = render(<Section background="slate">x</Section>);
expect(container.querySelector('section')).toHaveClass('bg-slate-indigo', 'text-ochre-clay');
});
it('applies white background', () => {
const { container } = render(<Section background="white">x</Section>);
expect(container.querySelector('section')).toHaveClass('bg-white', 'text-carbon-black');
it('applies blue background', () => {
const { container } = render(<Section background="blue">x</Section>);
expect(container.querySelector('section')).toHaveClass('bg-blue', 'text-cream');
});
});
+5 -6
View File
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react';
import { cn } from '$shared/lib';
export type SectionBackground = 'ochre' | 'slate' | 'white';
export type SectionBackground = 'cream' | 'blue';
export type ContainerSize = 'default' | 'wide' | 'ultra-wide';
interface SectionProps {
@@ -11,7 +11,7 @@ interface SectionProps {
children: ReactNode;
/**
* Background color variant
* @default 'ochre'
* @default 'cream'
*/
background?: SectionBackground;
/**
@@ -26,15 +26,14 @@ interface SectionProps {
}
const BACKGROUNDS: Record<SectionBackground, string> = {
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 (
<section className={cn(BACKGROUNDS[background], bordered && 'brutal-border-top brutal-border-bottom', className)}>
{children}
+1 -1
View File
@@ -18,7 +18,7 @@ export function TechStackBrick({ name, className }: TechStackBrickProps) {
return (
<div
className={cn(
'brutal-border brutal-shadow bg-white px-4 py-3 text-center',
'brutal-border brutal-shadow bg-cream px-4 py-3 text-center',
'transition-all duration-200 hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px]',
className,
)}
+1 -1
View File
@@ -6,7 +6,7 @@ export type { CardBackground } from './Card';
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './Card';
export { Input, Textarea } from './Input';
export { RichText } from './RichText';
export type { ContainerSize, SectionBackground } from './Section';
export { Container, Section } from './Section';
export { TechStackBrick, TechStackGrid } from './TechStack';
@@ -1,23 +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<PageContentRecord>('bio', {
filter: 'slug = "bio"',
});
const data = await getFirstRecord<PageContentRecord>('bio');
if (!data) {
notFound();
}
return (
<div className="prose prose-lg dark:prose-invert max-w-none">
<p>{data.content}</p>
</div>
);
return <RichText html={data.content} className="prose prose-lg max-w-none" />;
}
@@ -1,23 +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<PageContentRecord>('intro', {
filter: 'slug = "intro"',
});
const data = await getFirstRecord<PageContentRecord>('intro');
if (!data) {
notFound();
}
return (
<div className="prose prose-lg dark:prose-invert max-w-none">
<p>{data.content}</p>
</div>
);
return <RichText html={data.content} className="prose prose-lg max-w-none" />;
}
+28 -11
View File
@@ -3,7 +3,12 @@ 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', () => {
@@ -19,27 +24,39 @@ describe('MobileNav', () => {
it('menu items are hidden initially', () => {
render(<MobileNav items={ITEMS} />);
expect(screen.queryByRole('button', { name: /about/i })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: /intro/i })).not.toBeInTheDocument();
});
});
describe('navigation items', () => {
it('shows items as links with correct hrefs when open', async () => {
render(<MobileNav items={ITEMS} />);
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 () => {
it('click toggle shows links and changes label to "Close"', async () => {
render(<MobileNav items={ITEMS} />);
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
expect(screen.getByText('About')).toBeInTheDocument();
expect(screen.getByText('Intro')).toBeInTheDocument();
});
it('click item button closes the menu', async () => {
render(<MobileNav items={ITEMS} />);
it('closes menu when pathname changes', async () => {
const { usePathname } = await import('next/navigation');
vi.mocked(usePathname).mockReturnValue('/');
const { rerender } = render(<MobileNav items={ITEMS} />);
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 as HTMLElement);
expect(screen.queryByText('Close')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
vi.mocked(usePathname).mockReturnValue('/bio');
rerender(<MobileNav items={ITEMS} />);
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
});
});
});
+15 -20
View File
@@ -1,9 +1,14 @@
'use client';
import { useState } from 'react';
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
@@ -13,30 +18,25 @@ interface Props {
/**
* 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 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' });
}
// biome-ignore lint/correctness/useExhaustiveDependencies: pathname is the trigger, not a value used inside the callback
useEffect(() => {
setIsOpen(false);
}
}, [pathname]);
return (
<div className="lg:hidden fixed top-0 left-0 right-0 bg-ochre-clay brutal-border-bottom z-50">
<div className="lg:hidden fixed top-0 left-0 right-0 bg-cream brutal-border-bottom z-50">
<div className="px-6 py-4 flex items-center justify-between">
<h4>allmy.work</h4>
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay"
className="brutal-border px-4 py-2 bg-blue text-cream"
>
{isOpen ? 'Close' : 'Menu'}
</button>
@@ -44,12 +44,7 @@ export function MobileNav({ items }: Props) {
{isOpen && (
<div className="px-6 py-6 brutal-border-top space-y-2 max-h-[80vh] overflow-y-auto">
{items.map((item) => (
<button
type="button"
key={item.id}
onClick={() => scrollToSection(item.id)}
className="w-full text-left brutal-border bg-ochre-clay px-4 py-3"
>
<Link key={item.id} href={`/${item.id}`} className="block w-full brutal-border bg-cream px-4 py-3">
<div className={cn('flex items-baseline gap-3')}>
<span className="text-sm opacity-60 font-body">{item.number}</span>
<span
@@ -59,7 +54,7 @@ export function MobileNav({ items }: Props) {
{item.label}
</span>
</div>
</button>
</Link>
))}
</div>
)}
+36 -11
View File
@@ -2,21 +2,23 @@ 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 = class {
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(<SidebarNav items={ITEMS} />);
expect(screen.getByRole('navigation')).toBeInTheDocument();
@@ -50,10 +52,33 @@ describe('SidebarNav', () => {
expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument();
});
it('renders a button for each item', () => {
it('renders a link for each nav item', () => {
render(<SidebarNav items={ITEMS} />);
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThanOrEqual(ITEMS.length);
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(<SidebarNav items={ITEMS} />);
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(<SidebarNav items={ITEMS} />);
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(<SidebarNav items={ITEMS} />);
const firstLink = screen.getByRole('link', { name: /Bio/i });
expect(firstLink).not.toHaveClass('opacity-40');
});
});
});
+34 -52
View File
@@ -1,9 +1,13 @@
'use client';
import { useEffect, useState } from 'react';
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
@@ -13,45 +17,27 @@ interface Props {
/**
* 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 (
<nav className="fixed top-0 left-0 h-screen w-full lg:w-1/3 bg-ochre-clay brutal-border-right hidden lg:block overflow-y-auto z-50">
<nav className="fixed top-0 left-0 h-screen w-full lg:w-1/3 bg-cream brutal-border-right hidden lg:block overflow-y-auto z-50">
<div className="px-8 py-12 space-y-2">
<div className="mb-12">
<h2>Index</h2>
@@ -60,27 +46,23 @@ export function SidebarNav({ items }: Props) {
</div>
</div>
{items.map((item) => {
const isActive = activeSection === item.id;
return (
<button
type="button"
key={item.id}
onClick={() => scrollToSection(item.id)}
className={cn(
'w-full text-left brutal-border bg-ochre-clay px-6 py-4 transition-all duration-300',
isActive
? 'shadow-[12px_12px_0_var(--carbon-black)] opacity-100 translate-x-0'
: 'opacity-40 shadow-none hover:opacity-60',
)}
>
<div className="flex items-baseline gap-4">
<span className="text-sm opacity-60">{item.number}</span>
<span className="font-heading text-xl font-black">{item.label}</span>
</div>
</button>
);
})}
{items.map((item) => (
<Link
key={item.id}
href={`/${item.id}`}
className={cn(
'block w-full text-left brutal-border bg-cream px-6 py-4 transition-all duration-300',
isActive(item)
? 'shadow-[12px_12px_0_var(--blue)] opacity-100 translate-x-0'
: 'opacity-40 shadow-none hover:opacity-60',
)}
>
<div className="flex items-baseline gap-4">
<span className="text-sm opacity-60">{item.number}</span>
<span className="font-heading text-xl font-black">{item.label}</span>
</div>
</Link>
))}
<div className="mt-12 pt-12 brutal-border-top">
<p className="text-sm uppercase tracking-wider mb-4 opacity-60">Quick Links</p>
@@ -23,7 +23,7 @@ describe('UtilityBar', () => {
it('Download CV button has primary variant class', () => {
render(<UtilityBar />);
const btn = screen.getByRole('button', { name: /download cv/i });
expect(btn).toHaveClass('bg-burnt-oxide');
expect(btn).toHaveClass('bg-blue');
});
});
});
+2 -2
View File
@@ -15,11 +15,11 @@ export function UtilityBar() {
}
return (
<div className="fixed bottom-0 left-0 right-0 bg-ochre-clay brutal-border-top z-40">
<div className="fixed bottom-0 left-0 right-0 bg-cream brutal-border-top z-40">
<div className="max-w-[2560px] mx-auto px-6 md:px-12 lg:px-16 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<span className="text-sm uppercase tracking-wider">Contact</span>
<a href={`mailto:${CONTACT_LINKS.email}`} className="text-base hover:text-burnt-oxide transition-colors">
<a href={`mailto:${CONTACT_LINKS.email}`} className="text-base hover:opacity-60 transition-opacity">
{CONTACT_LINKS.email}
</a>
</div>
@@ -1,21 +1,20 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
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', number: '01', order: 1 },
{ ...baseRecord, id: '2', slug: 'bio', title: 'Bio', number: '02', order: 2 },
{ ...baseRecord, id: '3', slug: 'skills', title: 'Skills', number: '03', order: 3 },
{ ...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('initial state', () => {
it('renders the first section as active (h1)', () => {
describe('active section rendering', () => {
it('renders the active section as an h1', () => {
render(
<SectionsAccordion sections={sections}>
<SectionsAccordion sections={sections} activeSlug="intro">
<div>Intro content</div>
<div>Bio content</div>
<div>Skills content</div>
@@ -24,56 +23,49 @@ describe('SectionsAccordion', () => {
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('01. Intro');
});
it('renders remaining sections as collapsed buttons', () => {
it('renders inactive sections as links', () => {
render(
<SectionsAccordion sections={sections}>
<SectionsAccordion sections={sections} activeSlug="intro">
<div>Intro content</div>
<div>Bio content</div>
<div>Skills content</div>
</SectionsAccordion>,
);
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(2);
const links = screen.getAllByRole('link');
expect(links).toHaveLength(2);
});
});
describe('interaction', () => {
it('activates clicked section', async () => {
const user = userEvent.setup();
it('inactive section links point to correct hrefs', () => {
render(
<SectionsAccordion sections={sections}>
<SectionsAccordion sections={sections} activeSlug="intro">
<div>Intro content</div>
<div>Bio content</div>
<div>Skills content</div>
</SectionsAccordion>,
);
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(
<SectionsAccordion sections={sections} activeSlug="bio">
<div>Intro content</div>
<div>Bio content</div>
<div>Skills content</div>
</SectionsAccordion>,
);
await user.click(screen.getByRole('button', { name: /02\. Bio/i }));
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('02. Bio');
});
it('deactivates previously active section after click', async () => {
const user = userEvent.setup();
it('only one section is active at a time', () => {
render(
<SectionsAccordion sections={sections}>
<SectionsAccordion sections={sections} activeSlug="skills">
<div>Intro content</div>
<div>Bio content</div>
<div>Skills content</div>
</SectionsAccordion>,
);
await user.click(screen.getByRole('button', { name: /02\. Bio/i }));
expect(screen.queryByRole('heading', { level: 1, name: /01\. Intro/i })).not.toBeInTheDocument();
});
it('only one section is active at a time', async () => {
const user = userEvent.setup();
render(
<SectionsAccordion sections={sections}>
<div>Intro content</div>
<div>Bio content</div>
<div>Skills content</div>
</SectionsAccordion>,
);
await user.click(screen.getByRole('button', { name: /03\. Skills/i }));
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1);
});
});
@@ -81,7 +73,7 @@ describe('SectionsAccordion', () => {
describe('content slots', () => {
it('shows active section content', () => {
render(
<SectionsAccordion sections={sections}>
<SectionsAccordion sections={sections} activeSlug="intro">
<div>Intro content</div>
<div>Bio content</div>
<div>Skills content</div>
@@ -90,17 +82,16 @@ describe('SectionsAccordion', () => {
expect(screen.getByText('Intro content')).toBeInTheDocument();
});
it('shows correct content after switching sections', async () => {
const user = userEvent.setup();
it('does not show inactive section content', () => {
render(
<SectionsAccordion sections={sections}>
<SectionsAccordion sections={sections} activeSlug="intro">
<div>Intro content</div>
<div>Bio content</div>
<div>Skills content</div>
</SectionsAccordion>,
);
await user.click(screen.getByRole('button', { name: /02\. Bio/i }));
expect(screen.getByText('Bio content')).toBeInTheDocument();
expect(screen.queryByText('Bio content')).not.toBeInTheDocument();
expect(screen.queryByText('Skills content')).not.toBeInTheDocument();
});
});
});
@@ -1,7 +1,5 @@
'use client';
import type { ReactNode } from 'react';
import { Children, useState } from 'react';
import { Children } from 'react';
import type { SectionRecord } from '$entities/Section';
import { SectionAccordion } from '$entities/Section';
@@ -10,6 +8,11 @@ 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
*/
@@ -17,11 +20,11 @@ type Props = {
};
/**
* Manages accordion open/close state across all portfolio sections.
* Receives RSC content as opaque children slots, matched positionally to sections.
* 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, children }: Props) {
const [activeSlug, setActiveSlug] = useState(sections[0]?.slug ?? '');
export function SectionsAccordion({ sections, activeSlug, children }: Props) {
const slots = Children.toArray(children);
return (
@@ -30,10 +33,10 @@ export function SectionsAccordion({ sections, children }: Props) {
<SectionAccordion
key={section.slug}
id={section.slug}
number={section.number}
number={String(section.order).padStart(2, '0')}
title={section.title}
isActive={activeSlug === section.slug}
onClick={() => setActiveSlug(section.slug)}
href={`/${section.slug}`}
>
{slots[i]}
</SectionAccordion>
+10 -10
View File
@@ -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, '.'),
},
},
})
});
+118
View File
@@ -3239,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"
@@ -3288,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"
@@ -4291,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"
@@ -4307,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"
@@ -4390,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"
@@ -5859,6 +5951,7 @@ __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"
@@ -6016,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"
@@ -6752,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"