Compare commits

...

7 Commits

Author SHA1 Message Date
Ilia Mashkov 76f5b269f8 refactor: use shadow theme tokens, remove ProjectCard translate-hover
Replace inline var(--blue) arbitrary shadow values with typed theme
tokens (shadow-brutal-xl, shadow-brutal-2xl). Remove translate on
ProjectCard hover — shadow-only interaction is less distracting in
a dense grid layout.
2026-05-16 19:04:37 +03:00
Ilia Mashkov b8b5e65497 feat: constrain section content width with max-w-section
Adds max-w-section (56rem via --container-section token) to the
experience, projects, and skills section wrappers for consistent
readable line length across all content areas.
2026-05-16 19:04:27 +03:00
Ilia Mashkov e63de14515 feat: apply RichText to content sections and experience cards
ExperienceCard description switches from a plain <p> to RichText so
rich-text HTML from PocketBase renders correctly. BioSection and
IntroSection drop the prose class overrides — RichText handles
typography consistently.
2026-05-16 19:04:18 +03:00
Ilia Mashkov dfc3ed4715 feat: editorial typography via RichText component
Always wraps content in .rich-text: max-width 65ch, onum figures,
hanging punctuation, pretty text-wrap, auto hyphens, 1.65 line-height,
and 1.2em paragraph spacing. className prop merges additonal classes.
2026-05-16 19:04:08 +03:00
Ilia Mashkov a77cd43749 feat: Button elevation hover/active effect with snap shadow
Variants now use brutal shadow tokens. On hover the button translates
up-left (−0.5px), on active down-right (+0.5px). Only transform animates
(130ms ease-out); shadow snaps instantly so the eye reads button movement
not shadow resize. Primary keeps rgba alpha shadow; secondary/outline use
solid brutal tokens.
2026-05-16 19:04:00 +03:00
Ilia Mashkov 8db4f81f70 refactor: simplify section body animation to hard-cut on navigation 2026-05-16 19:03:50 +03:00
Ilia Mashkov f1049624f7 refactor: design tokens — shadow scale, animation timing, section width
- Expand brutal shadow scale: xs (1px) through 2xl (12px)
- Add --ease-micro cubic-bezier for fast micro-interactions
- Tune --duration-normal 200ms→150ms, --duration-spring 380ms→220ms
- Add --section-content-width and register as --container-section in @theme inline
- Register all brutal shadow tokens in @theme inline for Tailwind utility generation
- Add .btn-transition utility (transform-only, shadow snaps instantly)
- Add .rich-text editorial typography class with magazine-quality settings
- Remove section-body blur-out/slide-in view transition animations
2026-05-16 19:03:43 +03:00
13 changed files with 62 additions and 86 deletions
@@ -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
View File
@@ -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);
}
}
+11 -6
View File
@@ -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.
+4 -8
View File
@@ -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} />;
} }
+1 -1
View File
@@ -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>