Merge pull request 'Feature/brutalism overhaul' (#10) from feature/brutalism-overhaul into main
Build and push / build (push) Successful in 3m15s
Build and push / build (push) Successful in 3m15s
Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
@@ -1,36 +1,67 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# portfolio
|
||||
|
||||
## Getting Started
|
||||
Personal portfolio site. Next.js App Router frontend, PocketBase as the CMS,
|
||||
rendered with on-demand ISR — content is served static and revalidated by
|
||||
webhook when the CMS changes.
|
||||
|
||||
First, run the development server:
|
||||
## Stack
|
||||
|
||||
- **Next.js 16** (App Router, React 19, standalone output)
|
||||
- **TypeScript** (strict)
|
||||
- **Tailwind CSS v4**
|
||||
- **PocketBase** — headless CMS, fetched at build/runtime via a typed client
|
||||
- **Biome** — lint + format
|
||||
- **Vitest** + Testing Library — unit/component tests
|
||||
- **Storybook** (a11y + Vitest addons) — component workshop
|
||||
- **Yarn 4**
|
||||
|
||||
Architecture follows [Feature-Sliced Design](https://feature-sliced.design/)
|
||||
adapted to Next.js (`src/{shared,entities,widgets}` + `app/`).
|
||||
|
||||
## Rendering
|
||||
|
||||
Pages are statically generated and served through ISR. There is no fixed TTL —
|
||||
PocketBase calls the `/api/revalidate` webhook after any content mutation,
|
||||
which purges the matching cache tag (`revalidateTag`). See
|
||||
[`app/api/revalidate/route.ts`](app/api/revalidate/route.ts).
|
||||
|
||||
## Getting started
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn install
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
Open http://localhost:3000.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
Without a reachable PocketBase, the app falls back to fixtures in
|
||||
[`app/api/collections/[collection]/records/route.ts`](app/api/collections/%5Bcollection%5D/records/route.ts).
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
## Environment
|
||||
|
||||
## Learn More
|
||||
```
|
||||
PB_URL # internal PocketBase URL (server-side fetches)
|
||||
PB_PUBLIC_URL # public PocketBase URL (image optimizer allowlist, build-time)
|
||||
REVALIDATE_SECRET # shared secret for the /api/revalidate webhook
|
||||
```
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
`PB_PUBLIC_URL` must be present at `next build` time — `images.remotePatterns`
|
||||
is frozen into the build.
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
## Scripts
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `yarn dev` | Dev server |
|
||||
| `yarn build` | Production build (standalone) |
|
||||
| `yarn start` | Serve the build |
|
||||
| `yarn test` | Run tests (Vitest) |
|
||||
| `yarn check` | Lint + format (Biome, writes fixes) |
|
||||
| `yarn check:ci` | Lint + format check (no writes) |
|
||||
| `yarn storybook` | Storybook on :6006 |
|
||||
|
||||
## Deploy on Vercel
|
||||
## Deployment
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
Built as a standalone Docker image; PocketBase reached over the internal
|
||||
Docker network. Content updates trigger the revalidation webhook — no rebuild
|
||||
needed.
|
||||
|
||||
@@ -21,6 +21,9 @@ const nextConfig: NextConfig = {
|
||||
experimental: {
|
||||
viewTransition: true,
|
||||
},
|
||||
turbopack: {
|
||||
root: __dirname,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -41,7 +41,8 @@ export function ExperienceCard({ title, company, period, description, stack, cla
|
||||
<p className="text-sm font-medium brutal-border-left pl-3">{period}</p>
|
||||
<p className="text-lg font-black">{company}</p>
|
||||
{stack.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
/* Golden-ratio cap for the stacked (top) tablet layout; full width on mobile, and lg sidebar is already width-constrained. */
|
||||
<div className="flex flex-wrap gap-2 sm:max-w-golden lg:max-w-none">
|
||||
{stack.map((tech) => (
|
||||
<Badge key={tech} variant="outline" size="xs">
|
||||
{tech}
|
||||
|
||||
@@ -47,7 +47,8 @@ export function ProjectCard({ title, year, description, tags, url, imageUrl, pri
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm font-medium brutal-border-left pl-3">{year}</p>
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
/* Golden-ratio cap for the stacked (top) tablet layout; full width on mobile, and lg sidebar is already width-constrained. */
|
||||
<div className="flex flex-wrap gap-2 sm:max-w-golden lg:max-w-none">
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline" size="xs">
|
||||
{tag}
|
||||
|
||||
+120
-25
@@ -69,17 +69,21 @@
|
||||
--space-20: 10rem;
|
||||
|
||||
/* === BORDERS === */
|
||||
--border-width: 3px;
|
||||
--radius: 0px;
|
||||
--border-width: 1.5px;
|
||||
--radius: 5px;
|
||||
|
||||
/* === BRUTALIST SHADOWS === */
|
||||
--shadow-brutal-xs: 1px 1px 0 var(--blue);
|
||||
--shadow-brutal-sm: 3px 3px 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);
|
||||
/* Muted offset shadow — quieter than the old hard-blue brutalist stamp.
|
||||
* Softer alpha + tighter offsets keep the offset-shadow feel, just lighter. */
|
||||
--shadow-color: rgba(4, 28, 243, 0.25);
|
||||
|
||||
/* === OFFSET SHADOWS === */
|
||||
--shadow-brutal-xs: 1px 1px 0 var(--shadow-color);
|
||||
--shadow-brutal-sm: 2px 2px 0 var(--shadow-color);
|
||||
--shadow-brutal: 3px 3px 0 var(--shadow-color);
|
||||
--shadow-brutal-md: 3px 3px 0 var(--shadow-color);
|
||||
--shadow-brutal-lg: 4px 4px 0 var(--shadow-color);
|
||||
--shadow-brutal-xl: 5px 5px 0 var(--shadow-color);
|
||||
--shadow-brutal-2xl: 6px 6px 0 var(--shadow-color);
|
||||
|
||||
/* === GRID === */
|
||||
--grid-gap: var(--space-3);
|
||||
@@ -151,7 +155,12 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Background lives on html (not body) so the grain overlay can sit behind
|
||||
* body's content at a negative z-index and still paint against the cream
|
||||
* canvas — with the background on body, a z-index:-1 body::before would hide
|
||||
* behind body's own background instead of showing through the page gaps. */
|
||||
html {
|
||||
@apply bg-background;
|
||||
font-size: var(--font-size);
|
||||
scroll-behavior: smooth;
|
||||
/* Reserve scrollbar gutter so locking body scroll (e.g. when a modal
|
||||
@@ -160,23 +169,24 @@
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply text-foreground;
|
||||
font-family: var(--font-body);
|
||||
font-weight: var(--font-weight-body);
|
||||
line-height: var(--line-height-tight);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Page-wide blue dot grain overlay. z-index 100 puts it above the footer
|
||||
* (z-50) so the grain reads as continuous across the entire viewport;
|
||||
* pointer-events: none keeps everything clickable through it. */
|
||||
/* Page-wide blue dot grain. z-index -1 drops it behind body's content so
|
||||
* opaque surfaces (cards, badges, footer) cover it and the grain only shows
|
||||
* through the transparent page gaps; pointer-events: none keeps everything
|
||||
* clickable through it. */
|
||||
body::before {
|
||||
@apply grain-pattern;
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
h1,
|
||||
@@ -231,9 +241,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Button elevation transition — only transform animates; shadow snaps instantly */
|
||||
.btn-transition {
|
||||
transition: transform 0.13s var(--ease-micro);
|
||||
/* Button elevation transition — transform + shadow animate together over 130ms
|
||||
* on the micro easing curve; the spring feel comes from the curve, no keyframes. */
|
||||
@utility btn-transition {
|
||||
transition: transform 130ms var(--ease-micro);
|
||||
}
|
||||
|
||||
/* Offset "shadow" as a static pseudo-block instead of box-shadow. box-shadow
|
||||
* swims because it repaints on the main thread while the button's translate
|
||||
* runs on the compositor — the two desync per frame. Here both the button and
|
||||
* its ::before are transforms, so they stay frame-synced. The ::before counter-
|
||||
* translates to hold a fixed 3px world offset while the button lifts/presses,
|
||||
* so the shadow appears frozen and only the button moves. */
|
||||
.btn-shadow {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
transition: transform 130ms var(--ease-micro);
|
||||
}
|
||||
.btn-shadow::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
/* ponytail: inset:0 tracks the padding box, ~1.5px shy of the border box;
|
||||
* imperceptible on a hard offset block. Widen with negative inset if it shows. */
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
background: var(--shadow-color);
|
||||
transform: translate(3px, 3px);
|
||||
transition: transform 130ms var(--ease-micro);
|
||||
}
|
||||
.btn-shadow:hover {
|
||||
transform: translate(-2px, -2px);
|
||||
}
|
||||
.btn-shadow:hover::before {
|
||||
transform: translate(5px, 5px);
|
||||
}
|
||||
.btn-shadow:active {
|
||||
transform: translate(2px, 2px);
|
||||
}
|
||||
.btn-shadow:active::before {
|
||||
transform: translate(1px, 1px);
|
||||
}
|
||||
|
||||
/* Brutalist utility classes */
|
||||
@@ -246,8 +293,16 @@
|
||||
.brutal-shadow-lg {
|
||||
box-shadow: var(--shadow-brutal-lg);
|
||||
}
|
||||
/* Golden-ratio (1/φ ≈ 0.618) cap for badge/tag groups so they form a tidy
|
||||
* block instead of spanning the full width. Apply with a breakpoint prefix
|
||||
* (e.g. sm:) — on mobile the group should use the full available width. */
|
||||
@utility max-w-golden {
|
||||
max-width: 61.8%;
|
||||
}
|
||||
|
||||
@utility brutal-border {
|
||||
border: var(--border-width) solid var(--blue);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
@utility brutal-border-top {
|
||||
border-top: var(--border-width) solid var(--blue);
|
||||
@@ -266,6 +321,7 @@
|
||||
* ancestor must not have overflow:hidden or the outline gets clipped. */
|
||||
@utility brutal-outline {
|
||||
outline: var(--border-width) solid var(--blue);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
/* Tiled blue dot pattern — applied to body::before (page-wide) and reusable
|
||||
* on any surface that should share the same paper-grain texture. The SVG
|
||||
@@ -329,16 +385,28 @@
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Some CMS output wraps li text in a block <p> (<li><p>…</p></li>), which drops
|
||||
* the text below the marker. Keep it inline so marker and text share a line. */
|
||||
.rich-text ul li > p {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rich-text ul li::before {
|
||||
content: "◆";
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: calc(1.5em / 0.55);
|
||||
/* reset inherited text-indent so glyph isn't shifted inside the ::before box */
|
||||
/* 0.5em glyph + 1em gap = 1.5em advance, filling the hanging indent */
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
margin-right: 1em;
|
||||
/* reset inherited text-indent so the box isn't shifted */
|
||||
text-indent: 0;
|
||||
color: var(--blue);
|
||||
font-size: 0.55em;
|
||||
/* line-height matches parent so diamond centers within the first line box */
|
||||
line-height: calc(var(--line-height-relaxed) / 0.55);
|
||||
background: var(--blue);
|
||||
/* squircle: corner-shape is progressive enhancement, ignored where unsupported */
|
||||
border-radius: 30%;
|
||||
/* biome-ignore lint/correctness/noUnknownProperty: corner-shape is valid CSS, newer than Biome's property list */
|
||||
corner-shape: squircle;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Cross-section view transition (navigation between sections) */
|
||||
@@ -415,6 +483,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Per-item slide-in reusing the section-body distance/easing. Stagger via
|
||||
* --stagger-index set inline by the consumer. */
|
||||
.stagger-in {
|
||||
animation: stagger-in var(--duration-spring) var(--ease-spring) both;
|
||||
animation-delay: calc(var(--stagger-index, 0) * var(--stagger-step, 50ms));
|
||||
}
|
||||
|
||||
@keyframes stagger-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(var(--slide-section-body-in));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.stagger-in {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Page-load entry animation for the footer. Previously also carried
|
||||
* `view-transition-name: site-footer` to layer above section-body's slide
|
||||
* group, but the section-body group now has `animation: none` (purely
|
||||
@@ -473,6 +565,9 @@ dialog.lightbox {
|
||||
box-sizing: content-box;
|
||||
max-width: calc(100vw - 2rem);
|
||||
max-height: calc(100vh - 10rem);
|
||||
/* Match the wrapper's brutal-outline radius so image corners sit flush
|
||||
* inside the rounded outline instead of poking past it. */
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Disable transition animation for Firefox
|
||||
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
import 'react';
|
||||
|
||||
/**
|
||||
* Allow CSS custom properties in `style` objects without casts.
|
||||
*/
|
||||
declare module 'react' {
|
||||
interface CSSProperties {
|
||||
[key: `--${string}`]: string | number | undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { cn } from '$shared/lib';
|
||||
|
||||
export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'outline';
|
||||
export type BadgeSize = 'xs' | 'sm' | 'md';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Badge content
|
||||
*/
|
||||
children: ReactNode;
|
||||
interface Props extends Pick<ComponentPropsWithoutRef<'span'>, 'children' | 'className' | 'style'> {
|
||||
/**
|
||||
* Visual variant
|
||||
* @default 'default'
|
||||
@@ -19,10 +15,6 @@ interface Props {
|
||||
* @default 'sm'
|
||||
*/
|
||||
size?: BadgeSize;
|
||||
/**
|
||||
* Additional CSS classes
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const VARIANTS: Record<BadgeVariant, string> = {
|
||||
@@ -41,9 +33,17 @@ const SIZES: Record<BadgeSize, string> = {
|
||||
/**
|
||||
* Small label for categorization or status.
|
||||
*/
|
||||
export function Badge({ children, variant = 'default', size = 'sm', className }: Props) {
|
||||
export function Badge({ children, variant = 'default', size = 'sm', className, ...rest }: Props) {
|
||||
return (
|
||||
<span className={cn('inline-block uppercase tracking-wider', SIZES[size], VARIANTS[variant], className)}>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center leading-none uppercase tracking-wider',
|
||||
SIZES[size],
|
||||
VARIANTS[variant],
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -41,14 +41,12 @@ function isAnchorProps(props: RestButton | RestAnchor): props is RestAnchor {
|
||||
}
|
||||
|
||||
const VARIANTS = {
|
||||
primary:
|
||||
'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)]',
|
||||
secondary:
|
||||
'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',
|
||||
primary: 'brutal-border bg-blue text-cream btn-shadow',
|
||||
secondary: 'brutal-border bg-blue text-cream btn-shadow',
|
||||
outline:
|
||||
'brutal-border border-blue/35 bg-cream text-blue hover:border-blue hover:bg-blue/10 active:bg-blue active:text-cream',
|
||||
ghost:
|
||||
'brutal-border bg-transparent text-blue hover:-translate-x-0.5 hover:-translate-y-0.5 active:translate-x-0.5 active:translate-y-0.5',
|
||||
'brutal-border bg-transparent text-blue btn-transition hover:-translate-x-0.5 hover:-translate-y-0.5 active:translate-x-0.5 active:translate-y-0.5',
|
||||
} as const satisfies Record<ButtonVariant, string>;
|
||||
|
||||
const SIZES = {
|
||||
@@ -57,9 +55,9 @@ const SIZES = {
|
||||
lg: 'px-8 py-4 text-lg',
|
||||
} as const satisfies Record<ButtonSize, string>;
|
||||
|
||||
/* box-shadow excluded from transition intentionally — snaps instantly so the
|
||||
* eye follows the 130ms button movement, not the shadow change. */
|
||||
const BASE = 'cursor-pointer btn-transition uppercase tracking-wider';
|
||||
/* Elevation lives per-variant: primary/secondary use btn-shadow (static offset
|
||||
* block, button moves), ghost uses btn-transition + translate. */
|
||||
const BASE = 'cursor-pointer uppercase tracking-wider';
|
||||
|
||||
/**
|
||||
* Brutalist button with variants and sizes.
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Link } from './Link';
|
||||
|
||||
const PRIMARY = 'underline underline-offset-2 hover:opacity-60 transition-opacity';
|
||||
const SECONDARY = 'no-underline opacity-60 hover:opacity-100 sm:brutal-border-bottom transition-opacity';
|
||||
const TERTIARY = 'no-underline opacity-100 hover:opacity-60 sm:brutal-border-bottom transition-opacity';
|
||||
|
||||
describe('internal link', () => {
|
||||
it('renders an anchor element', () => {
|
||||
@@ -92,6 +93,18 @@ describe('variant', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('tertiary applies reversed-hover classes', () => {
|
||||
render(
|
||||
<Link href="/about" variant="tertiary">
|
||||
About
|
||||
</Link>,
|
||||
);
|
||||
const link = screen.getByRole('link', { name: 'About' });
|
||||
for (const cls of TERTIARY.split(' ')) {
|
||||
expect(link).toHaveClass(cls);
|
||||
}
|
||||
});
|
||||
|
||||
it('secondary does not apply underline', () => {
|
||||
render(
|
||||
<Link href="/about" variant="secondary">
|
||||
|
||||
@@ -2,7 +2,7 @@ import NextLink from 'next/link';
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '$shared/lib';
|
||||
|
||||
export type LinkVariant = 'primary' | 'secondary';
|
||||
export type LinkVariant = 'primary' | 'secondary' | 'tertiary';
|
||||
|
||||
/**
|
||||
* Props for Link.
|
||||
@@ -24,6 +24,7 @@ interface Props {
|
||||
* Visual variant.
|
||||
* primary — text-decoration underline.
|
||||
* secondary — border-bottom at sm+, no underline on mobile (for icon+label links).
|
||||
* tertiary — like secondary but reversed: bright by default, dims on hover.
|
||||
* @default 'primary'
|
||||
*/
|
||||
variant?: LinkVariant;
|
||||
@@ -37,6 +38,7 @@ interface Props {
|
||||
const VARIANTS = {
|
||||
primary: 'underline underline-offset-2 hover:opacity-60 transition-opacity',
|
||||
secondary: 'no-underline opacity-60 hover:opacity-100 sm:brutal-border-bottom transition-opacity',
|
||||
tertiary: 'no-underline opacity-100 hover:opacity-60 sm:brutal-border-bottom transition-opacity',
|
||||
} as const satisfies Record<LinkVariant, string>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { Stagger } from './ui/Stagger';
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||
import { Badge } from '../../Badge';
|
||||
import { Stagger } from './Stagger';
|
||||
|
||||
const meta: Meta<typeof Stagger> = {
|
||||
title: 'Shared/Stagger',
|
||||
component: Stagger,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof Stagger>;
|
||||
|
||||
const ITEMS = ['React', 'TypeScript', 'Next.js', 'Node', 'CSS', 'Vitest'];
|
||||
|
||||
/**
|
||||
* Each item slides in from the right with a 50ms-per-index delay.
|
||||
* Refresh the story to replay the entry animation.
|
||||
*/
|
||||
export const Staggered: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap gap-3 p-8 bg-ochre-clay">
|
||||
{ITEMS.map((item, i) => (
|
||||
<Stagger key={item} index={i}>
|
||||
<Badge>{item}</Badge>
|
||||
</Stagger>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Stagger } from './Stagger';
|
||||
|
||||
describe('Stagger', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders children', () => {
|
||||
render(<Stagger index={0}>Item</Stagger>);
|
||||
expect(screen.getByText('Item')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies the stagger-in class', () => {
|
||||
render(<Stagger index={0}>Item</Stagger>);
|
||||
expect(screen.getByText('Item')).toHaveClass('stagger-in');
|
||||
});
|
||||
|
||||
it('merges custom className', () => {
|
||||
render(
|
||||
<Stagger index={0} className="mt-4">
|
||||
Item
|
||||
</Stagger>,
|
||||
);
|
||||
expect(screen.getByText('Item')).toHaveClass('stagger-in', 'mt-4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stagger index', () => {
|
||||
it('sets the --stagger-index custom property', () => {
|
||||
render(<Stagger index={3}>Item</Stagger>);
|
||||
expect(screen.getByText('Item').style.getPropertyValue('--stagger-index')).toBe('3');
|
||||
});
|
||||
|
||||
it('sets index 0 for the first item', () => {
|
||||
render(<Stagger index={0}>Item</Stagger>);
|
||||
expect(screen.getByText('Item').style.getPropertyValue('--stagger-index')).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('step', () => {
|
||||
it('omits --stagger-step by default', () => {
|
||||
render(<Stagger index={1}>Item</Stagger>);
|
||||
expect(screen.getByText('Item').style.getPropertyValue('--stagger-step')).toBe('');
|
||||
});
|
||||
|
||||
it('sets --stagger-step in ms when provided', () => {
|
||||
render(
|
||||
<Stagger index={1} step={120}>
|
||||
Item
|
||||
</Stagger>,
|
||||
);
|
||||
expect(screen.getByText('Item').style.getPropertyValue('--stagger-step')).toBe('120ms');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '$shared/lib';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Position in the list — drives the per-item animation-delay via the
|
||||
* --stagger-index custom property consumed by the .stagger-in keyframe.
|
||||
*/
|
||||
index: number;
|
||||
/**
|
||||
* Item to animate in.
|
||||
*/
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Per-item delay in ms. Bump it for few/large items (cards) so the
|
||||
* stagger reads as sequential rather than simultaneous.
|
||||
* @default 50
|
||||
*/
|
||||
step?: number;
|
||||
/**
|
||||
* Additional CSS classes merged onto the wrapper.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a list item so it slides in from the right with a per-index delay.
|
||||
* Reuses the section-body slide distance/easing; respects reduced-motion.
|
||||
*/
|
||||
export function Stagger({ index, children, step, className }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cn('stagger-in', className)}
|
||||
style={{ '--stagger-index': index, ...(step !== undefined && { '--stagger-step': `${step}ms` }) }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export function TechStackBrick({ name, className }: TechStackBrickProps) {
|
||||
<div
|
||||
className={cn(
|
||||
'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]',
|
||||
'transition-all duration-200 hover:shadow-none hover:translate-x-[3px] hover:translate-y-[3px]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -13,5 +13,6 @@ export { Modal, type ModalHandle } from './Modal';
|
||||
export { RichText } from './RichText';
|
||||
export type { ContainerSize, SectionBackground } from './Section';
|
||||
export { Container, Section } from './Section';
|
||||
export { Stagger } from './Stagger';
|
||||
export { TechStackBrick, TechStackGrid } from './TechStack';
|
||||
export { ViewTransitionWrapper } from './ViewTransitionWrapper';
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ExperienceCard } from '$entities/experience';
|
||||
import type { ExperienceRecord } from '$shared/api';
|
||||
import { getCollection } from '$shared/api';
|
||||
import { formatMonthYearRange } from '$shared/lib';
|
||||
import { Stagger } from '$shared/ui';
|
||||
|
||||
/**
|
||||
* Experience section component.
|
||||
@@ -15,15 +16,16 @@ export default async function ExperienceSection() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-section">
|
||||
{items.map((exp) => (
|
||||
<ExperienceCard
|
||||
key={exp.id}
|
||||
title={exp.role}
|
||||
company={exp.company}
|
||||
period={formatMonthYearRange(exp.start_date, exp.end_date)}
|
||||
description={exp.description}
|
||||
stack={exp.stack}
|
||||
/>
|
||||
{items.map((exp, i) => (
|
||||
<Stagger key={exp.id} index={i} step={240}>
|
||||
<ExperienceCard
|
||||
title={exp.role}
|
||||
company={exp.company}
|
||||
period={formatMonthYearRange(exp.start_date, exp.end_date)}
|
||||
description={exp.description}
|
||||
stack={exp.stack}
|
||||
/>
|
||||
</Stagger>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function Footer() {
|
||||
<div className="w-full flex flex-row justify-between gap-4">
|
||||
<div className="flex flex-wrap items-center gap-6 sm:gap-4">
|
||||
{email && (
|
||||
<Link href={email.url} external variant="secondary" className="flex items-center gap-1.5 text-sm">
|
||||
<Link href={email.url} external variant="tertiary" className="flex items-center gap-1.5 text-sm">
|
||||
{email.icon && <InlineSvg svg={email.icon} className="inline-flex w-8 h-8 sm:hidden" />}
|
||||
<span className="hidden sm:block">{email.label}</span>
|
||||
</Link>
|
||||
@@ -33,7 +33,7 @@ export async function Footer() {
|
||||
key={social.id}
|
||||
href={social.url}
|
||||
external
|
||||
variant="secondary"
|
||||
variant="tertiary"
|
||||
className="flex items-center gap-1.5 text-sm"
|
||||
>
|
||||
{social.icon && <InlineSvg svg={social.icon} className="inline-flex w-8 h-8 sm:hidden" />}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ProjectCard } from '$entities/project';
|
||||
import type { ProjectRecord } from '$shared/api';
|
||||
import { getCollection } from '$shared/api';
|
||||
import { buildFileUrl } from '$shared/lib';
|
||||
import { Stagger } from '$shared/ui';
|
||||
|
||||
/**
|
||||
* Projects section component.
|
||||
@@ -21,16 +22,17 @@ export default async function ProjectsSection() {
|
||||
return (
|
||||
<div className="space-y-6 max-w-section">
|
||||
{items.map((project, index) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
title={project.title}
|
||||
year={project.year}
|
||||
description={project.description}
|
||||
tags={project.stack}
|
||||
url={project.url}
|
||||
imageUrl={project.image ? buildFileUrl(project.collectionId, project.id, project.image) : undefined}
|
||||
priority={index === lcpIndex}
|
||||
/>
|
||||
<Stagger key={project.id} index={index} step={240}>
|
||||
<ProjectCard
|
||||
title={project.title}
|
||||
year={project.year}
|
||||
description={project.description}
|
||||
tags={project.stack}
|
||||
url={project.url}
|
||||
imageUrl={project.image ? buildFileUrl(project.collectionId, project.id, project.image) : undefined}
|
||||
priority={index === lcpIndex}
|
||||
/>
|
||||
</Stagger>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { notFound } from 'next/navigation';
|
||||
import type { SkillRecord } from '$shared/api';
|
||||
import { getCollection } from '$shared/api';
|
||||
import { groupByKey } from '$shared/lib';
|
||||
import { Badge } from '$shared/ui';
|
||||
import { Badge, Stagger } from '$shared/ui';
|
||||
|
||||
/**
|
||||
* Skills section component.
|
||||
@@ -24,10 +24,12 @@ export default async function SkillsSection() {
|
||||
<div className="space-y-12 max-w-section">
|
||||
{Object.entries(categories).map(([category, items]) => (
|
||||
<div key={category} className="space-y-4">
|
||||
<h3 className="text-xl font-bold uppercase tracking-widest opacity-60">{category}</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{items.map((skill) => (
|
||||
<Badge key={skill.id}>{skill.name}</Badge>
|
||||
<h3 className="font-heading">{category}</h3>
|
||||
<div className="flex flex-wrap gap-3 sm:max-w-golden">
|
||||
{items.map((skill, i) => (
|
||||
<Stagger key={skill.id} index={i}>
|
||||
<Badge>{skill.name}</Badge>
|
||||
</Stagger>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user