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.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
+49
-41
@@ -28,28 +28,26 @@
|
||||
--fraunces-wonk: 1;
|
||||
--fraunces-soft: 0;
|
||||
|
||||
/* === COLOR PALETTE === */
|
||||
--vibrant-blue: #041cf3;
|
||||
--paper-white: #ffffff;
|
||||
--carbon-black: #121212;
|
||||
--structure-gray: #f2f2f2;
|
||||
/* === COLOR PALETTE: 2-color system === */
|
||||
--cream: #f4f0e8;
|
||||
--blue: #041cf3;
|
||||
|
||||
/* === SEMANTIC COLORS === */
|
||||
--background: var(--paper-white);
|
||||
--foreground: var(--carbon-black);
|
||||
--card: var(--paper-white);
|
||||
--card-foreground: var(--carbon-black);
|
||||
--primary: var(--vibrant-blue);
|
||||
--primary-foreground: var(--paper-white);
|
||||
--secondary: var(--structure-gray);
|
||||
--secondary-foreground: var(--carbon-black);
|
||||
--muted: var(--structure-gray);
|
||||
--muted-foreground: #666666;
|
||||
--accent: var(--vibrant-blue);
|
||||
--accent-foreground: var(--paper-white);
|
||||
--destructive: #d4183d;
|
||||
--border: var(--carbon-black);
|
||||
--ring: var(--vibrant-blue);
|
||||
--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 */
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
secondary: 'bg-blue text-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.
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
@@ -30,13 +30,13 @@ export function MobileNav({ items }: Props) {
|
||||
}, [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,7 +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) => (
|
||||
<Link key={item.id} href={`/${item.id}`} className="block w-full 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
|
||||
|
||||
@@ -37,7 +37,7 @@ export function SidebarNav({ items }: Props) {
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -51,9 +51,9 @@ export function SidebarNav({ items }: Props) {
|
||||
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',
|
||||
'block w-full text-left brutal-border bg-cream px-6 py-4 transition-all duration-300',
|
||||
isActive(item)
|
||||
? 'shadow-[12px_12px_0_var(--carbon-black)] opacity-100 translate-x-0'
|
||||
? 'shadow-[12px_12px_0_var(--blue)] opacity-100 translate-x-0'
|
||||
: 'opacity-40 shadow-none hover:opacity-60',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user