Compare commits

...

46 Commits

Author SHA1 Message Date
ilia d439e81236 Merge pull request 'fix: ul styles for rich text' (#9) from fixes/visuals into main
Build and push / build (push) Successful in 4m37s
Reviewed-on: #9
2026-05-26 12:40:28 +00:00
Ilia Mashkov 615f4afc2d fix: ul styles for rich text 2026-05-26 15:36:29 +03:00
ilia e5f5c7b82e Merge pull request 'Feature/image dialog' (#8) from feature/image-dialog into main
Build and push / build (push) Successful in 1m7s
Reviewed-on: #8
2026-05-23 10:16:54 +00:00
Ilia Mashkov e16b88ba7e chore: remove outdated code 2026-05-23 13:14:06 +03:00
Ilia Mashkov 83ddd2724f chore: enforce common prop typing style 2026-05-23 13:06:56 +03:00
Ilia Mashkov 7e87cbc3ae chore: experience card responsive style tweak 2026-05-23 13:00:56 +03:00
Ilia Mashkov 521aa7d05c feat: create new grain-pattern utility and use it for body 2026-05-23 12:53:37 +03:00
Ilia Mashkov 532f93d896 fix: disable lightbox animation in firefox since it isnt supported yet 2026-05-23 12:52:16 +03:00
Ilia Mashkov 9ebb515032 feat(projects): prioritize LCP image, fix View Project button on mobile
- ProjectCard accepts a `priority` prop forwarded to the thumbnail Image so
  above-the-fold cards skip lazy-loading and get a preload hint.
- ProjectsSection marks the first card *with an image* as priority; handles
  the case where the first project has no image and the LCP candidate ends
  up being a later card.
- View Project button drops `w-full` on mobile (collapsed sidebar above the
  card body), using `self-start` + `text-center` instead so it sizes to its
  content. Restores column-filling on lg+ where the sidebar is its own
  narrow column.
2026-05-23 09:54:43 +03:00
Ilia Mashkov cd59766f92 feat(ImageLightbox): morph to dialog via View Transitions, fix sizing and stacking
- Use the shared Modal for dialog mechanics; ImageLightbox keeps only the
  view-transition coordination (just-in-time view-transition-name handoff
  between thumb and dialog frame, ESC routed through onCancel).
- Switch to <Image> for both thumb and dialog image; thumb is priority/sizes-
  driven for LCP, dialog image is lazy-loaded (deferred until open).
- New brutal-outline utility for the dialog frame: outline paints after
  children so subpixel image bleed can't cover it; dialog gets overflow-
  visible so the outline isn't clipped.
- lightbox-image utility caps image dimensions to viewport minus close-button
  and footer headroom, with box-sizing: content-box.
- Lightbox dialog gets view-transition-name lightbox-dialog (z=20) and the
  frame gets lightbox-frame (z=30) so both stack cleanly above the page
  during the open/close transition. Footer drops its named VT group since
  section-body no longer slides over it.
- Cream-tinted backdrop replaces the blur (Firefox-friendly), color-mix
  with var(--cream) for the token reference.
- scrollbar-gutter: stable on html so locking body scroll doesn't shift
  the layout.
2026-05-23 09:54:20 +03:00
Ilia Mashkov ecbb76312b refactor(section): snap-out old section-body on navigation
The explicit fade-and-slide-left OLD animation left a visible ghost of the
previous section's content behind the incoming slide. Replacing it with an
instant opacity:0 keeps the transition clean while preserving the NEW
slide-in delay so the snap-out has a beat to register. Drops the now-dead
keyframes and --slide-section-body-out token.
2026-05-23 09:53:26 +03:00
Ilia Mashkov 82933dedf8 feat(shared): add Modal component
Native <dialog>+showModal() wrapper with imperative open()/close() via ref,
body scroll lock, and backdrop-click/keyboard-close behaviors. Exposes
onCancel and onBackdropClose escape hatches for consumers that need to
wrap the close path (e.g. in a view transition).
2026-05-23 09:50:42 +03:00
Ilia Mashkov c4002ebb4f chore: use node:path protocol in vitest config 2026-05-23 09:50:29 +03:00
Ilia Mashkov a31cf4deec fix: opt Next.js out of smooth scroll for route transitions
Adds data-scroll-behavior="smooth" to <html> so Next disables the global
scroll-behavior during programmatic route-change scrolls while keeping
smooth behavior for user-driven anchor jumps. Silences the Next 15 warning.
2026-05-22 15:30:42 +03:00
Ilia Mashkov 5b686ad87c fix: SectionAccordion animation misbehave 2026-05-22 14:22:08 +03:00
Ilia Mashkov 43242c3bed refactor: swap Button ghost/outline semantics, clean up ImageLightbox thumbnail
ghost now means transparent bg (no fill); outline keeps cream bg with subtle border.
Remove magnify icon overlay from ImageLightbox thumbnail — hover cursor-zoom-in is sufficient.
Close button updated to variant="outline" for cream-on-blue contrast in the dialog.
2026-05-22 14:22:08 +03:00
Ilia Mashkov 7a06d42d20 feat: add icon components and update ImageLightbox with icons and Button 2026-05-22 12:48:54 +03:00
Ilia Mashkov eeb7d6b4a6 feat: use ImageLightbox in ProjectCard 2026-05-22 12:18:00 +03:00
Ilia Mashkov eb13328f9a feat: add lightbox backdrop CSS and export ImageLightbox 2026-05-22 12:05:42 +03:00
Ilia Mashkov bfb0b46a37 feat: implement ImageLightbox with tests 2026-05-22 12:04:11 +03:00
Ilia Mashkov c7ed458c8e test: ImageLightbox failing tests 2026-05-22 10:14:53 +03:00
Ilia Mashkov 49cafe7161 feat: ImageLightbox placeholder 2026-05-21 20:28:28 +03:00
ilia 58eae96791 Merge pull request 'Features/visual improvements' (#7) from features/visual-improvements into main
Build and push / build (push) Successful in 1m5s
Reviewed-on: #7
2026-05-21 15:16:16 +00:00
Ilia Mashkov 1b0ffd41a2 chore: changes for deployment to get the pocketbase public url variable value 2026-05-21 18:15:16 +03:00
Ilia Mashkov 3e520f6abb fix: use https protocol in next config 2026-05-21 18:01:03 +03:00
Ilia Mashkov 4d54947a91 fix: tweak slide section animation 2026-05-21 18:00:12 +03:00
Ilia Mashkov f121443e52 fix: add footer z index in transition group to stay above main content during transitions 2026-05-21 17:59:40 +03:00
Ilia Mashkov df4526cabd chore: remove unused favicon.ico 2026-05-21 17:07:58 +03:00
Ilia Mashkov bf36a40bb5 chore: swap the favicon 2026-05-21 16:57:30 +03:00
Ilia Mashkov 886cf4b5c4 feat: add spring slide animation for section content 2026-05-21 16:36:00 +03:00
Ilia Mashkov fc588f9e66 feat: set page title and description 2026-05-21 16:01:42 +03:00
Ilia Mashkov f08ee51332 chore: remove unused files 2026-05-21 15:58:48 +03:00
Ilia Mashkov 9ded41db3c feat: set the favicon 2026-05-21 15:58:11 +03:00
Ilia Mashkov 0697e9ad72 feat: add error handling and tests for client.ts 2026-05-21 15:57:43 +03:00
Ilia Mashkov caff3fe7e3 fix: align footer paddings with main ones 2026-05-21 15:55:47 +03:00
ilia 56f3f94e41 Merge pull request 'feat: add PBHttpError and try/catch in getFirstRecord' (#6) from fix/get-first-record into main
Build and push / build (push) Successful in 3m35s
Reviewed-on: #6
2026-05-21 10:08:04 +00:00
Ilia Mashkov 9deefaf3fc feat: add PBHttpError and try/catch in getFirstRecord 2026-05-21 13:05:31 +03:00
ilia a54963091c Merge pull request 'fix: require PB_URL in production, fall back to localhost in dev only' (#5) from fix/pocketbase into main
Build and push / build (push) Failing after 1m10s
Reviewed-on: #5
2026-05-19 16:28:43 +00:00
Ilia Mashkov 6b15a0e658 fix: gracefully handle PocketBase unreachable during static generation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:27:04 +03:00
Ilia Mashkov 93b8adf55d fix: require PB_URL in production, fall back to localhost in dev only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:26:58 +03:00
ilia 03a90e1cf0 Merge pull request 'fix: old palette purged from stories and stories purged from production build' (#4) from fixes/storybook into main
Build and push / build (push) Failing after 1m14s
Reviewed-on: #4
2026-05-19 15:58:13 +00:00
Ilia Mashkov 06d69a860e fix: add css and json to biome check, set yarn version in package.json 2026-05-19 18:57:16 +03:00
Ilia Mashkov 181cfdebdf fix: old palette purged from stories and stories purged from production build 2026-05-19 18:50:45 +03:00
Ilia Mashkov e0565d6ddc fix: yarn instead of npm for dockerfile
Build and push / build (push) Failing after 2m17s
2026-05-19 18:37:58 +03:00
ilia 598d566487 Merge pull request 'feat: add route-level error page and per-section error boundary' (#3) from feature/error-handling into main
Build and push / build (push) Failing after 22s
Reviewed-on: #3
2026-05-19 15:27:17 +00:00
Ilia Mashkov dd9cc766d5 feat: add route-level error page and per-section error boundary
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:20:53 +03:00
70 changed files with 1190 additions and 668 deletions
+2
View File
@@ -6,3 +6,5 @@ node_modules
README.md
Dockerfile
.dockerignore
.yarn
.pnp.*
+2
View File
@@ -24,6 +24,8 @@ jobs:
with:
context: .
push: true
build-args: |
PB_PUBLIC_URL=${{ vars.PB_PUBLIC_URL }}
tags: |
docker.allmy.work/${{ gitea.repository }}:latest
docker.allmy.work/${{ gitea.repository }}:${{ gitea.sha }}
+8 -3
View File
@@ -1,14 +1,18 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
RUN corepack enable
COPY package.json yarn.lock .yarnrc.yml ./
RUN yarn install --immutable
FROM node:22-alpine AS builder
WORKDIR /app
RUN corepack enable
ARG PB_PUBLIC_URL
ENV PB_PUBLIC_URL=$PB_PUBLIC_URL
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
RUN yarn build
FROM node:22-alpine AS runner
WORKDIR /app
@@ -20,6 +24,7 @@ RUN addgroup -S -g 1001 nodejs && adduser -S -u 1001 -G nodejs nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN mkdir -p .next/cache && chown -R nextjs:nodejs .next/cache
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
+16
View File
@@ -0,0 +1,16 @@
'use client';
import { Link } from '$shared/ui';
/**
* Route-level error boundary — shown when an unhandled error escapes
* the [[...slug]] segment. Mirrors the not-found layout.
*/
export default function ErrorPage() {
return (
<main className="flex-1 flex flex-col items-center justify-center gap-6">
<h1 className="font-heading text-[clamp(8rem,20vw,18rem)] leading-none">500</h1>
<Link href="/">Back to main</Link>
</main>
);
}
+11 -7
View File
@@ -8,16 +8,20 @@ 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',
tags: ['sections'],
});
return [{}, ...sections.map((s) => ({ slug: [s.slug] }))];
try {
const { items: sections } = await getCollection<SectionRecord>('sections', {
sort: 'order',
});
return [{}, ...sections.map((s) => ({ slug: [s.slug] }))];
} catch (err) {
console.warn('[generateStaticParams] PocketBase unreachable at build — deferring to runtime ISR', err);
return [];
}
}
type Props = {
export interface Props {
params: Promise<{ slug?: string[] }>;
};
}
/**
* Portfolio page — one route per section, sections list always visible.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+4 -3
View File
@@ -4,8 +4,9 @@ import { Footer } from '$widgets/Footer';
import './globals.css';
export const metadata: Metadata = {
title: 'Portfolio',
description: 'Portfolio',
title: 'Ilia Mashkov — Portfolio',
description: 'Portfolio of Ilia Mashkov, a frontend software engineer.',
icons: { icon: '/favicon.svg' },
};
/**
@@ -13,7 +14,7 @@ export const metadata: Metadata = {
*/
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<html lang="en" data-scroll-behavior="smooth">
<body className={`${fraunces.variable} ${publicSans.variable} pb-footer md:pb-footer-wide`}>
{children}
<Footer />
+1 -1
View File
@@ -7,7 +7,7 @@
},
"files": {
"ignoreUnknown": false,
"includes": ["src/**/*", "app/**/*", "*.ts", "*.tsx", "*.js", "*.jsx"]
"includes": ["src/**/*", "app/**/*", "*.ts", "*.tsx", "*.js", "*.jsx", "*.json", "*.css"]
},
"formatter": {
"enabled": true,
+7 -7
View File
@@ -1,9 +1,10 @@
import type { NextConfig } from 'next';
/* PocketBase origin — used to allowlist remote images.
* PB_HOSTNAME and PB_PORT are server-only env vars; safe to read here. */
const pbHostname = process.env.PB_HOSTNAME ?? '127.0.0.1';
const pbPort = process.env.PB_PORT ?? '8090';
/* Public PocketBase host for the image optimizer allowlist.
* Derived from PB_PUBLIC_URL (e.g. https://cms.allmy.work) at BUILD time —
* remotePatterns is frozen into the build, so PB_PUBLIC_URL must be present
* during `next build` in CI (via build-arg), not just at runtime. */
const pbPublicHost = process.env.PB_PUBLIC_URL ? new URL(process.env.PB_PUBLIC_URL).hostname : '127.0.0.1';
const nextConfig: NextConfig = {
output: 'standalone',
@@ -11,9 +12,8 @@ const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: 'http',
hostname: pbHostname,
port: pbPort,
protocol: 'https',
hostname: pbPublicHost,
pathname: '/api/files/**',
},
],
+1
View File
@@ -1,6 +1,7 @@
{
"name": "portfolio",
"version": "0.1.0",
"packageManager": "yarn@4.11.0",
"private": true,
"scripts": {
"dev": "next dev",
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1000" fill="none" xmlns:v="https://vecta.io/nano"><path d="M1000 500c0 276.142-223.858 500-500 500S0 776.142 0 500 223.858 0 500 0s500 223.858 500 500z" fill="#f4f0e8"/><path d="M483.953 210.023c-12.558 3.37-26.493 11.531-155.686 92.422-49.373 30.867-48.34 29.98-55.566 40.446C263.928 355.486 170 572.083 170 579.711c0 9.934 6.365 15.611 40.083 36.543l113.023 69.361 119.56 73.619c59.866 37.075 56.081 36.011 79.305 21.464l269.398-165.863c18.923-11.53 33.545-21.819 35.61-24.835 7.053-10.289 11.526 1.419-75.177-197.084-19.611-45.058-24.428-53.573-33.546-60.846-10.493-8.16-177.018-111.581-188.715-117.08-8.946-4.257-13.935-5.322-25.805-5.854-8.085-.355-16.859 0-19.783.887zm69.156 60.491l90.487 56.411c27.352 17.03 49.028 31.576 48.168 32.286-1.72 1.774-29.245 18.981-120.592 75.215l-70.876 43.816-95.821-59.072-95.648-60.846c0-1.597 163.772-104.662 176.846-111.048 4.989-2.484 9.634-3.371 16.687-2.839 9.118.533 12.558 2.306 50.749 26.077zM386.584 450.569l96.509 59.604v115.483c0 91.535-.516 115.129-2.065 114.419-1.204-.355-22.019-12.95-46.103-27.851l-113.54-69.715-111.818-70.426c0-.709 10.321-24.835 22.88-53.395l39.222-89.761c8.774-20.755 16.687-37.785 17.375-37.785s44.556 26.786 97.54 59.427zm327.888-55.879c.688 2.128 14.278 33.882 30.449 70.602 45.588 104.308 46.62 106.969 45.071 108.388-.86.887-28.556 18.094-61.758 38.317L593.707 694.84l-75.176 45.767c-.688 0-.86-51.976-.688-115.66l.516-115.661 88.595-54.637 95.476-58.895c8.429-5.499 10.665-5.677 12.042-1.064z" fill="#041cf3"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

@@ -55,10 +55,5 @@ describe('SectionAccordion', () => {
render(<SectionAccordion {...activeProps} />);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('content wrapper has section-content class', () => {
const { container } = render(<SectionAccordion {...activeProps} />);
expect(container.querySelector('.section-content')).toBeInTheDocument();
});
});
});
@@ -39,13 +39,13 @@ export function SectionAccordion({ number, title, id, isActive, href, children }
<section id={id} className="scroll-mt-8">
{isActive ? (
<div className="mb-6 sm:mb-12">
<ViewTransitionWrapper name="section-content">
<div className="mb-6 sm:mb-16">
<ViewTransitionWrapper name="section-title">
<div className="mb-6 sm:mb-12">
<h1 className="font-heading font-black text-xl sm:text-section-title leading-[1.2] mb-0">{heading}</h1>
</div>
</ViewTransitionWrapper>
<ViewTransitionWrapper name="section-body">
<div className="section-content">{children}</div>
<div>{children}</div>
</ViewTransitionWrapper>
</div>
) : (
@@ -23,6 +23,7 @@ const baseArgs = {
period: '2021 2024',
description:
'Led frontend development for the core product, established design system practices, and mentored junior engineers across two distributed teams.',
stack: ['React', 'TypeScript', 'Next.js'],
};
export const Default: Story = {
@@ -1,6 +1,6 @@
import { Badge, Card, CardSidebar, CardTitle, RichText } from '$shared/ui';
type Props = {
export interface Props {
/**
* Job title
*/
@@ -25,7 +25,7 @@ type Props = {
* Additional CSS classes forwarded to the card
*/
className?: string;
};
}
/**
* Work experience card with sidebar layout.
@@ -37,9 +37,9 @@ export function ExperienceCard({ title, company, period, description, stack, cla
<Card className={className}>
<CardSidebar
sidebar={
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 sm:gap-4">
<p className="text-sm font-medium brutal-border-left pl-3">{period}</p>
<p className="text-base font-medium">{company}</p>
<p className="text-lg font-black">{company}</p>
{stack.length > 0 && (
<div className="flex flex-wrap gap-2">
{stack.map((tech) => (
@@ -2,7 +2,7 @@ import Image from 'next/image';
import { Card, RichText } from '$shared/ui';
import { ProjectMetadata } from '../ProjectMetadata/ProjectMetadata';
type Props = {
export interface Props {
/**
* Project name
*/
@@ -36,7 +36,7 @@ type Props = {
* @default false
*/
reverse?: boolean;
};
}
/**
* Full-width detailed project card with metadata sidebar.
@@ -124,5 +124,11 @@ describe('ProjectCard', () => {
const imgWrapper = container.querySelector('img')?.parentElement;
expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border');
});
it('image is wrapped in a lightbox button with cursor-zoom-in', () => {
render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />);
const btn = screen.getByRole('button', { name: DEFAULT_PROPS.title });
expect(btn).toHaveClass('cursor-zoom-in');
});
});
});
@@ -1,8 +1,7 @@
import Image from 'next/image';
import { cn } from '$shared/lib';
import { Badge, Button, Card, CardSidebar, CardTitle, RichText } from '$shared/ui';
import { Badge, Button, Card, CardSidebar, CardTitle, ImageLightbox, RichText } from '$shared/ui';
type Props = {
export interface Props {
/**
* Project name
*/
@@ -27,14 +26,20 @@ type Props = {
* Optional preview image URL
*/
imageUrl?: string;
};
/**
* Skip lazy-loading the preview image. Set true for above-the-fold cards
* (typically the first card in the list) to improve LCP.
* @default false
*/
priority?: boolean;
}
/**
* Project card with sidebar layout.
* Sidebar: year badge, stack tags, View Project button.
* Main: title, optional image, description.
*/
export function ProjectCard({ title, year, description, tags, url, imageUrl }: Props) {
export function ProjectCard({ title, year, description, tags, url, imageUrl, priority = false }: Props) {
return (
<Card className={cn('group hover:shadow-brutal-xl transition-shadow duration-300')}>
<CardSidebar
@@ -50,7 +55,7 @@ export function ProjectCard({ title, year, description, tags, url, imageUrl }: P
))}
</div>
)}
<Button href={url} variant="primary" size="sm" className="w-full">
<Button href={url} variant="primary" size="sm" className="self-start lg:w-full lg:self-auto text-center">
View Project
</Button>
</div>
@@ -59,9 +64,7 @@ export function ProjectCard({ title, year, description, tags, url, imageUrl }: P
<div className="flex flex-col gap-4">
<CardTitle className="font-heading">{title}</CardTitle>
{imageUrl && (
<div className="brutal-border aspect-video bg-blue overflow-hidden relative">
<Image src={imageUrl} alt={title} fill className="object-cover" />
</div>
<ImageLightbox src={imageUrl} alt={title} priority={priority} className="brutal-border bg-blue" />
)}
<RichText html={description} />
</div>
@@ -1,6 +1,6 @@
import { cn } from '$shared/lib';
type Props = {
export interface Props {
/**
* Project year
*/
@@ -17,7 +17,7 @@ type Props = {
* Additional CSS classes
*/
className?: string;
};
}
/**
* Sidebar metadata display for a project: year, role, and stack.
-79
View File
@@ -1,79 +0,0 @@
import type { ListResponse } from './types';
/*
* Native fetch wrapper for PocketBase API requests.
*/
/* Prefer the server-only var (not exposed to the browser bundle),
* fall back to the public var for client-side usage, then to the
* local dev default. */
const PB_URL = process.env.PB_URL ?? process.env.NEXT_PUBLIC_PB_URL ?? 'http://127.0.0.1:8090';
/**
* Options for PocketBase collection fetching.
*/
export type PBFetchOptions = {
/**
* Sorting criteria (e.g., "-created,order")
*/
sort?: string;
/**
* Filter query string
*/
filter?: string;
/**
* Fields to expand (e.g., "stack")
*/
expand?: string;
/**
* Cache tags for on-demand revalidation via `revalidateTag`.
* Typically set to the collection name.
*/
tags?: string[];
/**
* ISR revalidation interval in seconds.
* @default 3600
*/
revalidate?: number;
};
/**
* Fetch a list of records from a PocketBase collection.
*/
export async function getCollection<T>(collection: string, options: PBFetchOptions = {}): Promise<ListResponse<T>> {
const { sort, filter, expand, tags, revalidate } = options;
const params = new URLSearchParams();
if (sort) {
params.set('sort', sort);
}
if (filter) {
params.set('filter', filter);
}
if (expand) {
params.set('expand', expand);
}
const url = `${PB_URL}/api/collections/${collection}/records?${params.toString()}`;
const res = await fetch(url, {
next: {
tags: tags ?? [],
revalidate: revalidate ?? 3600,
},
});
if (!res.ok) {
throw new Error(`PocketBase ${res.status} ${res.statusText} on collection "${collection}"`);
}
return res.json();
}
/**
* Fetch the first record matching an optional filter from a PocketBase collection.
*/
export async function getFirstRecord<T>(collection: string, options: PBFetchOptions = {}): Promise<T | null> {
const data = await getCollection<T>(collection, options);
return data.items[0] ?? null;
}
+40
View File
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PBHttpError } from '../error';
import { getCollection } from './client';
describe('getCollection', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
describe('when PocketBase is unreachable', () => {
it('returns an empty list instead of throwing', async () => {
vi.stubEnv('PB_URL', 'http://localhost:8090');
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed')));
const result = await getCollection('projects');
expect(result.items).toEqual([]);
expect(result.totalItems).toBe(0);
});
});
describe('when PocketBase returns an HTTP error', () => {
it('rethrows PBHttpError', async () => {
vi.stubEnv('PB_URL', 'http://localhost:8090');
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden',
json: vi.fn(),
}),
);
await expect(getCollection('projects')).rejects.toBeInstanceOf(PBHttpError);
});
});
});
+102
View File
@@ -0,0 +1,102 @@
import { PBHttpError } from '../error';
import type { ListResponse } from '../types';
/*
* Native fetch wrapper for PocketBase API requests.
*/
/**
* Options for PocketBase collection fetching.
*/
export type PBFetchOptions = {
/**
* Sorting criteria (e.g., "-created,order")
*/
sort?: string;
/**
* Filter query string
*/
filter?: string;
/**
* Fields to expand (e.g., "stack")
*/
expand?: string;
/**
* Cache tags for on-demand revalidation via `revalidateTag`.
* Typically set to the collection name.
*/
tags?: string[];
/**
* ISR revalidation interval in seconds.
* @default 3600
*/
revalidate?: number;
};
/**
* Fetch a list of records from a PocketBase collection.
*/
export async function getCollection<T>(collection: string, options: PBFetchOptions = {}): Promise<ListResponse<T>> {
/* Required in production; falls back to localhost in development. */
const pbUrl = process.env.PB_URL ?? (process.env.NODE_ENV === 'development' ? 'http://127.0.0.1:8090' : undefined);
if (!pbUrl) {
throw new Error('PB_URL is required in production');
}
const { sort, filter, expand, tags, revalidate } = options;
const params = new URLSearchParams();
if (sort) {
params.set('sort', sort);
}
if (filter) {
params.set('filter', filter);
}
if (expand) {
params.set('expand', expand);
}
const url = `${pbUrl}/api/collections/${collection}/records?${params.toString()}`;
try {
const res = await fetch(url, {
next: {
tags: tags ?? [],
revalidate: revalidate ?? 3600,
},
});
if (!res.ok) {
throw new PBHttpError(res.status, collection, res.statusText);
}
return res.json();
} catch (err) {
if (err instanceof PBHttpError) {
throw err;
}
console.warn(`[getCollection] "${collection}" unreachable — returning empty list`, err);
return { items: [], page: 1, perPage: 0, totalItems: 0, totalPages: 0 };
}
}
/**
* Fetch the first record matching an optional filter from a PocketBase collection.
*
* Returns null on connection failure (e.g. PocketBase unreachable during build)
* so prerendering doesn't crash. HTTP errors (4xx/5xx) are rethrown — PB is
* reachable but something is genuinely wrong, which shouldn't be silently hidden.
*/
export async function getFirstRecord<T>(collection: string, options: PBFetchOptions = {}): Promise<T | null> {
try {
const data = await getCollection<T>(collection, options);
return data.items[0] ?? null;
} catch (err) {
if (err instanceof PBHttpError) {
throw err;
}
console.warn(`[getFirstRecord] "${collection}" unreachable — returning null`, err);
return null;
}
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Error thrown when PocketBase responds with a non-OK HTTP status (4xx/5xx).
*
* Distinguishes *server-responded-with-failure* from *server-unreachable*.
* A connection-level failure (ECONNREFUSED, DNS, the build-time `PB_URL`
* guard) throws a plain `Error`; only an actual HTTP response throws this.
* Callers use `instanceof PBHttpError` to decide whether to swallow the
* failure (connection — safe to ignore at build) or rethrow it (HTTP — a
* real problem that must surface).
*
* @example
* try {
* await getCollection('site_settings');
* } catch (err) {
* if (err instanceof PBHttpError) {
* // PB is up but returned e.g. 403 — log, alert, rethrow
* console.error(`PB returned ${err.status} for ${err.collection}`);
* } else {
* // PB unreachable — acceptable during build, render empty
* }
* }
*/
export class PBHttpError extends Error {
/**
* @param status HTTP status code returned by PocketBase (e.g. 404, 500).
* @param collection Name of the collection that was queried, for context.
* @param statusText HTTP status text from the response (e.g. "Not Found").
*/
constructor(
public readonly status: number,
public readonly collection: string,
statusText: string,
) {
super(`PocketBase ${status} ${statusText} on collection "${collection}"`);
this.name = 'PBHttpError';
}
}
+1 -1
View File
@@ -1,2 +1,2 @@
export * from './client';
export * from './client/client';
export * from './types';
+30
View File
@@ -0,0 +1,30 @@
export interface Props {
/**
* CSS classes on the svg element
*/
className?: string;
}
/**
* Close / X icon (Lucide).
*/
export function CloseIcon({ className }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden={true}
className={className}
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
);
}
+30
View File
@@ -0,0 +1,30 @@
export interface Props {
/**
* CSS classes on the svg element
*/
className?: string;
}
/**
* Magnify / search icon (Lucide).
*/
export function MagnifyIcon({ className }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden={true}
className={className}
>
<path d="m21 21-4.34-4.34" />
<circle cx="11" cy="11" r="8" />
</svg>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { CloseIcon } from './CloseIcon';
export { MagnifyIcon } from './MagnifyIcon';
@@ -5,7 +5,7 @@ export function buildFileUrl(
collectionId: string,
recordId: string,
filename: string,
baseUrl: string = process.env.NEXT_PUBLIC_PB_URL ?? 'http://127.0.0.1:8090',
baseUrl: string = process.env.PB_PUBLIC_URL ?? 'http://127.0.0.1:8090',
): string {
return `${baseUrl}/api/files/${collectionId}/${recordId}/${filename}`;
}
+140 -26
View File
@@ -84,6 +84,7 @@
/* === GRID === */
--grid-gap: var(--space-3);
--section-content-width: 72rem;
/* === ANIMATION === */
--ease-default: ease;
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
@@ -93,6 +94,8 @@
--duration-normal: 150ms;
--duration-slow: 350ms;
--duration-spring: 220ms;
--delay-normal: 200ms;
--slide-section-body-in: clamp(1.25rem, 5vw, 3rem);
}
@theme inline {
@@ -150,6 +153,10 @@
html {
font-size: var(--font-size);
scroll-behavior: smooth;
/* Reserve scrollbar gutter so locking body scroll (e.g. when a modal
* opens) doesn't widen the viewport and shift fixed elements. */
scrollbar-gutter: stable;
}
body {
@@ -160,30 +167,16 @@
overflow-x: hidden;
}
/* Subtle blue-tinted grain on parchment */
/* 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. */
body::before {
@apply grain-pattern;
content: "";
position: fixed;
inset: 0;
background-image:
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;
z-index: 100;
}
h1,
@@ -268,6 +261,18 @@
@utility brutal-border-right {
border-right: var(--border-width) solid var(--blue);
}
/* Border drawn as an outline — painted after children, so an image's
* subpixel paint bleed can't cover it. Doesn't take layout space; the
* ancestor must not have overflow:hidden or the outline gets clipped. */
@utility brutal-outline {
outline: var(--border-width) solid var(--blue);
}
/* 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
* tile is rasterized once and composited cheaply via repeating background. */
@utility grain-pattern {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Ccircle cx='1' cy='1' r='1' fill='%23041cf3' opacity='0.10'/%3E%3C/svg%3E");
}
/* Apply Fraunces variable axes to non-heading elements using the heading font */
.font-wonk {
font-variation-settings:
@@ -311,15 +316,12 @@
.rich-text ul {
list-style: none;
padding-left: 0;
padding-left: 1.5em;
margin: 1em 0;
}
.rich-text ul li {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.65em;
align-items: start;
text-indent: -1.5em;
margin-top: 0.5em;
}
@@ -329,6 +331,10 @@
.rich-text ul li::before {
content: "◆";
display: inline-block;
width: calc(1.5em / 0.55);
/* reset inherited text-indent so glyph isn't shifted inside the ::before box */
text-indent: 0;
color: var(--blue);
font-size: 0.55em;
/* line-height matches parent so diamond centers within the first line box */
@@ -336,14 +342,14 @@
}
/* Cross-section view transition (navigation between sections) */
::view-transition-old(section-content) {
::view-transition-old(section-title) {
animation-name: section-fade-out;
animation-duration: var(--duration-normal);
animation-timing-function: var(--ease-default);
animation-fill-mode: both;
}
::view-transition-new(section-content) {
::view-transition-new(section-title) {
animation-name: section-fade-in;
animation-duration: var(--duration-spring);
animation-timing-function: var(--ease-spring);
@@ -371,3 +377,111 @@
transform: translateY(0);
}
}
/* Disable group geometry interpolation — OLD and NEW live at different scroll
* positions, so morphing the container drags the slide-in across the viewport.
* Let old/new each animate at their own positions instead. */
::view-transition-group(section-body) {
animation: none;
}
/* Section body — snap OLD out and slide NEW in. Running an explicit OLD
* animation left a visible ghost of the previous section's content sitting
* behind the incoming slide; hiding it immediately keeps the transition
* clean and prevents the two-content overlap. */
::view-transition-old(section-body) {
animation: none;
opacity: 0;
}
::view-transition-new(section-body) {
animation-name: section-body-in;
animation-duration: var(--duration-spring);
animation-timing-function: var(--ease-spring);
animation-fill-mode: both;
/* Hold the start-state for this delay before the slide-in begins — gives
* the snap-out a beat to register visually before new content arrives. */
animation-delay: var(--delay-normal);
}
@keyframes section-body-in {
from {
opacity: 0;
transform: translateX(var(--slide-section-body-in)) scale(0.98);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
/* 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
* horizontal slide of OLD/NEW snapshots), so the footer can live in the root
* snapshot during transitions — which also lets the lightbox dialog group
* stack cleanly above it. */
.footer-vt {
animation: footer-enter var(--duration-slow) var(--ease-spring) both;
}
@keyframes footer-enter {
from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Lightbox dialog backdrop — flat cream wash. No filter, no gradient.
* Cheapest possible; Firefox-friendly during view transitions. */
dialog.lightbox::backdrop {
background-color: color-mix(in srgb, var(--cream) 80%, transparent);
}
/* Give the lightbox dialog its own view-transition group so it can stack
* above the footer's named group (z=10) during the open/close transition.
* Closed dialogs have `display: none` (UA) and don't get snapshotted, so
* sharing the name across all `.lightbox` dialogs at rest is harmless —
* only the open one participates in any given transition. */
dialog.lightbox {
view-transition-name: lightbox-dialog;
}
::view-transition-group(lightbox-dialog) {
z-index: 20;
}
/* The image wrapper (and the thumb during the OLD snapshot of an open
* transition) shares this static name — imperatively assigned only during
* the morph, so there's never more than one element holding it at a time.
* z=30 keeps it above the dialog (z=20) and the footer (z=10) during VT. */
::view-transition-group(lightbox-frame) {
z-index: 30;
}
/* Lightbox image sizing — leaves vertical headroom for the fixed close button
* (sits at top-3, ~2.5rem tall). 8rem total = ~4rem top/bottom after centering,
* so the button never overlaps the image.
* box-sizing: content-box so max-w/max-h apply to the image pixels and the
* 3px brutal-border sits outside them — avoids subpixel clipping of the
* border by the dialog's overflow:hidden when both have border-box. */
@utility lightbox-image {
box-sizing: content-box;
max-width: calc(100vw - 2rem);
max-height: calc(100vh - 10rem);
}
/* Disable transition animation for Firefox
* since it isn't supported yet */
@supports (-moz-appearance: none) {
::view-transition-group(lightbox-frame),
::view-transition-old(lightbox-frame),
::view-transition-new(lightbox-frame) {
animation-duration: 0s !important;
animation-delay: 0s !important;
}
}
+2 -2
View File
@@ -24,11 +24,11 @@ describe('Button', () => {
});
it('applies outline variant', () => {
render(<Button variant="outline">Go</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-transparent');
expect(screen.getByRole('button')).toHaveClass('bg-cream');
});
it('applies ghost variant', () => {
render(<Button variant="ghost">Go</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-cream');
expect(screen.getByRole('button')).toHaveClass('bg-transparent');
});
});
describe('sizes', () => {
+2 -2
View File
@@ -46,9 +46,9 @@ const VARIANTS = {
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',
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:
'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',
} as const satisfies Record<ButtonVariant, string>;
const SIZES = {
+9 -16
View File
@@ -13,24 +13,17 @@ type Story = StoryObj<typeof Card>;
export const AllBackgrounds: Story = {
render: () => (
<div className="flex gap-6 flex-wrap p-8 bg-white">
<Card background="ochre" className="w-64">
<Card background="cream" className="w-64">
<CardHeader>
<CardTitle>Ochre Card</CardTitle>
<CardDescription>Background ochre-clay variant</CardDescription>
<CardTitle>Cream Card</CardTitle>
<CardDescription>Default cream background variant</CardDescription>
</CardHeader>
<CardFooter>Footer content</CardFooter>
</Card>
<Card background="slate" className="w-64">
<Card background="blue" className="w-64">
<CardHeader>
<CardTitle>Slate Card</CardTitle>
<CardDescription>Background slate-indigo variant</CardDescription>
</CardHeader>
<CardFooter>Footer content</CardFooter>
</Card>
<Card background="white" className="w-64">
<CardHeader>
<CardTitle>White Card</CardTitle>
<CardDescription>Background white variant</CardDescription>
<CardTitle>Blue Card</CardTitle>
<CardDescription>Blue background variant</CardDescription>
</CardHeader>
<CardFooter>Footer content</CardFooter>
</Card>
@@ -40,9 +33,9 @@ export const AllBackgrounds: Story = {
export const NoPadding: Story = {
render: () => (
<div className="p-8 bg-ochre-clay">
<div className="p-8">
<Card noPadding className="w-64 overflow-hidden">
<div className="h-40 bg-slate-indigo flex items-center justify-center text-ochre-clay">Image placeholder</div>
<div className="h-40 bg-blue flex items-center justify-center">Image placeholder</div>
</Card>
</div>
),
@@ -51,7 +44,7 @@ export const NoPadding: Story = {
export const FullComposition: Story = {
render: () => (
<div className="p-8 bg-white max-w-md">
<Card background="ochre">
<Card background="cream">
<CardHeader>
<CardTitle>Full Composition</CardTitle>
<CardDescription>A card using all available slot components</CardDescription>
+1
View File
@@ -0,0 +1 @@
export { ImageLightbox } from './ui/ImageLightbox';
@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { ImageLightbox } from './ImageLightbox';
const meta: Meta<typeof ImageLightbox> = {
title: 'Shared/ImageLightbox',
component: ImageLightbox,
};
export default meta;
type Story = StoryObj<typeof ImageLightbox>;
export const Default: Story = {
args: {
src: 'https://picsum.photos/800/450',
alt: 'Sample project image',
},
decorators: [
(Story) => (
<div className="p-8 max-w-2xl">
<Story />
</div>
),
],
};
@@ -0,0 +1,90 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { ImageLightbox } from './ImageLightbox';
// jsdom does not implement dialog methods — mock them
beforeAll(() => {
HTMLDialogElement.prototype.showModal = vi.fn();
HTMLDialogElement.prototype.close = vi.fn();
});
beforeEach(() => {
vi.clearAllMocks();
document.body.style.overflow = '';
});
const DEFAULT_PROPS = { src: '/project.jpg', alt: 'My Project' };
describe('ImageLightbox', () => {
describe('thumbnail', () => {
it('renders a thumbnail image', () => {
render(<ImageLightbox {...DEFAULT_PROPS} />);
expect(screen.getByRole('img', { name: 'My Project' })).toBeInTheDocument();
});
it('thumbnail button has cursor-zoom-in', () => {
render(<ImageLightbox {...DEFAULT_PROPS} />);
const btn = screen.getByRole('button', { name: 'My Project' });
expect(btn).toHaveClass('cursor-zoom-in');
});
it('forwards className to the thumbnail button', () => {
render(<ImageLightbox {...DEFAULT_PROPS} className="extra-class" />);
expect(screen.getByRole('button', { name: 'My Project' })).toHaveClass('extra-class');
});
});
describe('dialog', () => {
it('clicking the thumbnail opens the dialog', () => {
render(<ImageLightbox {...DEFAULT_PROPS} />);
fireEvent.click(screen.getByRole('button', { name: 'My Project' }));
expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1);
});
it('clicking the close button closes the dialog', () => {
render(<ImageLightbox {...DEFAULT_PROPS} />);
fireEvent.click(screen.getByRole('button', { name: 'My Project' })); // open first
fireEvent.click(screen.getByRole('button', { name: /close/i, hidden: true }));
expect(HTMLDialogElement.prototype.close).toHaveBeenCalledTimes(1);
});
it('clicking the backdrop (dialog element itself) closes the dialog', () => {
render(<ImageLightbox {...DEFAULT_PROPS} />);
const dialog = document.querySelector('dialog') as HTMLDialogElement;
fireEvent.click(dialog, { target: dialog });
expect(HTMLDialogElement.prototype.close).toHaveBeenCalledTimes(1);
});
it('clicking inside the dialog (not backdrop) does not close', () => {
render(<ImageLightbox {...DEFAULT_PROPS} />);
const dialog = document.querySelector('dialog') as HTMLDialogElement;
const inner = dialog.querySelector('img') as HTMLImageElement;
fireEvent.click(inner);
expect(HTMLDialogElement.prototype.close).not.toHaveBeenCalled();
});
it('dialog has accessible label matching alt text', () => {
render(<ImageLightbox {...DEFAULT_PROPS} />);
expect(document.querySelector('dialog')).toHaveAttribute('aria-label', 'My Project');
});
it('opening the lightbox blocks body scroll', () => {
render(<ImageLightbox {...DEFAULT_PROPS} />);
fireEvent.click(screen.getByRole('button', { name: 'My Project' }));
expect(document.body.style.overflow).toBe('hidden');
});
it('closing the lightbox restores body scroll', () => {
render(<ImageLightbox {...DEFAULT_PROPS} />);
fireEvent.click(screen.getByRole('button', { name: 'My Project' }));
const dialog = document.querySelector('dialog') as HTMLDialogElement;
fireEvent(dialog, new Event('close'));
expect(document.body.style.overflow).toBe('');
});
it('close button is positioned fixed', () => {
render(<ImageLightbox {...DEFAULT_PROPS} />);
const closeBtn = screen.getByRole('button', { name: /close/i, hidden: true });
expect(closeBtn).toHaveClass('fixed');
});
});
});
@@ -0,0 +1,197 @@
'use client';
import Image from 'next/image';
import { type SyntheticEvent, useRef } from 'react';
import { CloseIcon } from '$shared/assets/icons';
import { cn } from '$shared/lib';
import { Button } from '$shared/ui/Button';
import { Modal, type ModalHandle } from '$shared/ui/Modal';
export interface Props {
/**
* Image source URL
*/
src: string;
/**
* Image alt text, also used as the dialog accessible label
*/
alt: string;
/**
* CSS classes forwarded to the thumbnail button wrapper
*/
className?: string;
/**
* Skip lazy-loading and preload the thumbnail. Set true for above-the-fold
* images to improve LCP.
* @default false
*/
priority?: boolean;
/**
* Responsive `sizes` attribute for the thumbnail. Without this, next/image
* `fill` defaults to `100vw` and the browser fetches the largest srcset
* variant. Tune to match the actual rendered width at each breakpoint.
* @default '(min-width: 1024px) 56rem, 100vw'
*/
sizes?: string;
}
/**
* Clickable image thumbnail that opens a fullscreen brutalist dialog on click.
*
* Uses the View Transitions API to morph the thumbnail's rect into the dialog
* frame's rect (and back on close). The view-transition-name is seated
* just-in-time around each transition rather than living on the thumb at rest
* — a persistent name would isolate the thumb from any parent transition
* (e.g. the section-body slide-in), causing the image to snap into place
* while the rest of the section animates.
*/
export function ImageLightbox({
src,
alt,
className,
priority = false,
sizes = '(min-width: 1024px) 56rem, 100vw',
}: Props) {
const modalRef = useRef<ModalHandle>(null);
const thumbRef = useRef<HTMLButtonElement>(null);
const dialogFrameRef = useRef<HTMLDivElement>(null);
/* Shared static name across all instances. Only one dialog can be open at a
* time (showModal is browser-exclusive), and we set the name imperatively
* only during a transition — so at any snapshot, exactly one element has it. */
const vtName = 'lightbox-frame';
/**
* Drops the view-transition-name from both thumb and dialog frame. Called
* after the lightbox close transition settles (and as a safety net on any
* unexpected close path) so the thumb rejoins parent transitions.
*/
function clearVtNames() {
if (thumbRef.current) {
thumbRef.current.style.viewTransitionName = '';
}
if (dialogFrameRef.current) {
dialogFrameRef.current.style.viewTransitionName = '';
}
}
/**
* Runs `mutate` inside a view transition when supported; falls back to a
* plain synchronous call otherwise (Firefox without VT support, jsdom).
* Returns the transition handle so callers can await `finished` for cleanup.
*/
function withTransition(mutate: () => void): { finished: Promise<void> } | null {
const doc = document as Document & {
startViewTransition?: (cb: () => void) => { finished: Promise<void> };
};
if (typeof doc.startViewTransition === 'function') {
return doc.startViewTransition(mutate);
}
mutate();
return null;
}
function open() {
/* Seat the name on the thumb *before* startViewTransition so it's
* captured in the OLD snapshot. The thumb otherwise carries no vt-name. */
if (thumbRef.current) {
thumbRef.current.style.viewTransitionName = vtName;
}
withTransition(() => {
if (thumbRef.current) {
thumbRef.current.style.viewTransitionName = '';
}
if (dialogFrameRef.current) {
dialogFrameRef.current.style.viewTransitionName = vtName;
}
modalRef.current?.open();
});
}
function close() {
const transition = withTransition(() => {
if (dialogFrameRef.current) {
dialogFrameRef.current.style.viewTransitionName = '';
}
if (thumbRef.current) {
thumbRef.current.style.viewTransitionName = vtName;
}
modalRef.current?.close();
});
/* Drop the name from the thumb once the transition settles. Otherwise the
* thumb stays its own snapshot until the next open, isolated from any
* parent transition that runs in the meantime. */
if (transition) {
transition.finished.finally(clearVtNames);
} else {
clearVtNames();
}
}
/**
* Intercept ESC so it also runs through our view-transition-wrapped close.
* Without this, ESC would snap the dialog away without the morph.
*/
function handleCancel(e: SyntheticEvent<HTMLDialogElement>) {
e.preventDefault();
close();
}
return (
<>
<button
ref={thumbRef}
type="button"
onClick={open}
aria-label={alt}
className={cn('relative block w-full aspect-video overflow-hidden cursor-zoom-in', className)}
>
<Image
src={src}
alt={alt}
fill
loading={priority ? 'eager' : undefined}
priority={priority}
sizes={sizes}
className="object-cover"
/>
</button>
<Modal
ref={modalRef}
aria-label={alt}
className="lightbox bg-cream overflow-visible"
onClose={clearVtNames}
onCancel={handleCancel}
onBackdropClose={close}
>
{/* Wrapper carries the border as an `outline` (not `border`) — paint
* order is border→children→outline, so an `outline` is drawn ON TOP
* of any subpixel image bleed and stays fully visible. The dialog
* uses `overflow-visible` because outline can be clipped by an
* ancestor's overflow:hidden.
* The wrapper is the named VT element so the brutalist frame
* participates in the morph.
* aria-hidden: the dialog element itself carries the accessible label. */}
<div ref={dialogFrameRef} className="brutal-outline block w-fit">
{/* Explicit width/height are placeholders next/image requires when not using `fill`; CSS
* (`lightbox-image` max-w/max-h + `w-auto h-auto`) drives the actual rendered size, so
* the dialog still hugs the image's intrinsic dimensions (capped at viewport bounds).
* `sizes="100vw"` hints the browser to fetch the srcset variant matching viewport width. */}
<Image
src={src}
alt={alt}
width={2000}
height={2000}
loading="lazy"
sizes="100vw"
aria-hidden={true}
className="lightbox-image block w-auto h-auto"
/>
</div>
<Button variant="outline" size="sm" onClick={close} aria-label="Close image" className="fixed top-3 right-3">
<CloseIcon />
</Button>
</Modal>
</>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import parse from 'html-react-parser';
import { cn } from '$shared/lib';
type Props = {
export interface Props {
/**
* SVG markup string to inline as React elements
*/
@@ -10,7 +10,7 @@ type Props = {
* Additional CSS classes on the wrapper span
*/
className?: string;
};
}
/**
* Parses an SVG markup string into React elements.
+1
View File
@@ -1 +1,2 @@
export type { LinkVariant } from './ui/Link/Link';
export { Link } from './ui/Link/Link';
+1
View File
@@ -0,0 +1 @@
export { Modal, type ModalHandle } from './ui/Modal';
+56
View File
@@ -0,0 +1,56 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { useRef } from 'react';
import { Button } from '../../Button';
import { Modal, type ModalHandle } from './Modal';
const meta: Meta<typeof Modal> = {
title: 'Shared/Modal',
component: Modal,
};
export default meta;
type Story = StoryObj<typeof Modal>;
/**
* Imperative trigger — Storybook can't drive a ref-based API directly, so each
* story renders its own trigger button that calls `modalRef.current?.open()`.
*/
function TriggerWrapper({ className, children }: { className?: string; children: React.ReactNode }) {
const modalRef = useRef<ModalHandle>(null);
return (
<div className="p-8">
<Button onClick={() => modalRef.current?.open()}>Open modal</Button>
<Modal ref={modalRef} aria-label="Story modal" className={className}>
<div className="p-8 max-w-md">
{children}
<div className="mt-4">
<Button variant="outline" size="sm" onClick={() => modalRef.current?.close()}>
Close
</Button>
</div>
</div>
</Modal>
</div>
);
}
export const Default: Story = {
render: () => (
<TriggerWrapper className="bg-cream brutal-border">
<h3 className="mb-2">Default modal</h3>
<p>Native dialog with scroll lock, backdrop-click close, and ESC close.</p>
</TriggerWrapper>
),
};
export const Brutalist: Story = {
render: () => (
<TriggerWrapper className="bg-blue text-cream brutal-border">
<h3 className="mb-2 text-cream">Brutalist modal</h3>
<p className="text-cream">
Blue background, hard border. Style is fully driven by the className you pass Modal stays neutral.
</p>
</TriggerWrapper>
),
};
+145
View File
@@ -0,0 +1,145 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { createRef } from 'react';
import { Modal, type ModalHandle } from './Modal';
// jsdom does not implement dialog methods — mock them
beforeAll(() => {
HTMLDialogElement.prototype.showModal = vi.fn();
HTMLDialogElement.prototype.close = vi.fn();
});
beforeEach(() => {
vi.clearAllMocks();
document.body.style.overflow = '';
});
describe('Modal', () => {
describe('rendering', () => {
it('renders children', () => {
render(<Modal aria-label="Test">child content</Modal>);
expect(screen.getByText('child content')).toBeInTheDocument();
});
it('forwards className to dialog element', () => {
render(
<Modal aria-label="Test" className="extra-class">
x
</Modal>,
);
expect(document.querySelector('dialog')).toHaveClass('extra-class');
});
it('sets aria-label on dialog', () => {
render(<Modal aria-label="My modal">x</Modal>);
expect(document.querySelector('dialog')).toHaveAttribute('aria-label', 'My modal');
});
});
describe('imperative API', () => {
it('open() calls showModal', () => {
const ref = createRef<ModalHandle>();
render(
<Modal ref={ref} aria-label="Test">
x
</Modal>,
);
ref.current?.open();
expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledOnce();
});
it('close() calls dialog.close', () => {
const ref = createRef<ModalHandle>();
render(
<Modal ref={ref} aria-label="Test">
x
</Modal>,
);
ref.current?.close();
expect(HTMLDialogElement.prototype.close).toHaveBeenCalledOnce();
});
});
describe('scroll lock', () => {
it('locks body scroll on open()', () => {
const ref = createRef<ModalHandle>();
render(
<Modal ref={ref} aria-label="Test">
x
</Modal>,
);
ref.current?.open();
expect(document.body.style.overflow).toBe('hidden');
});
it('restores body scroll when the dialog close event fires', () => {
const ref = createRef<ModalHandle>();
render(
<Modal ref={ref} aria-label="Test">
x
</Modal>,
);
ref.current?.open();
const dialog = document.querySelector('dialog') as HTMLDialogElement;
fireEvent(dialog, new Event('close'));
expect(document.body.style.overflow).toBe('');
});
});
describe('backdrop interaction', () => {
it('closes on backdrop click (dialog element itself)', () => {
render(<Modal aria-label="Test">x</Modal>);
const dialog = document.querySelector('dialog') as HTMLDialogElement;
fireEvent.click(dialog, { target: dialog });
expect(HTMLDialogElement.prototype.close).toHaveBeenCalledOnce();
});
it('does not close on click inside content', () => {
render(
<Modal aria-label="Test">
<span>inner</span>
</Modal>,
);
fireEvent.click(screen.getByText('inner'));
expect(HTMLDialogElement.prototype.close).not.toHaveBeenCalled();
});
it('routes backdrop click through onBackdropClose when provided (skips default close)', () => {
const onBackdropClose = vi.fn();
render(
<Modal aria-label="Test" onBackdropClose={onBackdropClose}>
x
</Modal>,
);
const dialog = document.querySelector('dialog') as HTMLDialogElement;
fireEvent.click(dialog, { target: dialog });
expect(onBackdropClose).toHaveBeenCalledOnce();
expect(HTMLDialogElement.prototype.close).not.toHaveBeenCalled();
});
});
describe('callbacks', () => {
it('invokes onClose when close event fires', () => {
const onClose = vi.fn();
render(
<Modal aria-label="Test" onClose={onClose}>
x
</Modal>,
);
const dialog = document.querySelector('dialog') as HTMLDialogElement;
fireEvent(dialog, new Event('close'));
expect(onClose).toHaveBeenCalledOnce();
});
it('invokes onCancel when cancel event fires', () => {
const onCancel = vi.fn();
render(
<Modal aria-label="Test" onCancel={onCancel}>
x
</Modal>,
);
const dialog = document.querySelector('dialog') as HTMLDialogElement;
fireEvent(dialog, new Event('cancel'));
expect(onCancel).toHaveBeenCalledOnce();
});
});
});
+121
View File
@@ -0,0 +1,121 @@
'use client';
import {
type DialogHTMLAttributes,
forwardRef,
type KeyboardEvent,
type MouseEvent,
useEffect,
useImperativeHandle,
useRef,
} from 'react';
import { cn } from '$shared/lib';
export type ModalHandle = {
/**
* Opens the dialog as a modal and locks body scroll.
*/
open: () => void;
/**
* Closes the dialog. Body scroll is restored on the native `close` event,
* so any close path (ESC, backdrop, this method) restores it.
*/
close: () => void;
};
export interface Props extends DialogHTMLAttributes<HTMLDialogElement> {
/**
* Called when the user activates the backdrop (click or Enter/Space).
* Replaces the default close — useful when the close path must be wrapped
* (e.g. in a view transition). When omitted, the dialog closes itself.
*/
onBackdropClose?: () => void;
}
/**
* Thin wrapper over native `<dialog>` with `showModal()`. Locks body scroll
* while open, restores it on any close path, and treats backdrop clicks /
* Enter|Space on the backdrop as a close intent. Style the backdrop externally
* via a className on this component + a `::backdrop` selector.
*
* All standard dialog attributes pass through — `aria-label`, `onClose`,
* `onCancel`, etc. Use `onCancel` with `e.preventDefault()` to intercept ESC
* (e.g. to route close through a view transition wrapper).
*/
export const Modal = forwardRef<ModalHandle, Props>(function Modal(
{ className, onClick, onKeyUp, onBackdropClose, children, ...rest },
ref,
) {
const dialogRef = useRef<HTMLDialogElement>(null);
/* Either run consumer-supplied close path (e.g. view-transition-wrapped) or
* fall back to closing the dialog directly. Shared between click and keyboard
* backdrop activation. */
function triggerBackdropClose() {
if (onBackdropClose) {
onBackdropClose();
} else {
dialogRef.current?.close();
}
}
useImperativeHandle(ref, () => ({
open: () => {
document.body.style.overflow = 'hidden';
dialogRef.current?.showModal();
},
close: () => {
dialogRef.current?.close();
},
}));
useEffect(() => {
const el = dialogRef.current;
if (!el) {
return;
}
/* Scroll restore lives on the native event listener (not a React onClose
* prop) so it can't be unintentionally overridden by a caller that passes
* their own onClose. Both fire for the same close event. */
const handleClose = () => {
document.body.style.overflow = '';
};
el.addEventListener('close', handleClose);
return () => el.removeEventListener('close', handleClose);
}, []);
/**
* Closes the dialog when the user clicks the backdrop area directly.
* Target===currentTarget distinguishes the <dialog> element itself (the
* backdrop hit-area) from its content children. Caller's onClick still runs.
*/
function handleClick(e: MouseEvent<HTMLDialogElement>) {
if (e.target === e.currentTarget) {
triggerBackdropClose();
}
onClick?.(e);
}
/**
* Keyboard equivalent of backdrop click — Enter/Space on the backdrop area
* closes the dialog. ESC is handled natively by `showModal()`.
*/
function handleKeyUp(e: KeyboardEvent<HTMLDialogElement>) {
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
triggerBackdropClose();
}
onKeyUp?.(e);
}
return (
<dialog
ref={dialogRef}
{...rest}
className={cn('fixed inset-0 m-auto p-0 overflow-hidden', className)}
onClick={handleClick}
onKeyUp={handleKeyUp}
>
{children}
</dialog>
);
});
+2 -2
View File
@@ -1,7 +1,7 @@
import parse from 'html-react-parser';
import { cn } from '$shared/lib';
type Props = {
export interface Props {
/**
* HTML string from PocketBase rich-text editor
*/
@@ -10,7 +10,7 @@ type Props = {
* Additional CSS classes merged onto the wrapper div
*/
className?: string;
};
}
/**
* Renders a PocketBase rich-text HTML string as React elements.
+5 -14
View File
@@ -13,27 +13,18 @@ type Story = StoryObj<typeof Section>;
export const AllBackgrounds: Story = {
render: () => (
<div>
<Section background="ochre" className="py-12">
<Section background="cream" className="py-12">
<Container>
<h2>Ochre Section</h2>
<h2>Cream Section</h2>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua.
</p>
</Container>
</Section>
<Section background="slate" className="py-12">
<Section background="blue" className="py-12">
<Container>
<h2>Slate Section</h2>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua.
</p>
</Container>
</Section>
<Section background="white" className="py-12">
<Container>
<h2>White Section</h2>
<h2>Blue Section</h2>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua.
@@ -46,7 +37,7 @@ export const AllBackgrounds: Story = {
export const Bordered: Story = {
render: () => (
<Section background="ochre" bordered className="py-12">
<Section background="cream" bordered className="py-12">
<Container>
<h2>Bordered Section</h2>
<p>
@@ -6,7 +6,7 @@ import { Fragment, type ReactNode, ViewTransition as VT } from 'react';
*/
const Transition = (VT ?? Fragment) as typeof VT;
type Props = {
export interface Props {
/**
* Maps to the view-transition-name CSS property
*/
@@ -15,7 +15,7 @@ type Props = {
* Content to animate
*/
children: ReactNode;
};
}
/**
* Wraps children in React's ViewTransition when available,
+2 -1
View File
@@ -4,11 +4,12 @@ export type { ButtonSize, ButtonVariant } from './Button';
export { Button } from './Button';
export type { CardBackground } from './Card';
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './Card';
export { ImageLightbox } from './ImageLightbox';
export { InlineSvg } from './InlineSvg';
export { Input, Textarea } from './Input';
export type { LinkVariant } from './Link';
export { Link } from './Link';
export { Modal, type ModalHandle } from './Modal';
export { RichText } from './RichText';
export type { ContainerSize, SectionBackground } from './Section';
export { Container, Section } from './Section';
+1 -1
View File
@@ -19,7 +19,7 @@ export async function Footer() {
const socials = contacts?.expand?.socials ?? [];
return (
<footer className="fixed bottom-0 left-0 right-0 z-50 h-footer md:h-footer-wide brutal-border-top bg-background px-8 lg:px-16 flex items-center">
<footer className="footer-vt fixed bottom-0 left-0 right-0 z-50 h-footer md:h-footer-wide brutal-border-top bg-background px-4 sm:px-8 lg:px-16 flex items-center">
<div className="w-full flex flex-row justify-between gap-4">
<div className="flex flex-wrap items-center gap-6 sm:gap-4">
{email && (
-4
View File
@@ -1,4 +0,0 @@
export type { NavItem } from './model/types';
export { MobileNav } from './ui/MobileNav';
export { SidebarNav } from './ui/SidebarNav';
export { UtilityBar } from './ui/UtilityBar';
-14
View File
@@ -1,14 +0,0 @@
export type NavItem = {
/**
* Section HTML id for anchor scrolling
*/
id: string;
/**
* Display label
*/
label: string;
/**
* Display number prefix (e.g. "01")
*/
number: string;
};
@@ -1,28 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { MobileNav } from './MobileNav';
// MobileNav is lg:hidden — it renders only on mobile viewports.
// Use the viewport toolbar in Storybook to switch to a mobile size to see it.
const meta: Meta<typeof MobileNav> = {
title: 'Widgets/MobileNav',
component: MobileNav,
parameters: {
viewport: {
defaultViewport: 'mobile1',
},
},
};
export default meta;
type Story = StoryObj<typeof MobileNav>;
export const Default: Story = {
args: {
items: [
{ id: 'bio', label: 'Bio', number: '01' },
{ id: 'work', label: 'Work', number: '02' },
{ id: 'contact', label: 'Contact', number: '03' },
],
},
};
@@ -1,62 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { NavItem } from '../model/types';
import { MobileNav } from './MobileNav';
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', () => {
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: /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 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('Intro')).toBeInTheDocument();
});
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' }));
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();
});
});
});
-63
View File
@@ -1,63 +0,0 @@
'use client';
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
*/
items: NavItem[];
}
/**
* 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();
// 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-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-blue text-cream"
>
{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}`} 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
className="font-heading text-lg font-black"
style={{ fontVariationSettings: '"WONK" 1, "SOFT" 0' }}
>
{item.label}
</span>
</div>
</Link>
))}
</div>
)}
</div>
);
}
@@ -1,29 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { SidebarNav } from './SidebarNav';
// SidebarNav is hidden lg:block — it renders only on desktop viewports.
// Use the viewport toolbar in Storybook to switch to a desktop size to see it.
const meta: Meta<typeof SidebarNav> = {
title: 'Widgets/SidebarNav',
component: SidebarNav,
parameters: {
layout: 'fullscreen',
viewport: {
defaultViewport: 'desktop',
},
},
};
export default meta;
type Story = StoryObj<typeof SidebarNav>;
export const Default: Story = {
args: {
items: [
{ id: 'bio', label: 'Bio', number: '01' },
{ id: 'work', label: 'Work', number: '02' },
{ id: 'contact', label: 'Contact', number: '03' },
],
},
};
@@ -1,84 +0,0 @@
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 nav 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 (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');
});
});
});
-87
View File
@@ -1,87 +0,0 @@
'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';
/**
* Props for SidebarNav.
*/
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-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>
<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-cream px-6 py-4 transition-all duration-300',
isActive(item)
? 'shadow-brutal-2xl 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>
);
}
@@ -1,20 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { UtilityBar } from './UtilityBar';
const meta: Meta<typeof UtilityBar> = {
title: 'Widgets/UtilityBar',
component: UtilityBar,
decorators: [
(Story) => (
<div className="relative h-24 bg-ochre-clay">
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof UtilityBar>;
export const Default: Story = {};
@@ -1,29 +0,0 @@
import { render, screen } from '@testing-library/react';
import { UtilityBar } from './UtilityBar';
describe('UtilityBar', () => {
describe('rendering', () => {
it('renders "Contact" label', () => {
render(<UtilityBar />);
expect(screen.getByText('Contact')).toBeInTheDocument();
});
it('renders email link with correct href', () => {
render(<UtilityBar />);
const link = screen.getByRole('link', { name: 'hello@allmy.work' });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', 'mailto:hello@allmy.work');
});
it('renders "Download CV" button', () => {
render(<UtilityBar />);
expect(screen.getByRole('button', { name: /download cv/i })).toBeInTheDocument();
});
it('Download CV button has primary variant class', () => {
render(<UtilityBar />);
const btn = screen.getByRole('button', { name: /download cv/i });
expect(btn).toHaveClass('bg-blue');
});
});
});
-32
View File
@@ -1,32 +0,0 @@
'use client';
import { CONTACT_LINKS } from '$shared/lib';
import { Button } from '$shared/ui';
/**
* Fixed bottom utility bar with contact info and CV download.
*/
export function UtilityBar() {
/**
* Handles CV download action.
*/
function handleDownloadCV() {
console.log('Downloading CV...');
}
return (
<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:opacity-60 transition-opacity">
{CONTACT_LINKS.email}
</a>
</div>
<Button variant="primary" size="sm" onClick={handleDownloadCV}>
Download CV
</Button>
</div>
</div>
);
}
@@ -13,9 +13,14 @@ export default async function ProjectsSection() {
tags: ['projects'],
});
/* Mark the first project that actually has an image as LCP-priority.
* Using `index === 0` alone misses the case where the first card has no
* image and the LCP candidate ends up being the next card's image. */
const lcpIndex = items.findIndex((project) => project.image);
return (
<div className="space-y-6 max-w-section">
{items.map((project) => (
{items.map((project, index) => (
<ProjectCard
key={project.id}
title={project.title}
@@ -24,6 +29,7 @@ export default async function ProjectsSection() {
tags={project.stack}
url={project.url}
imageUrl={project.image ? buildFileUrl(project.collectionId, project.id, project.image) : undefined}
priority={index === lcpIndex}
/>
))}
</div>
@@ -0,0 +1,41 @@
'use client';
import type { ErrorInfo, ReactNode } from 'react';
import { Component } from 'react';
export interface Props {
/**
* Section content to render
*/
children: ReactNode;
}
type State = {
/**
* Whether an error was caught
*/
hasError: boolean;
};
/**
* Isolates a single section's render errors so a broken section
* does not crash the rest of the page.
*/
export class SectionErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
override componentDidCatch(error: Error, info: ErrorInfo) {
console.error('[SectionErrorBoundary]', error, info.componentStack);
}
override render() {
if (this.state.hasError) {
return null;
}
return this.props.children;
}
}
@@ -5,6 +5,7 @@ import ExperienceSection from '../../../ExperienceSection/ui/ExperienceSection/E
import IntroSection from '../../../IntroSection/ui/IntroSection/IntroSection';
import ProjectsSection from '../../../ProjectsSection/ui/ProjectsSection/ProjectsSection';
import SkillsSection from '../../../SkillsSection/ui/SkillsSection/SkillsSection';
import { SectionErrorBoundary } from '../SectionErrorBoundary/SectionErrorBoundary';
/**
* Props for the SectionFactory widget.
@@ -36,5 +37,9 @@ export function SectionFactory({ slug }: SectionFactoryProps) {
notFound();
}
return <Component />;
return (
<SectionErrorBoundary>
<Component />
</SectionErrorBoundary>
);
}
@@ -3,7 +3,7 @@ import { Children } from 'react';
import type { SectionRecord } from '$entities/Section';
import { SectionAccordion } from '$entities/Section';
type Props = {
export interface Props {
/**
* Ordered section metadata — drives navigation labels and IDs
*/
@@ -17,7 +17,7 @@ type Props = {
* Pre-rendered RSC content slots, one per section, matched by index
*/
children: ReactNode;
};
}
/**
* Renders all portfolio sections as an accordion list.
-1
View File
@@ -1,2 +1 @@
export * from './Footer';
export * from './Navigation';
+2 -9
View File
@@ -35,13 +35,6 @@
"$routes": ["./src/routes"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"],
"exclude": ["node_modules", "**/*.stories.ts", "**/*.stories.tsx"]
}
+1 -1
View File
@@ -1,5 +1,5 @@
import path from 'node:path';
import react from '@vitejs/plugin-react';
import path from 'path';
import { defineConfig } from 'vitest/config';
export default defineConfig({