Compare commits

...

29 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
50 changed files with 886 additions and 551 deletions
+2
View File
@@ -24,6 +24,8 @@ jobs:
with: with:
context: . context: .
push: true push: true
build-args: |
PB_PUBLIC_URL=${{ vars.PB_PUBLIC_URL }}
tags: | tags: |
docker.allmy.work/${{ gitea.repository }}:latest docker.allmy.work/${{ gitea.repository }}:latest
docker.allmy.work/${{ gitea.repository }}:${{ gitea.sha }} docker.allmy.work/${{ gitea.repository }}:${{ gitea.sha }}
+3
View File
@@ -7,6 +7,8 @@ RUN yarn install --immutable
FROM node:22-alpine AS builder FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
RUN corepack enable RUN corepack enable
ARG PB_PUBLIC_URL
ENV PB_PUBLIC_URL=$PB_PUBLIC_URL
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
@@ -22,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 /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN mkdir -p .next/cache && chown -R nextjs:nodejs .next/cache
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
CMD ["node", "server.js"] CMD ["node", "server.js"]
+2 -2
View File
@@ -19,9 +19,9 @@ export async function generateStaticParams() {
} }
} }
type Props = { export interface Props {
params: Promise<{ slug?: string[] }>; params: Promise<{ slug?: string[] }>;
}; }
/** /**
* Portfolio page — one route per section, sections list always visible. * Portfolio page — one route per section, sections list always visible.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+1 -1
View File
@@ -14,7 +14,7 @@ export const metadata: Metadata = {
*/ */
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en"> <html lang="en" data-scroll-behavior="smooth">
<body className={`${fraunces.variable} ${publicSans.variable} pb-footer md:pb-footer-wide`}> <body className={`${fraunces.variable} ${publicSans.variable} pb-footer md:pb-footer-wide`}>
{children} {children}
<Footer /> <Footer />
+7 -7
View File
@@ -1,9 +1,10 @@
import type { NextConfig } from 'next'; import type { NextConfig } from 'next';
/* PocketBase origin — used to allowlist remote images. /* Public PocketBase host for the image optimizer allowlist.
* PB_HOSTNAME and PB_PORT are server-only env vars; safe to read here. */ * Derived from PB_PUBLIC_URL (e.g. https://cms.allmy.work) at BUILD time —
const pbHostname = process.env.PB_HOSTNAME ?? '127.0.0.1'; * remotePatterns is frozen into the build, so PB_PUBLIC_URL must be present
const pbPort = process.env.PB_PORT ?? '8090'; * 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 = { const nextConfig: NextConfig = {
output: 'standalone', output: 'standalone',
@@ -11,9 +12,8 @@ const nextConfig: NextConfig = {
images: { images: {
remotePatterns: [ remotePatterns: [
{ {
protocol: 'http', protocol: 'https',
hostname: pbHostname, hostname: pbPublicHost,
port: pbPort,
pathname: '/api/files/**', pathname: '/api/files/**',
}, },
], ],
+1 -1
View File
@@ -1 +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="M548.399 188.863l5.08 2.98 5.31 3.14 10.86 6.36 37.64 22.31 24.31 14.34 22.14 13.01 21.68 12.74 27.59 16.33 11.89 7.04 17.82 10.6 10.15 5.97 2.29 1.32 4.34 2.48c12.01 6.94 19.11 15.63 22.82 29.08 2.58 8.48 6.64 16.47 10.39 24.48l13.44 29.44 1.19 2.65 15.58 35.08 8.47 18.57 10.13 22.46 9.25 20.24 9.32 20.38.9 1.99 14.33 32.09 11.12 24.38c5.32 11.45 7.13 21.43 2.9 33.66-4.87 11.08-12.12 15.85-22.4 21.67l-13.85 8.17-8.82 5.23-1.77 1.04-11.03 6.52-25.76 15.31-33.84 20.14-16.66 9.86-3.34 1.98-18.26 10.84-39.33 23.24-1.83 1.07-3.6 2.11-46.12 27.36-23.39 13.9-17.5 10.44-28.56 16.88-20.14 11.89c-18.05 10.75-34.67 20.4-56.43 15.11-9.12-2.9-17.13-7.74-25.31-12.63l-2.94-1.75-15.97-9.6-15.4-9.02c-7.83-4.5-15.55-9.17-23.25-13.88-10.2-6.23-20.48-12.28-30.85-18.22l-28.56-16.9-17.22-10.26-2.7-1.59-5.38-3.17-42.96-25.56-28.22-16.69-23.55-13.96-16.62-9.9-1.74-1.03-25.73-15.14-33.78-20.03-4.23-2.56c-11.94-7.23-18.46-13.09-22.47-26.69-1.71-8.23-.74-15.3 2.59-22.88.15-.37.15-.37.94-2.22 3.26-7.6 6.71-15.11 10.2-22.61l5.64-12.2 1.12-2.43 8.46-18.9 14.81-33.18 1.24-2.74 17.94-39.37 16.42-36.34 9.38-20.84 5.25-11.62 4.32-9.55 4.95-10.98 1.49-3.29c2.47-5.5 4.58-10.9 6.14-16.74 4.02-12.73 15.77-19.9 26.68-26.16l5.74-3.31 9.92-5.71 22.65-13.35 27.07-16.07 47.41-28 10.09-6 2.03-1.2 32.84-19.35 19.12-11.26 8.05-4.74 10.1-6.08c9.76-5.92 18.87-10.15 29.98-12.93l1.88-.54c22.28-4.83 45.18 1.99 64.12 13.29zm-91.34 25.69c-3.68 2.68-7.6 4.77-11.61 6.91-5.64 3.05-11.15 6.34-16.67 9.59l-3.84 2.25-27.29 16.28-25.52 14.9-25.46 15.06-30.61 18.02-19.66 11.55-2.62 1.54-14.1 8.34-9.84 5.74-2.07 1.2-5.37 3.08c-4.44 3.11-7.64 6.09-8.62 11.6.77 4.33 2.45 6.6 5.62 9.5 3.56 2.44 7.29 4.53 11.06 6.63l6.56 3.73 3.29 1.87c4.3 2.46 8.54 5.01 12.78 7.58l25.12 14.88 21.69 12.81 21.25 12.56 54.94 32.57 33.61 19.8 9.7 5.69 6.17 3.62 7.6 4.58c12.1 7.39 25.07 9.88 39.23 7.12 13.28-4.14 25.32-12.74 37.09-19.87 4.27-2.59 8.56-5.12 12.91-7.57 6.26-3.53 12.41-7.23 18.56-10.94l27.82-16.5 32.29-19.03 17.95-10.59 20.13-12 26.09-15.48 5.51-3.24 17.27-10.01 3.09-1.78 5.68-3.23c7.71-4.47 7.71-4.47 9.61-9.2.65-4.46.51-6.29-2.14-9.98-2.67-3.03-5.58-4.96-9.09-6.97l-1.79-1.04-5.73-3.26-3.97-2.29-7.81-4.47-12.41-7.24-3.95-2.32-9-5.32-5.36-3.17-2.74-1.62-22.04-12.95-30.97-18.37-51.53-30.48-22.52-13.16-5.62-3.29-15.63-9.27-2.33-1.39-4.01-2.46c-23.38-14.06-50.24-5.72-70.7 9.49zm-214.66 153.56a627.1 627.1 0 0 0-13.62 28.56l-1.83 4.07-5.55 12.37-1.67 3.72-1.6 3.58-1.59 3.55-3.17 7.09-12.47 27.56-22.07 49.01-18.69 41.38-3.11 6.81-1.99 4.35-.97 2.12-5.46 11.84-2.21 4.8-.97 2.04c-1.76 3.88-2.75 6.91-2.03 11.15 3.81 6.22 9.69 9.08 15.94 12.56l6.5 3.71 3.32 1.88 14.68 8.6 5.44 3.22 2.61 1.55 11.45 6.73 17.4 10.36 16.35 9.67 13.06 7.78 15.18 9 1.9 1.11 9.48 5.54 20.72 12.47c4.07 2.49 8.2 4.89 12.35 7.26l16.93 9.93 18.25 10.88 2.86 1.68 8.58 5.07 40.42 23.94 22.83 13.5 20.32 12.11 17.75 10.5 22.28 13.48 2.87 1.76 2.57 1.58c1.96 1.13 1.96 1.13 2.96 1.13v-292l-12-3c-12.22-4.97-23.51-12.32-34.79-19.09l-16.24-9.55-27.3-16.16-32.83-19.39-1.85-1.08-1.86-1.1-3.75-2.19-1.9-1.11-24.79-14.73-22.5-13.29-25.46-15.07-17.72-10.49c-6.31-3.72-12.56-7.53-18.74-11.46-2.27-1.29-2.27-1.29-4.27-1.29zm512.06.85l-2.2 1.36-2.47 1.51-2.64 1.65-5.52 3.39-2.73 1.69-9.44 5.74-1.71 1.02-32.06 18.86-54.01 31.92-37.08 21.85-24.87 14.77-22.59 13.3-13.69 8.06c-10.31 6.2-19.16 10.39-31.05 13.03v292l18.81-10.56 2.15-1.28 4.62-2.73 7.53-4.44 32.91-19.6 25.98-15.39 26.56-15.75 22.13-13.06 28.08-16.64 41.77-24.75 38.46-22.8 26.08-15.5 19.73-11.69 22.44-13.25 5.82-3.43 4.02-2.35 6.22-3.65 1.87-1.09c4.04-2.39 7.68-4.76 9.82-9.04.43-5.52-.56-9.03-2.96-13.98l-.94-1.96-3.04-6.25-2.09-4.36-4.12-8.59c-2.26-4.69-4.42-9.42-6.54-14.17l-1.04-2.34-5.92-13.33-2.91-6.58-1.52-3.42-13.69-30.18-15.17-33.71-18.88-41.69-10.04-22.18-.91-2.08-4.21-9.66-1.48-3.38-1.28-2.95c-1.64-2.85-3.07-3.71-6.2-2.34z" fill="#1334da"/></svg> <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>

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -55,10 +55,5 @@ describe('SectionAccordion', () => {
render(<SectionAccordion {...activeProps} />); render(<SectionAccordion {...activeProps} />);
expect(screen.queryByRole('link')).not.toBeInTheDocument(); 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"> <section id={id} className="scroll-mt-8">
{isActive ? ( {isActive ? (
<div className="mb-6 sm:mb-12"> <div className="mb-6 sm:mb-12">
<ViewTransitionWrapper name="section-content"> <ViewTransitionWrapper name="section-title">
<div className="mb-6 sm:mb-16"> <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> <h1 className="font-heading font-black text-xl sm:text-section-title leading-[1.2] mb-0">{heading}</h1>
</div> </div>
</ViewTransitionWrapper> </ViewTransitionWrapper>
<ViewTransitionWrapper name="section-body"> <ViewTransitionWrapper name="section-body">
<div className="section-content">{children}</div> <div>{children}</div>
</ViewTransitionWrapper> </ViewTransitionWrapper>
</div> </div>
) : ( ) : (
@@ -1,6 +1,6 @@
import { Badge, Card, CardSidebar, CardTitle, RichText } from '$shared/ui'; import { Badge, Card, CardSidebar, CardTitle, RichText } from '$shared/ui';
type Props = { export interface Props {
/** /**
* Job title * Job title
*/ */
@@ -25,7 +25,7 @@ type Props = {
* Additional CSS classes forwarded to the card * Additional CSS classes forwarded to the card
*/ */
className?: string; className?: string;
}; }
/** /**
* Work experience card with sidebar layout. * Work experience card with sidebar layout.
@@ -37,9 +37,9 @@ export function ExperienceCard({ title, company, period, description, stack, cla
<Card className={className}> <Card className={className}>
<CardSidebar <CardSidebar
sidebar={ 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-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 && ( {stack.length > 0 && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{stack.map((tech) => ( {stack.map((tech) => (
@@ -2,7 +2,7 @@ import Image from 'next/image';
import { Card, RichText } from '$shared/ui'; import { Card, RichText } from '$shared/ui';
import { ProjectMetadata } from '../ProjectMetadata/ProjectMetadata'; import { ProjectMetadata } from '../ProjectMetadata/ProjectMetadata';
type Props = { export interface Props {
/** /**
* Project name * Project name
*/ */
@@ -36,7 +36,7 @@ type Props = {
* @default false * @default false
*/ */
reverse?: boolean; reverse?: boolean;
}; }
/** /**
* Full-width detailed project card with metadata sidebar. * Full-width detailed project card with metadata sidebar.
@@ -124,5 +124,11 @@ describe('ProjectCard', () => {
const imgWrapper = container.querySelector('img')?.parentElement; const imgWrapper = container.querySelector('img')?.parentElement;
expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border'); 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 { 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 * Project name
*/ */
@@ -27,14 +26,20 @@ type Props = {
* Optional preview image URL * Optional preview image URL
*/ */
imageUrl?: string; 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. * Project card with sidebar layout.
* Sidebar: year badge, stack tags, View Project button. * Sidebar: year badge, stack tags, View Project button.
* Main: title, optional image, description. * 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 ( return (
<Card className={cn('group hover:shadow-brutal-xl transition-shadow duration-300')}> <Card className={cn('group hover:shadow-brutal-xl transition-shadow duration-300')}>
<CardSidebar <CardSidebar
@@ -50,7 +55,7 @@ export function ProjectCard({ title, year, description, tags, url, imageUrl }: P
))} ))}
</div> </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 View Project
</Button> </Button>
</div> </div>
@@ -59,9 +64,7 @@ export function ProjectCard({ title, year, description, tags, url, imageUrl }: P
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<CardTitle className="font-heading">{title}</CardTitle> <CardTitle className="font-heading">{title}</CardTitle>
{imageUrl && ( {imageUrl && (
<div className="brutal-border aspect-video bg-blue overflow-hidden relative"> <ImageLightbox src={imageUrl} alt={title} priority={priority} className="brutal-border bg-blue" />
<Image src={imageUrl} alt={title} fill className="object-cover" />
</div>
)} )}
<RichText html={description} /> <RichText html={description} />
</div> </div>
@@ -1,6 +1,6 @@
import { cn } from '$shared/lib'; import { cn } from '$shared/lib';
type Props = { export interface Props {
/** /**
* Project year * Project year
*/ */
@@ -17,7 +17,7 @@ type Props = {
* Additional CSS classes * Additional CSS classes
*/ */
className?: string; className?: string;
}; }
/** /**
* Sidebar metadata display for a project: year, role, and stack. * Sidebar metadata display for a project: year, role, and stack.
+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, collectionId: string,
recordId: string, recordId: string,
filename: 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 { ): string {
return `${baseUrl}/api/files/${collectionId}/${recordId}/${filename}`; return `${baseUrl}/api/files/${collectionId}/${recordId}/${filename}`;
} }
+117 -43
View File
@@ -95,6 +95,7 @@
--duration-slow: 350ms; --duration-slow: 350ms;
--duration-spring: 220ms; --duration-spring: 220ms;
--delay-normal: 200ms; --delay-normal: 200ms;
--slide-section-body-in: clamp(1.25rem, 5vw, 3rem);
} }
@theme inline { @theme inline {
@@ -152,6 +153,10 @@
html { html {
font-size: var(--font-size); 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 { body {
@@ -162,30 +167,16 @@
overflow-x: hidden; 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 { body::before {
@apply grain-pattern;
content: ""; content: "";
position: fixed; position: fixed;
inset: 0; 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; pointer-events: none;
z-index: 1; z-index: 100;
} }
h1, h1,
@@ -270,6 +261,18 @@
@utility brutal-border-right { @utility brutal-border-right {
border-right: var(--border-width) solid var(--blue); 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 */ /* Apply Fraunces variable axes to non-heading elements using the heading font */
.font-wonk { .font-wonk {
font-variation-settings: font-variation-settings:
@@ -313,15 +316,12 @@
.rich-text ul { .rich-text ul {
list-style: none; list-style: none;
padding-left: 0; padding-left: 1.5em;
margin: 1em 0; margin: 1em 0;
} }
.rich-text ul li { .rich-text ul li {
display: grid; text-indent: -1.5em;
grid-template-columns: auto 1fr;
gap: 0.65em;
align-items: start;
margin-top: 0.5em; margin-top: 0.5em;
} }
@@ -331,6 +331,10 @@
.rich-text ul li::before { .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 */
text-indent: 0;
color: var(--blue); color: var(--blue);
font-size: 0.55em; font-size: 0.55em;
/* line-height matches parent so diamond centers within the first line box */ /* line-height matches parent so diamond centers within the first line box */
@@ -338,14 +342,14 @@
} }
/* Cross-section view transition (navigation between sections) */ /* Cross-section view transition (navigation between sections) */
::view-transition-old(section-content) { ::view-transition-old(section-title) {
animation-name: section-fade-out; animation-name: section-fade-out;
animation-duration: var(--duration-normal); animation-duration: var(--duration-normal);
animation-timing-function: var(--ease-default); animation-timing-function: var(--ease-default);
animation-fill-mode: both; animation-fill-mode: both;
} }
::view-transition-new(section-content) { ::view-transition-new(section-title) {
animation-name: section-fade-in; animation-name: section-fade-in;
animation-duration: var(--duration-spring); animation-duration: var(--duration-spring);
animation-timing-function: var(--ease-spring); animation-timing-function: var(--ease-spring);
@@ -374,12 +378,20 @@
} }
} }
/* Section body slide-in from right */ /* 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) { ::view-transition-old(section-body) {
animation-name: section-body-out; animation: none;
animation-duration: var(--duration-normal); opacity: 0;
animation-timing-function: var(--ease-default);
animation-fill-mode: both;
} }
::view-transition-new(section-body) { ::view-transition-new(section-body) {
@@ -387,27 +399,89 @@
animation-duration: var(--duration-spring); animation-duration: var(--duration-spring);
animation-timing-function: var(--ease-spring); animation-timing-function: var(--ease-spring);
animation-fill-mode: both; 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); animation-delay: var(--delay-normal);
} }
@keyframes section-body-out {
from {
opacity: 1;
transform: translateX(0) scale(1);
}
to {
opacity: 0;
transform: translateX(-12px) scale(0.98);
}
}
@keyframes section-body-in { @keyframes section-body-in {
from { from {
opacity: 0; opacity: 0;
transform: translateX(48px) scale(0.98); transform: translateX(var(--slide-section-body-in)) scale(0.98);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateX(0) scale(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', () => { it('applies outline variant', () => {
render(<Button variant="outline">Go</Button>); render(<Button variant="outline">Go</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-transparent'); expect(screen.getByRole('button')).toHaveClass('bg-cream');
}); });
it('applies ghost variant', () => { it('applies ghost variant', () => {
render(<Button variant="ghost">Go</Button>); render(<Button variant="ghost">Go</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-cream'); expect(screen.getByRole('button')).toHaveClass('bg-transparent');
}); });
}); });
describe('sizes', () => { describe('sizes', () => {
+2 -2
View File
@@ -46,9 +46,9 @@ const VARIANTS = {
secondary: 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', '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: 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', '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>; } as const satisfies Record<ButtonVariant, string>;
const SIZES = { const SIZES = {
+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 borderchildrenoutline, 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 parse from 'html-react-parser';
import { cn } from '$shared/lib'; import { cn } from '$shared/lib';
type Props = { export interface Props {
/** /**
* SVG markup string to inline as React elements * SVG markup string to inline as React elements
*/ */
@@ -10,7 +10,7 @@ type Props = {
* Additional CSS classes on the wrapper span * Additional CSS classes on the wrapper span
*/ */
className?: string; className?: string;
}; }
/** /**
* Parses an SVG markup string into React elements. * Parses an SVG markup string into React elements.
+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 parse from 'html-react-parser';
import { cn } from '$shared/lib'; import { cn } from '$shared/lib';
type Props = { export interface Props {
/** /**
* HTML string from PocketBase rich-text editor * HTML string from PocketBase rich-text editor
*/ */
@@ -10,7 +10,7 @@ type Props = {
* Additional CSS classes merged onto the wrapper div * Additional CSS classes merged onto the wrapper div
*/ */
className?: string; className?: string;
}; }
/** /**
* Renders a PocketBase rich-text HTML string as React elements. * Renders a PocketBase rich-text HTML string as React elements.
@@ -6,7 +6,7 @@ import { Fragment, type ReactNode, ViewTransition as VT } from 'react';
*/ */
const Transition = (VT ?? Fragment) as typeof VT; const Transition = (VT ?? Fragment) as typeof VT;
type Props = { export interface Props {
/** /**
* Maps to the view-transition-name CSS property * Maps to the view-transition-name CSS property
*/ */
@@ -15,7 +15,7 @@ type Props = {
* Content to animate * Content to animate
*/ */
children: ReactNode; children: ReactNode;
}; }
/** /**
* Wraps children in React's ViewTransition when available, * 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 { Button } from './Button';
export type { CardBackground } from './Card'; export type { CardBackground } from './Card';
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './Card'; export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './Card';
export { ImageLightbox } from './ImageLightbox';
export { InlineSvg } from './InlineSvg'; export { InlineSvg } from './InlineSvg';
export { Input, Textarea } from './Input'; export { Input, Textarea } from './Input';
export type { LinkVariant } from './Link'; export type { LinkVariant } from './Link';
export { Link } from './Link'; export { Link } from './Link';
export { Modal, type ModalHandle } from './Modal';
export { RichText } from './RichText'; export { RichText } from './RichText';
export type { ContainerSize, SectionBackground } from './Section'; export type { ContainerSize, SectionBackground } from './Section';
export { Container, Section } from './Section'; export { Container, Section } from './Section';
+1 -1
View File
@@ -19,7 +19,7 @@ export async function Footer() {
const socials = contacts?.expand?.socials ?? []; const socials = contacts?.expand?.socials ?? [];
return ( return (
<footer className="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"> <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="w-full flex flex-row justify-between gap-4">
<div className="flex flex-wrap items-center gap-6 sm:gap-4"> <div className="flex flex-wrap items-center gap-6 sm:gap-4">
{email && ( {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'], 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 ( return (
<div className="space-y-6 max-w-section"> <div className="space-y-6 max-w-section">
{items.map((project) => ( {items.map((project, index) => (
<ProjectCard <ProjectCard
key={project.id} key={project.id}
title={project.title} title={project.title}
@@ -24,6 +29,7 @@ export default async function ProjectsSection() {
tags={project.stack} tags={project.stack}
url={project.url} url={project.url}
imageUrl={project.image ? buildFileUrl(project.collectionId, project.id, project.image) : undefined} imageUrl={project.image ? buildFileUrl(project.collectionId, project.id, project.image) : undefined}
priority={index === lcpIndex}
/> />
))} ))}
</div> </div>
@@ -3,12 +3,12 @@
import type { ErrorInfo, ReactNode } from 'react'; import type { ErrorInfo, ReactNode } from 'react';
import { Component } from 'react'; import { Component } from 'react';
type Props = { export interface Props {
/** /**
* Section content to render * Section content to render
*/ */
children: ReactNode; children: ReactNode;
}; }
type State = { type State = {
/** /**
@@ -3,7 +3,7 @@ import { Children } from 'react';
import type { SectionRecord } from '$entities/Section'; import type { SectionRecord } from '$entities/Section';
import { SectionAccordion } from '$entities/Section'; import { SectionAccordion } from '$entities/Section';
type Props = { export interface Props {
/** /**
* Ordered section metadata drives navigation labels and IDs * 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 * Pre-rendered RSC content slots, one per section, matched by index
*/ */
children: ReactNode; children: ReactNode;
}; }
/** /**
* Renders all portfolio sections as an accordion list. * Renders all portfolio sections as an accordion list.
-1
View File
@@ -1,2 +1 @@
export * from './Footer'; export * from './Footer';
export * from './Navigation';
+1 -1
View File
@@ -1,5 +1,5 @@
import path from 'node:path';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import path from 'path';
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({