Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76f5b269f8 | |||
| b8b5e65497 | |||
| e63de14515 | |||
| dfc3ed4715 | |||
| a77cd43749 | |||
| 8db4f81f70 | |||
| f1049624f7 |
@@ -50,10 +50,10 @@ describe('ExperienceCard', () => {
|
|||||||
expect(company).toHaveClass('opacity-80');
|
expect(company).toHaveClass('opacity-80');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('description paragraph has text-base and max-w-[700px]', () => {
|
it('description renders via RichText with rich-text class', () => {
|
||||||
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
render(<ExperienceCard {...DEFAULT_PROPS} />);
|
||||||
const desc = screen.getByText('Built scalable frontend systems.');
|
const desc = screen.getByText('Built scalable frontend systems.');
|
||||||
expect(desc).toHaveClass('text-base', 'max-w-[700px]');
|
expect(desc.closest('.rich-text')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('card has brutal-border class (from Card component)', () => {
|
it('card has brutal-border class (from Card component)', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Card } from '$shared/ui';
|
import { Card, RichText } from '$shared/ui';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
@@ -36,7 +36,7 @@ export function ExperienceCard({ title, company, period, description, className
|
|||||||
</div>
|
</div>
|
||||||
<span className="brutal-border px-4 py-2 bg-blue text-cream 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>
|
</div>
|
||||||
<p className="text-base max-w-[700px]">{description}</p>
|
<RichText html={description} />
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ describe('ProjectCard', () => {
|
|||||||
it('card has hover transition classes', () => {
|
it('card has hover transition classes', () => {
|
||||||
const { container } = render(<ProjectCard {...DEFAULT_PROPS} />);
|
const { container } = render(<ProjectCard {...DEFAULT_PROPS} />);
|
||||||
const card = container.firstChild as HTMLElement;
|
const card = container.firstChild as HTMLElement;
|
||||||
expect(card).toHaveClass('group', 'transition-all', 'duration-300');
|
expect(card).toHaveClass('group', 'transition-shadow', 'duration-300');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('year badge has correct classes', () => {
|
it('year badge has correct classes', () => {
|
||||||
|
|||||||
@@ -30,12 +30,7 @@ type Props = {
|
|||||||
*/
|
*/
|
||||||
export function ProjectCard({ title, year, description, tags, imageUrl }: Props) {
|
export function ProjectCard({ title, year, description, tags, imageUrl }: Props) {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card className={cn('group hover:shadow-brutal-xl transition-shadow duration-300')}>
|
||||||
className={cn(
|
|
||||||
'group hover:translate-x-[2px] hover:translate-y-[2px]',
|
|
||||||
'hover:shadow-[10px_10px_0_var(--blue)] transition-all duration-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex flex-row justify-between items-start mb-3">
|
<div className="flex flex-row justify-between items-start mb-3">
|
||||||
<CardTitle className="flex-1">{title}</CardTitle>
|
<CardTitle className="flex-1">{title}</CardTitle>
|
||||||
|
|||||||
+35
-55
@@ -69,21 +69,27 @@
|
|||||||
--radius: 0px;
|
--radius: 0px;
|
||||||
|
|
||||||
/* === BRUTALIST SHADOWS === */
|
/* === BRUTALIST SHADOWS === */
|
||||||
--shadow-brutal: 8px 8px 0 var(--blue);
|
--shadow-brutal-xs: 1px 1px 0 var(--blue);
|
||||||
--shadow-brutal-sm: 4px 4px 0 var(--blue);
|
--shadow-brutal-sm: 3px 3px 0 var(--blue);
|
||||||
--shadow-brutal-lg: 12px 12px 0 var(--blue);
|
--shadow-brutal: 5px 5px 0 var(--blue);
|
||||||
|
--shadow-brutal-md: 7px 7px 0 var(--blue);
|
||||||
|
--shadow-brutal-lg: 8px 8px 0 var(--blue);
|
||||||
|
--shadow-brutal-xl: 10px 10px 0 var(--blue);
|
||||||
|
--shadow-brutal-2xl: 12px 12px 0 var(--blue);
|
||||||
|
|
||||||
/* === GRID === */
|
/* === GRID === */
|
||||||
--grid-gap: var(--space-3);
|
--grid-gap: var(--space-3);
|
||||||
|
--section-content-width: 56rem;
|
||||||
|
|
||||||
/* === ANIMATION === */
|
/* === ANIMATION === */
|
||||||
--ease-default: ease;
|
--ease-default: ease;
|
||||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
--ease-decelerate: cubic-bezier(0.25, 0, 0, 1);
|
--ease-decelerate: cubic-bezier(0.25, 0, 0, 1);
|
||||||
|
--ease-micro: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
--duration-fast: 100ms;
|
--duration-fast: 100ms;
|
||||||
--duration-normal: 200ms;
|
--duration-normal: 150ms;
|
||||||
--duration-slow: 350ms;
|
--duration-slow: 350ms;
|
||||||
--duration-spring: 380ms;
|
--duration-spring: 220ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -110,6 +116,15 @@
|
|||||||
--radius-sm: var(--radius);
|
--radius-sm: var(--radius);
|
||||||
--radius-md: var(--radius);
|
--radius-md: var(--radius);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
|
--container-section: var(--section-content-width);
|
||||||
|
|
||||||
|
--shadow-brutal-xs: var(--shadow-brutal-xs);
|
||||||
|
--shadow-brutal-sm: var(--shadow-brutal-sm);
|
||||||
|
--shadow-brutal: var(--shadow-brutal);
|
||||||
|
--shadow-brutal-md: var(--shadow-brutal-md);
|
||||||
|
--shadow-brutal-lg: var(--shadow-brutal-lg);
|
||||||
|
--shadow-brutal-xl: var(--shadow-brutal-xl);
|
||||||
|
--shadow-brutal-2xl: var(--shadow-brutal-2xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -213,6 +228,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Button elevation transition — only transform animates; shadow snaps instantly */
|
||||||
|
.btn-transition {
|
||||||
|
transition: transform 0.13s var(--ease-micro);
|
||||||
|
}
|
||||||
|
|
||||||
/* Brutalist utility classes */
|
/* Brutalist utility classes */
|
||||||
.brutal-shadow {
|
.brutal-shadow {
|
||||||
box-shadow: var(--shadow-brutal);
|
box-shadow: var(--shadow-brutal);
|
||||||
@@ -239,20 +259,18 @@
|
|||||||
border-right: var(--border-width) solid var(--blue);
|
border-right: var(--border-width) solid var(--blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Section content enter animation (initial render, no navigation) */
|
/* Editorial rich-text typography */
|
||||||
.section-content {
|
.rich-text {
|
||||||
opacity: 1;
|
max-width: 65ch;
|
||||||
transform: translateY(0);
|
line-height: 1.65;
|
||||||
transition:
|
font-feature-settings: "onum";
|
||||||
opacity var(--duration-slow) var(--ease-default),
|
hanging-punctuation: first last;
|
||||||
transform var(--duration-slow) var(--ease-default);
|
text-wrap: pretty;
|
||||||
|
hyphens: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@starting-style {
|
.rich-text p + p {
|
||||||
.section-content {
|
margin-top: 1.2em;
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(12px);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Cross-section view transition (navigation between sections) */
|
/* Cross-section view transition (navigation between sections) */
|
||||||
@@ -291,41 +309,3 @@
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Section body: instant blur-out, clean slide-in */
|
|
||||||
::view-transition-old(section-body) {
|
|
||||||
animation-name: section-body-out;
|
|
||||||
animation-duration: var(--duration-fast);
|
|
||||||
animation-timing-function: var(--ease-default);
|
|
||||||
animation-fill-mode: both;
|
|
||||||
}
|
|
||||||
|
|
||||||
::view-transition-new(section-body) {
|
|
||||||
animation-name: section-body-in;
|
|
||||||
animation-duration: var(--duration-slow);
|
|
||||||
animation-delay: var(--duration-normal);
|
|
||||||
animation-timing-function: var(--ease-decelerate);
|
|
||||||
animation-fill-mode: both;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes section-body-out {
|
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
filter: blur(0);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
filter: blur(3px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes section-body-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(16px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -22,10 +22,14 @@ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const VARIANTS: Record<ButtonVariant, string> = {
|
const VARIANTS: Record<ButtonVariant, string> = {
|
||||||
primary: 'bg-blue text-cream outline-[3px] outline-cream',
|
primary:
|
||||||
secondary: 'bg-blue text-cream outline-[3px] outline-cream',
|
'brutal-border bg-blue text-cream shadow-[5px_5px_0_rgba(4,28,243,0.35)] hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-[7px_7px_0_rgba(4,28,243,0.35)] active:translate-x-0.5 active:translate-y-0.5 active:shadow-[1px_1px_0_rgba(4,28,243,0.35)]',
|
||||||
outline: 'bg-transparent text-blue border-blue',
|
secondary:
|
||||||
ghost: 'bg-cream text-blue border-blue',
|
'brutal-border bg-blue text-cream shadow-brutal hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-brutal-md active:translate-x-0.5 active:translate-y-0.5 active:shadow-brutal-xs',
|
||||||
|
outline:
|
||||||
|
'brutal-border bg-transparent text-blue shadow-brutal hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-brutal-md active:translate-x-0.5 active:translate-y-0.5 active:shadow-brutal-xs',
|
||||||
|
ghost:
|
||||||
|
'border-[3px] border-solid border-blue/35 bg-cream text-blue hover:border-blue hover:bg-blue/10 active:bg-blue active:text-cream',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SIZES: Record<ButtonSize, string> = {
|
const SIZES: Record<ButtonSize, string> = {
|
||||||
@@ -34,8 +38,9 @@ const SIZES: Record<ButtonSize, string> = {
|
|||||||
lg: 'px-8 py-4 text-lg',
|
lg: 'px-8 py-4 text-lg',
|
||||||
};
|
};
|
||||||
|
|
||||||
const BASE =
|
/* box-shadow excluded from transition intentionally — snaps instantly so the
|
||||||
'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';
|
* eye follows the 130ms button movement, not the shadow change. */
|
||||||
|
const BASE = 'btn-transition uppercase tracking-wider';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Brutalist button with variants and sizes.
|
* Brutalist button with variants and sizes.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import parse from 'html-react-parser';
|
import parse from 'html-react-parser';
|
||||||
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
@@ -6,24 +7,19 @@ type Props = {
|
|||||||
*/
|
*/
|
||||||
html: string;
|
html: string;
|
||||||
/**
|
/**
|
||||||
* CSS classes applied to the wrapper div
|
* Additional CSS classes merged onto the wrapper div
|
||||||
*/
|
*/
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a PocketBase rich-text HTML string as React elements.
|
* Renders a PocketBase rich-text HTML string as React elements.
|
||||||
|
* Always applies editorial magazine typography via the rich-text CSS class.
|
||||||
*/
|
*/
|
||||||
export function RichText({ html, className }: Props) {
|
export function RichText({ html, className }: Props) {
|
||||||
if (!html) {
|
if (!html) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = parse(html);
|
return <div className={cn('rich-text', className)}>{parse(html)}</div>;
|
||||||
|
|
||||||
if (className) {
|
|
||||||
return <div className={className}>{parsed}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{parsed}</>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ export default async function BioSection() {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return <RichText html={data.content} className="prose prose-lg max-w-none" />;
|
return <RichText html={data.content} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default async function ExperienceSection() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 max-w-section">
|
||||||
{items.map((exp) => (
|
{items.map((exp) => (
|
||||||
<ExperienceCard
|
<ExperienceCard
|
||||||
key={exp.id}
|
key={exp.id}
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ export default async function IntroSection() {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return <RichText html={data.content} className="prose prose-lg max-w-none" />;
|
return <RichText html={data.content} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function SidebarNav({ items }: Props) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'block w-full text-left brutal-border bg-cream 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)
|
isActive(item)
|
||||||
? 'shadow-[12px_12px_0_var(--blue)] opacity-100 translate-x-0'
|
? 'shadow-brutal-2xl opacity-100 translate-x-0'
|
||||||
: 'opacity-40 shadow-none hover:opacity-60',
|
: 'opacity-40 shadow-none hover:opacity-60',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default async function ProjectsSection() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-section">
|
||||||
{items.map((project) => (
|
{items.map((project) => (
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
key={project.id}
|
key={project.id}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default async function SkillsSection() {
|
|||||||
const categories = groupByKey(data.items, 'category');
|
const categories = groupByKey(data.items, 'category');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-12">
|
<div className="space-y-12 max-w-section">
|
||||||
{Object.entries(categories).map(([category, items]) => (
|
{Object.entries(categories).map(([category, items]) => (
|
||||||
<div key={category} className="space-y-4">
|
<div key={category} className="space-y-4">
|
||||||
<h3 className="text-xl font-bold uppercase tracking-widest opacity-50">{category}</h3>
|
<h3 className="text-xl font-bold uppercase tracking-widest opacity-50">{category}</h3>
|
||||||
|
|||||||
Reference in New Issue
Block a user