Compare commits

...

121 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
ilia d1b4452867 Merge pull request 'chore: add .dockerignore' (#2) from fixes/responsive into main
Build and push / build (push) Failing after 4m25s
Reviewed-on: #2
2026-05-19 15:11:13 +00:00
Ilia Mashkov d62c0ad501 chore: add Gitea Actions deploy workflow 2026-05-19 18:09:59 +03:00
Ilia Mashkov cf2f1bc7f3 chore: unignore .gitea directory 2026-05-19 18:09:26 +03:00
Ilia Mashkov 7f6e6369ff fix: reduce padding and spacing for mobile
Main: px-4 py-6 on mobile (was px-8 py-12). Section accordion:
mb/py on inactive links tightened to 1/1 on mobile, space-y-0
between sections. Active title text-xl on mobile to prevent
wrapping at ~400px, matches inactive title size.
2026-05-19 18:06:51 +03:00
Ilia Mashkov d0f09f0dbd feat: social links with inline SVG icons from CMS
SocialRecord gains icon field (SVG markup string). InlineSvg component
parses SVG string via html-react-parser. Footer renders icon on mobile
(sm:hidden label), label on sm+ (hidden icon). Email field refactored
from string to SocialRecord relation.
2026-05-19 18:06:20 +03:00
Ilia Mashkov 41af0b90a0 feat: add Link secondary variant with border-bottom at sm+
Secondary variant drops text-decoration, uses opacity-60/hover-100
and brutal-border-bottom at sm+ for use in icon+label links where
the underline should only appear alongside the label.
2026-05-19 18:06:10 +03:00
Ilia Mashkov 954b17d824 fix: reduce Button sm padding on mobile 2026-05-19 18:05:57 +03:00
Ilia Mashkov 906ec3b805 feat: fixed footer with responsive height tokens
Footer is fixed bottom-0 with h-footer (5rem mobile) / md:h-footer-wide
(4rem desktop). Body gets matching pb-footer/md:pb-footer-wide to
reserve space. Tokens registered in @theme inline as --spacing-footer*.
2026-05-19 18:05:46 +03:00
Ilia Mashkov 4d6d78a528 chore: add .dockerignore 2026-05-19 18:05:37 +03:00
Ilia Mashkov f40e9f54a3 chore: add dockerfile 2026-05-19 09:55:12 +03:00
Ilia Mashkov 7829f81d1a chore: add dockerfile 2026-05-19 09:46:35 +03:00
ilia cd9da6dd26 Merge pull request 'fix: storybook font rendering and shared fonts module' (#1) from feat/portfolio-setup into main
Reviewed-on: #1
2026-05-18 18:45:21 +00:00
Ilia Mashkov d5ba77b4ce feat: add poweredByHeader: false 2026-05-18 21:40:10 +03:00
Ilia Mashkov 5c00f8e8a0 feat: add /api/revalidate webhook for on-demand ISR
POST with x-revalidate-secret header and { tag } body calls
revalidateTag to purge a collection from the Next.js data cache.
Guarded by REVALIDATE_SECRET env var.
2026-05-18 21:34:51 +03:00
Ilia Mashkov cb3bdce24a feat: tag all PocketBase fetches for ISR cache invalidation
Each getCollection/getFirstRecord call now passes the collection name
as a cache tag so revalidateTag can target individual collections.
2026-05-18 21:34:43 +03:00
Ilia Mashkov 42ca683c65 feat: add tags/revalidate options to PocketBase fetch client
PB_URL falls back through PB_URL → NEXT_PUBLIC_PB_URL → localhost so
internal Docker hostname is used server-side without leaking into the
client bundle. cache: force-cache replaced with next: { tags, revalidate }
for ISR tag-based invalidation.
2026-05-18 21:34:34 +03:00
Ilia Mashkov fea6682024 feat: switch to standalone output with PocketBase remotePatterns
Drops static export (STATIC_EXPORT env var) in favour of standalone
for ISR. Images remotePatterns reads PB_HOSTNAME/PB_PORT env vars so
Docker internal hostname works without hardcoding.
2026-05-18 21:34:25 +03:00
Ilia Mashkov 540df57f8d feat: add 404 page with centered layout
not-found.tsx renders oversized Fraunces heading with a back link.
Body gets flex flex-col min-h-screen so main can flex-1 to fill
available height without pushing the footer off screen.
2026-05-18 20:46:22 +03:00
Ilia Mashkov b88263a65a fix: DetailedProjectCard — render description as RichText 2026-05-18 20:46:13 +03:00
Ilia Mashkov 06e39b58c6 refactor: ProjectsSection — use shared buildFileUrl, pass url prop, switch to stacked layout 2026-05-18 20:46:02 +03:00
Ilia Mashkov ac9ee0eb4e feat: ProjectCard — add url prop, RichText description, open link in new tab 2026-05-18 20:45:54 +03:00
Ilia Mashkov 2ae5ae3210 feat: wire Footer to PocketBase site_settings
Fetches CV file, email and social links via expand=contacts,contacts.socials.
CV rendered as polymorphic Button with download attr; socials and email
rendered as Link components.
2026-05-18 20:45:44 +03:00
Ilia Mashkov f159c6e861 feat: add SocialRecord, ContactsRecord, SiteSettingsRecord API types
Models PocketBase relations: SiteSettings → contacts → ContactsRecord
→ socials[] → SocialRecord. expand fields typed as optional resolved
records for use with PocketBase expand query param.
2026-05-18 20:45:32 +03:00
Ilia Mashkov b33b9f328c feat: add Link shared component
Renders Next.js Link for internal routes, plain anchor with
target="_blank" rel="noopener noreferrer" when external prop is set.
2026-05-18 20:45:17 +03:00
Ilia Mashkov c9631f9905 feat: add buildFileUrl utility with tests
Moved from ProjectsSection inline function to shared/lib/utils.
Accepts optional baseUrl for testability without env mocking.
2026-05-18 20:45:06 +03:00
Ilia Mashkov ba7395cb32 feat: make Button polymorphic — renders <a> when href is provided
Discriminated union types (AsButton | AsAnchor), isAnchorProps type guard
eliminates all 'as' casts. as const satisfies for VARIANTS/SIZES lookup
tables. brutal-border replaces border-[3px] in ghost variant.
2026-05-18 20:44:50 +03:00
Ilia Mashkov 7e542597d0 feat: Footer widget with email link and CV download, added to root layout 2026-05-18 14:15:25 +03:00
Ilia Mashkov 0552a2a8e5 refactor: register text-section-title in @theme inline, use as plain utility class 2026-05-18 14:06:01 +03:00
Ilia Mashkov d955aeb628 refactor: replace inline style with Tailwind class and font-wonk utility 2026-05-18 14:04:56 +03:00
Ilia Mashkov b40ff4f588 fix: fluid section title with clamp() to prevent wrapping below 900px 2026-05-18 14:02:03 +03:00
Ilia Mashkov 531de6899e refactor: ProjectCard sm button, left-border year matching ExperienceCard style 2026-05-18 13:20:47 +03:00
Ilia Mashkov 10034ec561 refactor: ProjectCard sidebar layout — year, tags, button in sidebar 2026-05-18 13:14:40 +03:00
Ilia Mashkov 458ee0e449 refactor: CardSidebar layout breakpoint md → lg for wider description area 2026-05-18 13:11:53 +03:00
Ilia Mashkov 979e2071d1 refactor: widen section and sidebar, plain period text, Badge xs size for stack 2026-05-18 13:07:01 +03:00
Ilia Mashkov 37098be3c8 feat: Badge size prop (sm/md) and use Badge in ExperienceCard 2026-05-18 13:02:07 +03:00
Ilia Mashkov 48a08ec3fb feat: formatMonthYearRange — period now includes abbreviated month 2026-05-18 13:01:58 +03:00
Ilia Mashkov 1550989fd9 feat: CardSidebar layout component and ExperienceCard sidebar redesign
Sidebar: period badge, company, stack tags.
Main: role title and rich-text description.
2026-05-18 12:51:33 +03:00
Ilia Mashkov 782c619a91 feat: ExperienceCard stack field and Card subcomponent layout 2026-05-18 12:39:41 +03:00
Ilia Mashkov 543020f85c feat: apply Fraunces font to ProjectCard title 2026-05-18 12:39:33 +03:00
Ilia Mashkov e00c1460e1 refactor: responsive spacing on CardHeader and CardFooter 2026-05-18 12:39:20 +03:00
Ilia Mashkov f874a943ff fix: a11y — accessible label on SectionAccordion, opacity-60 on category headings 2026-05-18 12:39:07 +03:00
Ilia Mashkov ff62cba5b1 feat: add line-height-relaxed token and text selection/focus-visible styles 2026-05-18 12:38:28 +03:00
Ilia Mashkov f4986d6657 chore: split React import to satisfy linter in ViewTransitionWrapper 2026-05-18 12:38:17 +03:00
Ilia Mashkov e3959c0e45 fix: add cursor-pointer to Button 2026-05-18 12:38:10 +03:00
Ilia Mashkov 76f5b269f8 refactor: use shadow theme tokens, remove ProjectCard translate-hover
Replace inline var(--blue) arbitrary shadow values with typed theme
tokens (shadow-brutal-xl, shadow-brutal-2xl). Remove translate on
ProjectCard hover — shadow-only interaction is less distracting in
a dense grid layout.
2026-05-16 19:04:37 +03:00
Ilia Mashkov b8b5e65497 feat: constrain section content width with max-w-section
Adds max-w-section (56rem via --container-section token) to the
experience, projects, and skills section wrappers for consistent
readable line length across all content areas.
2026-05-16 19:04:27 +03:00
Ilia Mashkov e63de14515 feat: apply RichText to content sections and experience cards
ExperienceCard description switches from a plain <p> to RichText so
rich-text HTML from PocketBase renders correctly. BioSection and
IntroSection drop the prose class overrides — RichText handles
typography consistently.
2026-05-16 19:04:18 +03:00
Ilia Mashkov dfc3ed4715 feat: editorial typography via RichText component
Always wraps content in .rich-text: max-width 65ch, onum figures,
hanging punctuation, pretty text-wrap, auto hyphens, 1.65 line-height,
and 1.2em paragraph spacing. className prop merges additonal classes.
2026-05-16 19:04:08 +03:00
Ilia Mashkov a77cd43749 feat: Button elevation hover/active effect with snap shadow
Variants now use brutal shadow tokens. On hover the button translates
up-left (−0.5px), on active down-right (+0.5px). Only transform animates
(130ms ease-out); shadow snaps instantly so the eye reads button movement
not shadow resize. Primary keeps rgba alpha shadow; secondary/outline use
solid brutal tokens.
2026-05-16 19:04:00 +03:00
Ilia Mashkov 8db4f81f70 refactor: simplify section body animation to hard-cut on navigation 2026-05-16 19:03:50 +03:00
Ilia Mashkov f1049624f7 refactor: design tokens — shadow scale, animation timing, section width
- Expand brutal shadow scale: xs (1px) through 2xl (12px)
- Add --ease-micro cubic-bezier for fast micro-interactions
- Tune --duration-normal 200ms→150ms, --duration-spring 380ms→220ms
- Add --section-content-width and register as --container-section in @theme inline
- Register all brutal shadow tokens in @theme inline for Tailwind utility generation
- Add .btn-transition utility (transform-only, shadow snaps instantly)
- Add .rich-text editorial typography class with magazine-quality settings
- Remove section-body blur-out/slide-in view transition animations
2026-05-16 19:03:43 +03:00
Ilia Mashkov 92e4a01641 refactor: group experience/ui components into subdirectories 2026-05-13 09:40:09 +03:00
Ilia Mashkov 9cf8caaead refactor: group project/ui components into subdirectories 2026-05-13 09:40:00 +03:00
Ilia Mashkov e518fc46a9 feat: section body animation with blur-out, delayed enter, and animation tokens
Add animation tokens to :root (--ease-spring, --ease-decelerate,
--ease-default, --duration-fast/normal/slow/spring). Apply spring easing
to section title enter. Add separate section-body transition: fast
blur-out exit (100ms), clean slide-in enter (350ms) delayed by 200ms so
content appears after the title animation completes.
2026-05-13 09:39:47 +03:00
Ilia Mashkov 481dda3c95 fix: resolve inactive section title hover opacity conflict
hover:opacity-60 on Link and opacity-30/group-hover:opacity-50 on h2
were multiplying (0.6 × 0.5 = 0.30 = base), making hover invisible.
Removed opacity from Link, consolidated to h2 only: opacity-30 base,
group-hover:opacity-60 on hover.
2026-05-13 09:39:08 +03:00
Ilia Mashkov d28343e22c feat: section open/close animations via ViewTransition and @starting-style
Enable experimental.viewTransition in Next.js config. Wrap active section
in ViewTransitionWrapper so the browser cross-fades between sections on
navigation. Replace animate-fadeIn keyframe with @starting-style + CSS
transition for the initial render enter animation.
2026-05-12 16:10:50 +03:00
Ilia Mashkov 7cba3053f4 feat: ViewTransitionWrapper shared component with stable react-dom fallback
Wraps children in React's ViewTransition (canary API) when available,
falling back to Fragment in environments where ViewTransition is undefined
(test env, stable react-dom). Add react/canary to tsconfig types to
expose the ViewTransition component type.
2026-05-12 16:10:37 +03:00
Ilia Mashkov 0090718869 fix: add outline to primary and secondary button variants 2026-05-12 13:58:29 +03:00
Ilia Mashkov 301e7a2555 feat: RichText component for safe PocketBase HTML rendering
Add html-react-parser-backed RichText component that converts HTML
strings from PocketBase rich-text fields into React elements without
dangerouslySetInnerHTML. Replace raw <p> render in IntroSection and
BioSection, and drop the invalid slug filters those collections lacked.
2026-05-12 13:58:17 +03:00
Ilia Mashkov 0a99a37bca fix: remove underline from collapsed section title links
Global a { border-bottom } was leaking onto the inactive section
nav links. Override with border-b-0 hover:border-b-0.
2026-05-12 13:57:39 +03:00
Ilia Mashkov e8bf8b502e fix: align PocketBase type definitions with actual schema
Remove slug field from PageContentRecord (intro/bio collections have none).
Remove number field from SectionRecord (not stored in PocketBase); derive
zero-padded display number from the order field at render time.
2026-05-12 13:57:25 +03:00
Ilia Mashkov 30f8e4be95 design: two-color palette — rename all tokens to --cream / --blue
Replace ochre-clay, carbon-black, burnt-oxide, slate-indigo with clean
two-color system: --cream (#f4f0e8) and --blue (#041cf3). Update every
component, utility class, and test assertion.
2026-05-11 12:59:32 +03:00
Ilia Mashkov fed9c97ddb feat: URL-driven catchall routing, drop sidebar nav, split export build
- app/[[...slug]]/page.tsx replaces app/page.tsx; activeSlug from URL params
- SidebarNav and MobileNav removed from main layout (sections accordion is the nav)
- next.config.ts: output:export controlled by STATIC_EXPORT env var instead of NODE_ENV
- package.json: yarn build is standard Next.js build; yarn export is STATIC_EXPORT=true
- Mock API route: force-static + generateStaticParams for output:export compatibility
2026-05-11 11:12:21 +03:00
Ilia Mashkov af165ec356 feat: MobileNav section items use Link, close menu on pathname change 2026-05-11 11:11:53 +03:00
Ilia Mashkov 1dfa9a62a2 design: update color palette from ochre-clay to white/blue scheme 2026-05-11 11:11:29 +03:00
Ilia Mashkov b4bda4b8f7 chore: biome format vitest config, add Props JSDoc to SidebarNav 2026-05-11 11:11:24 +03:00
Ilia Mashkov f9cdb06632 refactor: SidebarNav uses usePathname and Link instead of IntersectionObserver 2026-05-07 12:54:53 +03:00
Ilia Mashkov 9fa2156ee8 feat: SectionAccordion inactive state uses Link href instead of button onClick 2026-05-07 12:52:54 +03:00
Ilia Mashkov ced77f6f07 fix: make output export build-only so dev route handlers work 2026-05-07 12:28:42 +03:00
Ilia Mashkov f163b750b2 docs: add URL-driven section routing implementation plan 2026-05-07 11:48:55 +03:00
Ilia Mashkov 41d1a37352 docs: add URL-driven section routing design 2026-05-07 11:45:54 +03:00
Ilia Mashkov 1a413e3d04 feat: implement portfolio home page with split layout 2026-05-05 09:42:04 +03:00
Ilia Mashkov 24bf946cb0 refactor: SectionFactory static registry, remove dynamic imports 2026-05-05 09:41:56 +03:00
Ilia Mashkov 4219a7b4e7 fix: correct RSC error patterns and extract skills grouping to utility 2026-05-05 09:41:49 +03:00
Ilia Mashkov 4b18fc454e chore: add mock API route handlers and dev env config 2026-05-05 09:41:39 +03:00
130 changed files with 3873 additions and 1186 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
.next
.git
.gitea
.env*.local
README.md
Dockerfile
.dockerignore
.yarn
.pnp.*
+33
View File
@@ -0,0 +1,33 @@
name: Build and push
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Login to Gitea registry
uses: docker/login-action@v3
with:
registry: docker.allmy.work
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
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 }}
cache-from: type=registry,ref=docker.allmy.work/${{ gitea.repository }}:buildcache
cache-to: type=registry,ref=docker.allmy.work/${{ gitea.repository }}:buildcache,mode=max
+2
View File
@@ -54,6 +54,8 @@ next-env.d.ts
!/.vscode !/.vscode
!/.gitattributes !/.gitattributes
!/.gitignore !/.gitignore
!/.dockerignore
!/.gitea
!/biome.json !/biome.json
*storybook.log *storybook.log
+30
View File
@@ -0,0 +1,30 @@
FROM node:22-alpine AS deps
WORKDIR /app
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 yarn build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
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>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { notFound } from 'next/navigation';
import type { SectionRecord } from '$entities/Section';
import { getCollection } from '$shared/api';
import { SectionFactory } from '$widgets/SectionFactory';
import { SectionsAccordion } from '$widgets/SectionsAccordion';
/**
* Optional catchall: `/` → first section, `/:slug` → that section.
*/
export async function generateStaticParams() {
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 [];
}
}
export interface Props {
params: Promise<{ slug?: string[] }>;
}
/**
* Portfolio page — one route per section, sections list always visible.
*/
export default async function SectionPage({ params }: Props) {
const { slug } = await params;
const { items: sections } = await getCollection<SectionRecord>('sections', {
sort: 'order',
tags: ['sections'],
});
if (sections.length === 0) {
notFound();
}
const activeSlug = slug?.[0] ?? sections[0].slug;
if (!sections.some((s) => s.slug === activeSlug)) {
notFound();
}
return (
<main className="px-4 py-6 sm:px-8 sm:py-12 lg:py-16 lg:px-16">
<SectionsAccordion sections={sections} activeSlug={activeSlug}>
{sections.map((s) => (
<SectionFactory key={s.slug} slug={s.slug} />
))}
</SectionsAccordion>
</main>
);
}
@@ -0,0 +1,335 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-static';
const base = { created: '', updated: '' };
const FIXTURES: Record<string, unknown[]> = {
site_settings: [
{
id: 'ss1',
collectionId: 'site_settings',
collectionName: 'site_settings',
...base,
cv: '',
contacts: 'c1',
expand: {
contacts: {
id: 'c1',
collectionId: 'contacts',
collectionName: 'contacts',
...base,
email: 'hello@allmy.work',
socials: ['s1', 's2'],
expand: {
socials: [
{
id: 's1',
collectionId: 'contact',
collectionName: 'contact',
...base,
label: 'GitHub',
url: 'https://github.com',
},
{
id: 's2',
collectionId: 'contact',
collectionName: 'contact',
...base,
label: 'LinkedIn',
url: 'https://linkedin.com',
},
],
},
},
},
},
],
contacts: [
{
id: 'c1',
collectionId: 'contacts',
collectionName: 'contacts',
...base,
email: 'hello@allmy.work',
socials: ['s1', 's2'],
},
],
contact: [
{
id: 's1',
collectionId: 'contact',
collectionName: 'contact',
...base,
label: 'GitHub',
url: 'https://github.com',
},
{
id: 's2',
collectionId: 'contact',
collectionName: 'contact',
...base,
label: 'LinkedIn',
url: 'https://linkedin.com',
},
],
sections: [
{
id: '1',
collectionId: 'sections',
collectionName: 'sections',
...base,
slug: 'intro',
title: 'Introduction',
number: '01',
order: 1,
},
{
id: '2',
collectionId: 'sections',
collectionName: 'sections',
...base,
slug: 'bio',
title: 'Biography',
number: '02',
order: 2,
},
{
id: '3',
collectionId: 'sections',
collectionName: 'sections',
...base,
slug: 'skills',
title: 'Skills',
number: '03',
order: 3,
},
{
id: '4',
collectionId: 'sections',
collectionName: 'sections',
...base,
slug: 'experience',
title: 'Experience',
number: '04',
order: 4,
},
{
id: '5',
collectionId: 'sections',
collectionName: 'sections',
...base,
slug: 'projects',
title: 'Projects',
number: '05',
order: 5,
},
],
intro: [
{
id: '1',
collectionId: 'intro',
collectionName: 'intro',
...base,
slug: 'intro',
content:
"I'm a software engineer and designer building thoughtful digital products. I combine technical depth with a strong eye for design to create experiences that are both functional and beautiful.",
},
],
bio: [
{
id: '1',
collectionId: 'bio',
collectionName: 'bio',
...base,
slug: 'bio',
content:
"Based in Berlin. I've spent the last 8 years working at the intersection of product, design, and engineering. I believe the best products come from teams where these disciplines overlap and inform each other. When I'm not building, I'm reading, cooking, or somewhere with bad WiFi.",
},
],
skills: [
{
id: 's1',
collectionId: 'skills',
collectionName: 'skills',
...base,
name: 'TypeScript',
category: 'Frontend',
order: 1,
},
{
id: 's2',
collectionId: 'skills',
collectionName: 'skills',
...base,
name: 'React',
category: 'Frontend',
order: 2,
},
{
id: 's3',
collectionId: 'skills',
collectionName: 'skills',
...base,
name: 'Next.js',
category: 'Frontend',
order: 3,
},
{
id: 's4',
collectionId: 'skills',
collectionName: 'skills',
...base,
name: 'Tailwind CSS',
category: 'Frontend',
order: 4,
},
{
id: 's5',
collectionId: 'skills',
collectionName: 'skills',
...base,
name: 'Node.js',
category: 'Backend',
order: 1,
},
{ id: 's6', collectionId: 'skills', collectionName: 'skills', ...base, name: 'Go', category: 'Backend', order: 2 },
{
id: 's7',
collectionId: 'skills',
collectionName: 'skills',
...base,
name: 'PostgreSQL',
category: 'Backend',
order: 3,
},
{
id: 's8',
collectionId: 'skills',
collectionName: 'skills',
...base,
name: 'Figma',
category: 'Design',
order: 1,
},
{
id: 's9',
collectionId: 'skills',
collectionName: 'skills',
...base,
name: 'Docker',
category: 'Tools',
order: 1,
},
{ id: 's10', collectionId: 'skills', collectionName: 'skills', ...base, name: 'Git', category: 'Tools', order: 2 },
],
experience: [
{
id: 'e1',
collectionId: 'experience',
collectionName: 'experience',
...base,
company: 'Figma',
role: 'Senior Software Engineer',
start_date: '2022-03-01T00:00:00Z',
end_date: null,
description:
'Building the multiplayer infrastructure for real-time collaborative design tools. Led the migration from polling to WebSocket-based sync, reducing latency by 60%.',
order: 1,
},
{
id: 'e2',
collectionId: 'experience',
collectionName: 'experience',
...base,
company: 'N26',
role: 'Frontend Engineer',
start_date: '2019-06-01T00:00:00Z',
end_date: '2022-02-28T00:00:00Z',
description:
'Built and maintained the mobile banking web app serving 8 million customers. Owned the transaction history feature and drove accessibility improvements to WCAG 2.1 AA.',
order: 2,
},
{
id: 'e3',
collectionId: 'experience',
collectionName: 'experience',
...base,
company: 'Freelance',
role: 'Full-Stack Developer',
start_date: '2017-01-01T00:00:00Z',
end_date: '2019-05-31T00:00:00Z',
description: 'Worked with early-stage startups across fintech and healthtech to design and ship product MVPs.',
order: 3,
},
],
projects: [
{
id: 'p1',
collectionId: 'projects',
collectionName: 'projects',
...base,
title: 'Monograph',
year: '2024',
role: 'Lead Engineer & Designer',
description: 'A personal publishing platform built for long-form writing and visual essays.',
details: ['Custom rich-text editor', 'SSG with 100/100 Lighthouse', 'Sub-second TTFB globally'],
stack: ['Next.js', 'TypeScript', 'Tailwind CSS', 'PocketBase'],
image: '',
order: 1,
},
{
id: 'p2',
collectionId: 'projects',
collectionName: 'projects',
...base,
title: 'Verdant',
year: '2023',
role: 'Full-Stack Developer',
description: 'An inventory and sales management tool for independent plant nurseries.',
details: ['QR-code scanning', 'Offline-first PWA', 'Multi-location sync'],
stack: ['React', 'Go', 'PostgreSQL', 'Docker'],
image: '',
order: 2,
},
{
id: 'p3',
collectionId: 'projects',
collectionName: 'projects',
...base,
title: 'Folio',
year: '2022',
role: 'Designer & Developer',
description: 'A minimal portfolio generator for creative professionals.',
details: ['No-code page builder', 'Custom domain support'],
stack: ['Next.js', 'Prisma', 'Figma'],
image: '',
order: 3,
},
],
};
export function generateStaticParams() {
return Object.keys(FIXTURES).map((collection) => ({ collection }));
}
/**
* Mock API route handler for PocketBase collection records.
* Returns fixture data shaped as a PocketBase list response.
*/
export async function GET(_req: Request, { params }: { params: Promise<{ collection: string }> }) {
const { collection } = await params;
const items = FIXTURES[collection];
if (!items) {
return NextResponse.json({ message: 'Not found' }, { status: 404 });
}
return NextResponse.json({
page: 1,
perPage: items.length,
totalItems: items.length,
totalPages: 1,
items,
});
}
+45
View File
@@ -0,0 +1,45 @@
import { revalidateTag } from 'next/cache';
import { type NextRequest, NextResponse } from 'next/server';
/**
* POST /api/revalidate
*
* Webhook endpoint for on-demand ISR. PocketBase (or any external
* caller) sends this request after mutating CMS content so the
* relevant tag is purged from the Next.js data cache.
*
* Expected body: `{ "tag": "<collection-name>" }`
* Required header: `x-revalidate-secret: <REVALIDATE_SECRET>`
*/
export async function POST(request: NextRequest): Promise<NextResponse> {
const secret = request.headers.get('x-revalidate-secret');
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
if (
typeof body !== 'object' ||
body === null ||
!('tag' in body) ||
typeof (body as Record<string, unknown>).tag !== 'string'
) {
return NextResponse.json({ error: 'Missing or invalid "tag" field' }, { status: 400 });
}
const tag = (body as { tag: string }).tag;
/* Second arg is required by the Next.js 15 type signature;
* "max" means the purge propagates indefinitely — correct for
* an on-demand webhook that has no TTL of its own. */
revalidateTag(tag, 'max');
return NextResponse.json({ revalidated: true, tag }, { status: 200 });
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

+9 -4
View File
@@ -1,10 +1,12 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { fraunces, publicSans } from '$shared/lib'; import { fraunces, publicSans } from '$shared/lib';
import { Footer } from '$widgets/Footer';
import './globals.css'; import './globals.css';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Portfolio', title: 'Ilia Mashkov — Portfolio',
description: 'Portfolio', description: 'Portfolio of Ilia Mashkov, a frontend software engineer.',
icons: { icon: '/favicon.svg' },
}; };
/** /**
@@ -12,8 +14,11 @@ 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}`}>{children}</body> <body className={`${fraunces.variable} ${publicSans.variable} pb-footer md:pb-footer-wide`}>
{children}
<Footer />
</body>
</html> </html>
); );
} }
+13
View File
@@ -0,0 +1,13 @@
import { Link } from '$shared/ui';
/**
* Custom 404 page — shown for any unmatched route.
*/
export default function NotFound() {
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">404</h1>
<Link href="/">Back to main</Link>
</main>
);
}
-52
View File
@@ -1,52 +0,0 @@
import Image from 'next/image';
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image className="dark:invert" src="/next.svg" alt="Next.js logo" width={100} height={20} priority />
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{' '}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{' '}
or the{' '}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{' '}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image className="dark:invert" src="/vercel.svg" alt="Vercel logomark" width={16} height={16} />
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}
+1 -1
View File
@@ -7,7 +7,7 @@
}, },
"files": { "files": {
"ignoreUnknown": false, "ignoreUnknown": false,
"includes": ["src/**/*", "app/**/*"] "includes": ["src/**/*", "app/**/*", "*.ts", "*.tsx", "*.js", "*.jsx", "*.json", "*.css"]
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,
+23 -5
View File
@@ -1,8 +1,26 @@
import type { NextConfig } from 'next' import type { NextConfig } from 'next';
/* 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 = { const nextConfig: NextConfig = {
output: 'export', output: 'standalone',
images: { unoptimized: true }, poweredByHeader: false,
} images: {
remotePatterns: [
{
protocol: 'https',
hostname: pbPublicHost,
pathname: '/api/files/**',
},
],
},
experimental: {
viewTransition: true,
},
};
export default nextConfig export default nextConfig;
+3
View File
@@ -1,10 +1,12 @@
{ {
"name": "portfolio", "name": "portfolio",
"version": "0.1.0", "version": "0.1.0",
"packageManager": "yarn@4.11.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"export": "STATIC_EXPORT=true next build",
"start": "next start", "start": "next start",
"lint": "biome lint --write .", "lint": "biome lint --write .",
"format": "biome format --write .", "format": "biome format --write .",
@@ -19,6 +21,7 @@
}, },
"dependencies": { "dependencies": {
"clsx": "^2.1.1", "clsx": "^2.1.1",
"html-react-parser": "^6.1.0",
"next": "16.2.4", "next": "16.2.4",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
+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

-4
View File
@@ -12,10 +12,6 @@ export type SectionRecord = BaseRecord & {
* Display name of the section * Display name of the section
*/ */
title: string; title: string;
/**
* Visual numbering prefix (e.g., "01")
*/
number: string;
/** /**
* Sorting weight for section order * Sorting weight for section order
*/ */
@@ -23,7 +23,7 @@ export const Active: Story = {
title: 'Biography', title: 'Biography',
id: 'bio', id: 'bio',
isActive: true, isActive: true,
onClick: () => {}, href: '/bio',
children: <p>This is the expanded section content. It is visible because isActive is true.</p>, children: <p>This is the expanded section content. It is visible because isActive is true.</p>,
}, },
}; };
@@ -34,7 +34,7 @@ export const Collapsed: Story = {
title: 'Work', title: 'Work',
id: 'work', id: 'work',
isActive: false, isActive: false,
onClick: () => console.log('section clicked'), href: '/work',
children: <p>This content is hidden in collapsed state.</p>, children: <p>This content is hidden in collapsed state.</p>,
}, },
}; };
@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SectionAccordion } from './SectionAccordion'; import { SectionAccordion } from './SectionAccordion';
const defaultProps = { const defaultProps = {
@@ -7,7 +6,7 @@ const defaultProps = {
title: 'About', title: 'About',
id: 'about', id: 'about',
isActive: false, isActive: false,
onClick: vi.fn(), href: '/about',
children: <p>Content here</p>, children: <p>Content here</p>,
}; };
@@ -17,19 +16,25 @@ describe('SectionAccordion', () => {
const { container } = render(<SectionAccordion {...defaultProps} />); const { container } = render(<SectionAccordion {...defaultProps} />);
expect(container.querySelector('section#about')).toBeInTheDocument(); expect(container.querySelector('section#about')).toBeInTheDocument();
}); });
it('renders a button with number and title', () => {
it('renders a link with number and title', () => {
render(<SectionAccordion {...defaultProps} />); render(<SectionAccordion {...defaultProps} />);
expect(screen.getByRole('button', { name: /01.*About/i })).toBeInTheDocument(); expect(screen.getByRole('link', { name: /01.*About/i })).toBeInTheDocument();
}); });
it('link points to the correct href', () => {
render(<SectionAccordion {...defaultProps} />);
expect(screen.getByRole('link', { name: /01.*About/i })).toHaveAttribute('href', '/about');
});
it('does not render children', () => { it('does not render children', () => {
render(<SectionAccordion {...defaultProps} />); render(<SectionAccordion {...defaultProps} />);
expect(screen.queryByText('Content here')).not.toBeInTheDocument(); expect(screen.queryByText('Content here')).not.toBeInTheDocument();
}); });
it('calls onClick when button is clicked', async () => {
const onClick = vi.fn(); it('does not render a button', () => {
render(<SectionAccordion {...defaultProps} onClick={onClick} />); render(<SectionAccordion {...defaultProps} />);
await userEvent.click(screen.getByRole('button')); expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(onClick).toHaveBeenCalledOnce();
}); });
}); });
@@ -40,17 +45,15 @@ describe('SectionAccordion', () => {
render(<SectionAccordion {...activeProps} />); render(<SectionAccordion {...activeProps} />);
expect(screen.getByRole('heading', { level: 1, name: /01.*About/i })).toBeInTheDocument(); expect(screen.getByRole('heading', { level: 1, name: /01.*About/i })).toBeInTheDocument();
}); });
it('renders children', () => { it('renders children', () => {
render(<SectionAccordion {...activeProps} />); render(<SectionAccordion {...activeProps} />);
expect(screen.getByText('Content here')).toBeInTheDocument(); expect(screen.getByText('Content here')).toBeInTheDocument();
}); });
it('does not render a button', () => {
it('does not render a link', () => {
render(<SectionAccordion {...activeProps} />); render(<SectionAccordion {...activeProps} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument(); expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('content wrapper has animate-fadeIn class', () => {
const { container } = render(<SectionAccordion {...activeProps} />);
expect(container.querySelector('.animate-fadeIn')).toBeInTheDocument();
}); });
}); });
}); });
@@ -1,4 +1,6 @@
import Link from 'next/link';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { ViewTransitionWrapper } from '$shared/ui';
interface SectionAccordionProps { interface SectionAccordionProps {
/** /**
@@ -18,9 +20,9 @@ interface SectionAccordionProps {
*/ */
isActive: boolean; isActive: boolean;
/** /**
* Called when the collapsed header is clicked * Navigation URL for the collapsed heading link
*/ */
onClick: () => void; href: string;
/** /**
* Section content, shown when active * Section content, shown when active
*/ */
@@ -28,36 +30,34 @@ interface SectionAccordionProps {
} }
/** /**
* Accordion-style section that collapses to a heading button when inactive. * Accordion-style section that collapses to a navigation link when inactive.
*/ */
export function SectionAccordion({ number, title, id, isActive, onClick, children }: SectionAccordionProps) { export function SectionAccordion({ number, title, id, isActive, href, children }: SectionAccordionProps) {
const heading = `${number}. ${title}`;
return ( return (
<section id={id} className="scroll-mt-8"> <section id={id} className="scroll-mt-8">
{isActive ? ( {isActive ? (
<div className="mb-12"> <div className="mb-6 sm:mb-12">
<div className="mb-16"> <ViewTransitionWrapper name="section-title">
<h1 <div className="mb-6 sm:mb-12">
className="font-heading font-black text-5xl leading-[1.2] mb-0" <h1 className="font-heading font-black text-xl sm:text-section-title leading-[1.2] mb-0">{heading}</h1>
style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }} </div>
> </ViewTransitionWrapper>
{number}. {title} <ViewTransitionWrapper name="section-body">
</h1> <div>{children}</div>
</div> </ViewTransitionWrapper>
<div className="animate-fadeIn">{children}</div>
</div> </div>
) : ( ) : (
<button <Link
type="button" href={href}
onClick={onClick} aria-label={heading}
className="w-full text-left mb-3 py-3 transition-all duration-200 hover:opacity-60 group" className="block w-full text-left mb-1 py-1 sm:mb-3 sm:py-3 group border-b-0 hover:border-b-0"
> >
<h2 <span className="block font-heading font-wonk font-black text-xl sm:text-3xl opacity-30 group-hover:opacity-60 transition-opacity duration-200">
className="font-heading font-black text-2xl sm:text-3xl opacity-30 group-hover:opacity-50 transition-opacity" {heading}
style={{ fontVariationSettings: "'WONK' 1, 'SOFT' 0" }} </span>
> </Link>
{number}. {title}
</h2>
</button>
)} )}
</section> </section>
); );
+1 -1
View File
@@ -1 +1 @@
export { ExperienceCard } from './ui/ExperienceCard'; export { ExperienceCard } from './ui';
@@ -1,71 +0,0 @@
import { render, screen } from '@testing-library/react';
import { ExperienceCard } from './ExperienceCard';
const DEFAULT_PROPS = {
title: 'Senior Developer',
company: 'Acme Corp',
period: '2021 2024',
description: 'Built scalable frontend systems.',
};
describe('ExperienceCard', () => {
describe('rendering', () => {
it('renders the job title', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />);
expect(screen.getByText('Senior Developer')).toBeInTheDocument();
});
it('renders the company name', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />);
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
});
it('renders the period badge', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />);
expect(screen.getByText('2021 2024')).toBeInTheDocument();
});
it('renders the description', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />);
expect(screen.getByText('Built scalable frontend systems.')).toBeInTheDocument();
});
});
describe('structure', () => {
it('title is rendered as an h4', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />);
expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent('Senior Developer');
});
it('period badge has brutal-border, bg-carbon-black, text-ochre-clay, text-sm', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />);
const badge = screen.getByText('2021 2024');
expect(badge).toHaveClass('brutal-border', 'bg-carbon-black', 'text-ochre-clay', 'text-sm');
});
it('company paragraph has opacity-80', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />);
const company = screen.getByText('Acme Corp');
expect(company.tagName).toBe('P');
expect(company).toHaveClass('opacity-80');
});
it('description paragraph has text-base and max-w-[700px]', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />);
const desc = screen.getByText('Built scalable frontend systems.');
expect(desc).toHaveClass('text-base', 'max-w-[700px]');
});
it('card has brutal-border class (from Card component)', () => {
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} />);
expect(container.firstChild).toHaveClass('brutal-border');
});
});
describe('className passthrough', () => {
it('forwards className to the card', () => {
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} className="custom-class" />);
expect(container.firstChild).toHaveClass('custom-class');
});
});
});
@@ -1,42 +0,0 @@
import { Card } from '$shared/ui';
type Props = {
/**
* Job title
*/
title: string;
/**
* Company name
*/
company: string;
/**
* Employment period (e.g. "2021 2024")
*/
period: string;
/**
* Description of responsibilities and achievements
*/
description: string;
/**
* Additional CSS classes forwarded to the card
*/
className?: string;
};
/**
* Work experience card with title, company, period, and description.
*/
export function ExperienceCard({ title, company, period, description, className }: Props) {
return (
<Card className={className}>
<div className="flex flex-col md:flex-row md:items-start md:justify-between mb-4 gap-4">
<div className="flex-1 max-w-[700px]">
<h4>{title}</h4>
<p className="text-base opacity-80">{company}</p>
</div>
<span className="brutal-border px-4 py-2 bg-carbon-black text-ochre-clay text-sm self-start">{period}</span>
</div>
<p className="text-base max-w-[700px]">{description}</p>
</Card>
);
}
@@ -23,6 +23,7 @@ const baseArgs = {
period: '2021 2024', period: '2021 2024',
description: description:
'Led frontend development for the core product, established design system practices, and mentored junior engineers across two distributed teams.', '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 = { export const Default: Story = {
@@ -0,0 +1,108 @@
import { render, screen } from '@testing-library/react';
import { ExperienceCard } from './ExperienceCard';
const DEFAULT_PROPS = {
title: 'Senior Developer',
company: 'Acme Corp',
period: '2021 2024',
description: 'Built scalable frontend systems.',
stack: [],
};
describe('ExperienceCard', () => {
describe('rendering', () => {
it('renders the job title', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />);
expect(screen.getByText('Senior Developer')).toBeInTheDocument();
});
it('renders the company name', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />);
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
});
it('renders the period badge', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />);
expect(screen.getByText('2021 2024')).toBeInTheDocument();
});
it('renders the description', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />);
expect(screen.getByText('Built scalable frontend systems.')).toBeInTheDocument();
});
});
describe('layout', () => {
it('period badge is inside the sidebar column', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />);
const badge = screen.getByText('2021 2024');
expect(badge.closest('.brutal-border-sidebar')).toBeInTheDocument();
});
it('company name is inside the sidebar column', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />);
const company = screen.getByText('Acme Corp');
expect(company.closest('.brutal-border-sidebar')).toBeInTheDocument();
});
it('title is outside the sidebar column', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />);
const title = screen.getByText('Senior Developer');
expect(title.closest('.brutal-border-sidebar')).toBeNull();
});
it('description is outside the sidebar column', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />);
const desc = screen.getByText('Built scalable frontend systems.');
expect(desc.closest('.brutal-border-sidebar')).toBeNull();
});
});
describe('structure', () => {
it('title is rendered as an h3', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />);
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Senior Developer');
});
it('period has left border accent styling', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />);
const period = screen.getByText('2021 2024');
expect(period.tagName).toBe('P');
expect(period).toHaveClass('brutal-border-left', 'text-sm');
});
it('description renders via RichText with rich-text class', () => {
render(<ExperienceCard {...DEFAULT_PROPS} />);
const desc = screen.getByText('Built scalable frontend systems.');
expect(desc.closest('.rich-text')).toBeInTheDocument();
});
it('card has brutal-border class', () => {
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} />);
expect(container.firstChild).toHaveClass('brutal-border');
});
});
describe('stack tags', () => {
it('renders stack tags in the sidebar as xs outline badges', () => {
render(<ExperienceCard {...DEFAULT_PROPS} stack={['React', 'TypeScript']} />);
const react = screen.getByText('React');
const ts = screen.getByText('TypeScript');
expect(react.closest('.brutal-border-sidebar')).toBeInTheDocument();
expect(ts.closest('.brutal-border-sidebar')).toBeInTheDocument();
expect(react).toHaveClass('brutal-border', 'bg-transparent', 'px-2');
});
it('renders nothing extra when stack is empty', () => {
render(<ExperienceCard {...DEFAULT_PROPS} stack={[]} />);
expect(screen.queryByRole('list')).toBeNull();
});
});
describe('className passthrough', () => {
it('forwards className to the card', () => {
const { container } = render(<ExperienceCard {...DEFAULT_PROPS} className="custom-class" />);
expect(container.firstChild).toHaveClass('custom-class');
});
});
});
@@ -0,0 +1,62 @@
import { Badge, Card, CardSidebar, CardTitle, RichText } from '$shared/ui';
export interface Props {
/**
* Job title
*/
title: string;
/**
* Company name
*/
company: string;
/**
* Employment period (e.g. "Jan 2021 Dec 2024")
*/
period: string;
/**
* Description of responsibilities and achievements
*/
description: string;
/**
* Technologies used during this role
*/
stack: string[];
/**
* Additional CSS classes forwarded to the card
*/
className?: string;
}
/**
* Work experience card with sidebar layout.
* Sidebar: period, company, stack tags.
* Main: job title and rich-text description.
*/
export function ExperienceCard({ title, company, period, description, stack, className }: Props) {
return (
<Card className={className}>
<CardSidebar
sidebar={
<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-lg font-black">{company}</p>
{stack.length > 0 && (
<div className="flex flex-wrap gap-2">
{stack.map((tech) => (
<Badge key={tech} variant="outline" size="xs">
{tech}
</Badge>
))}
</div>
)}
</div>
}
>
<div className="flex flex-col gap-4">
<CardTitle className="font-heading">{title}</CardTitle>
<RichText html={description} />
</div>
</CardSidebar>
</Card>
);
}
+1
View File
@@ -0,0 +1 @@
export { ExperienceCard } from './ExperienceCard/ExperienceCard';
+1 -3
View File
@@ -1,3 +1 @@
export { DetailedProjectCard } from './ui/DetailedProjectCard'; export { DetailedProjectCard, ProjectCard, ProjectMetadata } from './ui';
export { ProjectCard } from './ui/ProjectCard';
export { ProjectMetadata } from './ui/ProjectMetadata';
@@ -1,8 +1,8 @@
import Image from 'next/image'; import Image from 'next/image';
import { Card } from '$shared/ui'; import { Card, RichText } from '$shared/ui';
import { ProjectMetadata } from './ProjectMetadata'; import { ProjectMetadata } from '../ProjectMetadata/ProjectMetadata';
type Props = { export interface Props {
/** /**
* Project name * Project name
*/ */
@@ -20,7 +20,7 @@ type Props = {
*/ */
stack: string[]; stack: string[];
/** /**
* Project description paragraph * Project description as HTML from the PocketBase rich-text editor
*/ */
description: string; description: string;
/** /**
@@ -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.
@@ -49,12 +49,12 @@ export function DetailedProjectCard({ title, year, role, stack, description, det
</div> </div>
<div className="lg:col-span-10 order-1 lg:order-2"> <div className="lg:col-span-10 order-1 lg:order-2">
<Card background="white"> <Card>
<h3>{title}</h3> <h3>{title}</h3>
<p className="text-lg mb-6">{description}</p> <RichText html={description} className="text-lg mb-6" />
{imageUrl && ( {imageUrl && (
<div className="brutal-border aspect-video bg-slate-indigo overflow-hidden relative"> <div className="brutal-border aspect-video bg-blue overflow-hidden relative">
<Image src={imageUrl} alt={title} fill className="object-cover" /> <Image src={imageUrl} alt={title} fill className="object-cover" />
</div> </div>
)} )}
@@ -1,84 +0,0 @@
import { render, screen } from '@testing-library/react';
import { ProjectCard } from './ProjectCard';
const DEFAULT_PROPS = {
title: 'My Project',
year: '2024',
description: 'A cool project description',
tags: ['React', 'Node'],
};
describe('ProjectCard', () => {
describe('rendering', () => {
it('renders the project title', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByText('My Project')).toBeInTheDocument();
});
it('renders the year badge', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByText('2024')).toBeInTheDocument();
});
it('renders the description', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByText('A cool project description')).toBeInTheDocument();
});
it('renders each tag', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByText('React')).toBeInTheDocument();
expect(screen.getByText('Node')).toBeInTheDocument();
});
it('renders the View Project button', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByRole('button', { name: /view project/i })).toBeInTheDocument();
});
});
describe('structure', () => {
it('card has hover transition classes', () => {
const { container } = render(<ProjectCard {...DEFAULT_PROPS} />);
const card = container.firstChild as HTMLElement;
expect(card).toHaveClass('group', 'transition-all', 'duration-300');
});
it('year badge has correct classes', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
const yearBadge = screen.getByText('2024');
expect(yearBadge).toHaveClass('brutal-border', 'bg-carbon-black', 'text-ochre-clay', 'text-sm');
});
it('tags have correct classes', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
const tag = screen.getByText('React');
expect(tag).toHaveClass(
'brutal-border',
'bg-white',
'text-carbon-black',
'text-sm',
'uppercase',
'tracking-wide',
);
});
});
describe('conditional image rendering', () => {
it('does not render image when imageUrl is absent', () => {
const { container } = render(<ProjectCard {...DEFAULT_PROPS} />);
expect(container.querySelector('img')).toBeNull();
});
it('renders image when imageUrl is provided', () => {
render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />);
expect(screen.getByRole('img')).toBeInTheDocument();
});
it('image wrapper has aspect-video and overflow-hidden when imageUrl is provided', () => {
const { container } = render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />);
const imgWrapper = container.querySelector('img')?.parentElement;
expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border');
});
});
});
-71
View File
@@ -1,71 +0,0 @@
import Image from 'next/image';
import { cn } from '$shared/lib';
import { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '$shared/ui';
type Props = {
/**
* Project name
*/
title: string;
/**
* Year the project was completed
*/
year: string;
/**
* Short project description
*/
description: string;
/**
* Technology or category tags
*/
tags: string[];
/**
* Optional preview image URL
*/
imageUrl?: string;
};
/**
* Compact project card for grid/list display.
*/
export function ProjectCard({ title, year, description, tags, imageUrl }: Props) {
return (
<Card
className={cn(
'group hover:translate-x-[2px] hover:translate-y-[2px]',
'hover:shadow-[10px_10px_0_var(--carbon-black)] transition-all duration-300',
)}
>
<CardHeader>
<div className="flex flex-row justify-between items-start mb-3">
<CardTitle className="flex-1">{title}</CardTitle>
<span className="brutal-border px-3 py-1 bg-carbon-black text-ochre-clay text-sm">{year}</span>
</div>
<CardDescription>{description}</CardDescription>
</CardHeader>
{imageUrl && (
<div className="brutal-border my-6 aspect-video bg-slate-indigo overflow-hidden relative">
<Image src={imageUrl} alt={title} fill className="object-cover" />
</div>
)}
<CardContent className="flex flex-wrap gap-2">
{tags.map((tag) => (
<span
key={tag}
className="brutal-border px-3 py-1 bg-white text-carbon-black text-sm uppercase tracking-wide"
>
{tag}
</span>
))}
</CardContent>
<CardFooter>
<Button variant="primary" className="w-full">
View Project
</Button>
</CardFooter>
</Card>
);
}
@@ -0,0 +1,134 @@
import { render, screen } from '@testing-library/react';
import { ProjectCard } from './ProjectCard';
const DEFAULT_PROPS = {
title: 'My Project',
year: '2024',
description: 'A cool project description',
tags: ['React', 'Node'],
url: 'https://example.com',
};
describe('ProjectCard', () => {
describe('rendering', () => {
it('renders the project title', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByText('My Project')).toBeInTheDocument();
});
it('renders the year', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByText('2024')).toBeInTheDocument();
});
it('renders the description', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByText('A cool project description')).toBeInTheDocument();
});
it('renders each tag', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByText('React')).toBeInTheDocument();
expect(screen.getByText('Node')).toBeInTheDocument();
});
it('renders the View Project button', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByRole('link', { name: /view project/i })).toBeInTheDocument();
});
it('View Project link points to the project url', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByRole('link', { name: /view project/i })).toHaveAttribute('href', 'https://example.com');
});
it('View Project link opens in a new tab', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByRole('link', { name: /view project/i })).toHaveAttribute('target', '_blank');
});
});
describe('layout', () => {
it('year is inside the sidebar column', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByText('2024').closest('.brutal-border-sidebar')).toBeInTheDocument();
});
it('tags are inside the sidebar column', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByText('React').closest('.brutal-border-sidebar')).toBeInTheDocument();
expect(screen.getByText('Node').closest('.brutal-border-sidebar')).toBeInTheDocument();
});
it('View Project button is inside the sidebar column', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
const btn = screen.getByRole('link', { name: /view project/i });
expect(btn.closest('.brutal-border-sidebar')).toBeInTheDocument();
});
it('title is outside the sidebar column', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByText('My Project').closest('.brutal-border-sidebar')).toBeNull();
});
it('description is outside the sidebar column', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByText('A cool project description').closest('.brutal-border-sidebar')).toBeNull();
});
});
describe('structure', () => {
it('card has hover transition classes', () => {
const { container } = render(<ProjectCard {...DEFAULT_PROPS} />);
expect(container.firstChild).toHaveClass('group', 'transition-shadow', 'duration-300');
});
it('title renders as h3', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('My Project');
});
it('year has period-style left border', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
const year = screen.getByText('2024');
expect(year.tagName).toBe('P');
expect(year).toHaveClass('brutal-border-left', 'text-sm');
});
it('View Project button uses sm size', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
const btn = screen.getByRole('link', { name: /view project/i });
expect(btn).toHaveClass('px-3', 'py-1.5', 'sm:px-4', 'sm:py-2', 'text-sm');
});
it('tags are xs outline badges', () => {
render(<ProjectCard {...DEFAULT_PROPS} />);
const tag = screen.getByText('React');
expect(tag).toHaveClass('brutal-border', 'bg-transparent', 'px-2');
});
});
describe('conditional image rendering', () => {
it('does not render image when imageUrl is absent', () => {
const { container } = render(<ProjectCard {...DEFAULT_PROPS} />);
expect(container.querySelector('img')).toBeNull();
});
it('renders image when imageUrl is provided', () => {
render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />);
expect(screen.getByRole('img')).toBeInTheDocument();
});
it('image wrapper has aspect-video and overflow-hidden', () => {
const { container } = render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />);
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');
});
});
});
@@ -0,0 +1,74 @@
import { cn } from '$shared/lib';
import { Badge, Button, Card, CardSidebar, CardTitle, ImageLightbox, RichText } from '$shared/ui';
export interface Props {
/**
* Project name
*/
title: string;
/**
* Year the project was completed
*/
year: string;
/**
* Short project description
*/
description: string;
/**
* Technology or category tags
*/
tags: string[];
/**
* Project's URL
*/
url: string;
/**
* 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, priority = false }: Props) {
return (
<Card className={cn('group hover:shadow-brutal-xl transition-shadow duration-300')}>
<CardSidebar
sidebar={
<div className="flex flex-col gap-4">
<p className="text-sm font-medium brutal-border-left pl-3">{year}</p>
{tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Badge key={tag} variant="outline" size="xs">
{tag}
</Badge>
))}
</div>
)}
<Button href={url} variant="primary" size="sm" className="self-start lg:w-full lg:self-auto text-center">
View Project
</Button>
</div>
}
>
<div className="flex flex-col gap-4">
<CardTitle className="font-heading">{title}</CardTitle>
{imageUrl && (
<ImageLightbox src={imageUrl} alt={title} priority={priority} className="brutal-border bg-blue" />
)}
<RichText html={description} />
</div>
</CardSidebar>
</Card>
);
}
@@ -50,19 +50,19 @@ describe('ProjectMetadata', () => {
it('year section has no brutal-border-top (first section)', () => { it('year section has no brutal-border-top (first section)', () => {
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />); const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />);
const sections = container.firstChild?.childNodes; const sections = container.firstChild?.childNodes as NodeListOf<Element>;
expect(sections[0]).not.toHaveClass('brutal-border-top'); expect(sections[0]).not.toHaveClass('brutal-border-top');
}); });
it('role section has brutal-border-top and pt-6', () => { it('role section has brutal-border-top and pt-6', () => {
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />); const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />);
const sections = container.firstChild?.childNodes; const sections = container.firstChild?.childNodes as NodeListOf<Element>;
expect(sections[1]).toHaveClass('brutal-border-top', 'pt-6'); expect(sections[1]).toHaveClass('brutal-border-top', 'pt-6');
}); });
it('stack section has brutal-border-top and pt-6', () => { it('stack section has brutal-border-top and pt-6', () => {
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />); const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />);
const sections = container.firstChild?.childNodes; const sections = container.firstChild?.childNodes as NodeListOf<Element>;
expect(sections[2]).toHaveClass('brutal-border-top', 'pt-6'); expect(sections[2]).toHaveClass('brutal-border-top', 'pt-6');
}); });
@@ -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.
+3
View File
@@ -0,0 +1,3 @@
export { DetailedProjectCard } from './DetailedProjectCard/DetailedProjectCard';
export { ProjectCard } from './ProjectCard/ProjectCard';
export { ProjectMetadata } from './ProjectMetadata/ProjectMetadata';
-69
View File
@@ -1,69 +0,0 @@
import type { ListResponse } from './types';
/*
* Native fetch wrapper for PocketBase API requests.
*/
const PB_URL =
process.env.NEXT_PUBLIC_PB_URL ||
(process.env.NODE_ENV === 'production'
? (() => {
throw new Error('NEXT_PUBLIC_PB_URL is not set');
})()
: '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;
};
/**
* 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 } = 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()}`;
/* force-cache deduplicates identical fetches during the static build phase;
* it has no runtime effect in `output: 'export'` mode. */
const res = await fetch(url, { cache: 'force-cache' });
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'; export * from './types';
+80 -5
View File
@@ -26,12 +26,9 @@ export type BaseRecord = {
/** /**
* PocketBase collection for simple text blocks (Intro, Bio). * PocketBase collection for simple text blocks (Intro, Bio).
* Each collection is named after its section no slug field.
*/ */
export type PageContentRecord = BaseRecord & { export type PageContentRecord = BaseRecord & {
/**
* Slug corresponding to the parent section
*/
slug: string;
/** /**
* HTML or Markdown content string * HTML or Markdown content string
*/ */
@@ -80,6 +77,10 @@ export type ExperienceRecord = BaseRecord & {
* Rich text description of responsibilities and achievements * Rich text description of responsibilities and achievements
*/ */
description: string; description: string;
/**
* Technologies used during this role
*/
stack: string[];
/** /**
* Sorting weight for chronological display * Sorting weight for chronological display
*/ */
@@ -103,7 +104,7 @@ export type ProjectRecord = BaseRecord & {
*/ */
role: string; role: string;
/** /**
* Short summary of the project * Project description as HTML from the PocketBase rich-text editor
*/ */
description: string; description: string;
/** /**
@@ -118,12 +119,86 @@ export type ProjectRecord = BaseRecord & {
* Primary thumbnail or hero image filename * Primary thumbnail or hero image filename
*/ */
image: string; image: string;
/**
* Project's url
*/
url: string;
/** /**
* Sorting weight for the project list * Sorting weight for the project list
*/ */
order: number; order: number;
}; };
/**
* PocketBase collection for individual social profile links.
*/
export type SocialRecord = BaseRecord & {
/**
* Display name shown as the link text
*/
label: string;
/**
* Full URL for the social profile
*/
url: string;
/**
* SVG markup string stored in PocketBase
*/
icon: string;
};
/**
* PocketBase collection for the primary contact record.
* Single-record collection only the first record is consumed.
*/
export type ContactsRecord = BaseRecord & {
/**
* Raw relation ID use expand?.email for the resolved record
*/
email: string;
/**
* Raw relation IDs use expand?.socials for resolved records
*/
socials: string[];
/**
* Expanded relation data, present when fetched with expand=email,socials
*/
expand?: {
/**
* Resolved email contact record
*/
email?: SocialRecord;
/**
* Resolved social link records
*/
socials?: SocialRecord[];
};
};
/**
* PocketBase collection for global site configuration.
* Single-record collection only the first record is consumed.
*/
export type SiteSettingsRecord = BaseRecord & {
/**
* CV filename stored in PocketBase build the full URL with buildFileUrl()
*/
cv: string;
/**
* Raw relation ID use expand?.contacts for the resolved record
*/
contacts: string;
/**
* Expanded relation data, present when fetched with expand=contacts,contacts.socials
*/
expand?: {
/**
* Resolved contacts record
*/
contacts?: ContactsRecord;
};
};
/** /**
* Generic response for a list of PocketBase records. * Generic response for a list of PocketBase records.
*/ */
+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';
+1
View File
@@ -1,6 +1,7 @@
export type { ClassValue } from 'clsx'; export type { ClassValue } from 'clsx';
export { CONTACT_LINKS } from './config/config'; export { CONTACT_LINKS } from './config/config';
export * from './fonts/fonts'; export * from './fonts/fonts';
export { buildFileUrl } from './utils/buildFileUrl/buildFileUrl';
export { cn } from './utils/cn/cn'; export { cn } from './utils/cn/cn';
export * from './utils/formatDate/formatDate'; export * from './utils/formatDate/formatDate';
export { groupByKey } from './utils/groupByKey/groupByKey'; export { groupByKey } from './utils/groupByKey/groupByKey';
@@ -0,0 +1,33 @@
import { buildFileUrl } from './buildFileUrl';
describe('buildFileUrl', () => {
describe('default base URL', () => {
it('builds correct URL with default base', () => {
expect(buildFileUrl('site_settings', 'ss1', 'cv_2024.pdf')).toBe(
'http://127.0.0.1:8090/api/files/site_settings/ss1/cv_2024.pdf',
);
});
});
describe('custom base URL', () => {
it('uses provided baseUrl when given', () => {
expect(buildFileUrl('photos', 'rec1', 'avatar.png', 'https://pb.example.com')).toBe(
'https://pb.example.com/api/files/photos/rec1/avatar.png',
);
});
});
describe('different collections, records, filenames', () => {
it('handles projects collection', () => {
expect(buildFileUrl('projects', 'proj42', 'screenshot.jpg', 'http://127.0.0.1:8090')).toBe(
'http://127.0.0.1:8090/api/files/projects/proj42/screenshot.jpg',
);
});
it('handles contacts collection', () => {
expect(buildFileUrl('contacts', 'cid99', 'photo.webp', 'http://127.0.0.1:8090')).toBe(
'http://127.0.0.1:8090/api/files/contacts/cid99/photo.webp',
);
});
});
});
@@ -0,0 +1,11 @@
/**
* Builds a URL for a file stored in a PocketBase record.
*/
export function buildFileUrl(
collectionId: string,
recordId: string,
filename: string,
baseUrl: string = process.env.PB_PUBLIC_URL ?? 'http://127.0.0.1:8090',
): string {
return `${baseUrl}/api/files/${collectionId}/${recordId}/${filename}`;
}
@@ -1,47 +1,47 @@
import { formatYearRange } from './formatDate'; import { formatMonthYearRange } from './formatDate';
describe('formatYearRange', () => { describe('formatMonthYearRange', () => {
describe('Success Paths', () => { describe('open-ended range', () => {
it('formats a date range within the same year', () => { it('formats start date with Present when end is null', () => {
const start = '2024-01-01 12:00:00.000Z'; expect(formatMonthYearRange('2022-01-01T00:00:00Z', null)).toBe('Jan 2022 — Present');
const end = '2024-12-31 12:00:00.000Z';
expect(formatYearRange(start, end)).toBe('2024');
}); });
it('formats a range between different years', () => { it('uses abbreviated month name', () => {
const start = '2021-05-15 12:00:00.000Z'; expect(formatMonthYearRange('2020-08-15T00:00:00Z', null)).toBe('Aug 2020 — Present');
const end = '2024-03-20 12:00:00.000Z';
expect(formatYearRange(start, end)).toBe('2021 — 2024');
});
it('formats a range with null end date as "Present"', () => {
const start = '2022-08-01 12:00:00.000Z';
const end = null;
expect(formatYearRange(start, end)).toBe('2022 — Present');
}); });
}); });
describe('Error & Edge Cases', () => { describe('closed range', () => {
it('formats start and end with month and year', () => {
expect(formatMonthYearRange('2021-05-01T00:00:00Z', '2024-03-31T00:00:00Z')).toBe('May 2021 — Mar 2024');
});
it('handles same year with different months', () => {
expect(formatMonthYearRange('2024-01-01T00:00:00Z', '2024-12-31T00:00:00Z')).toBe('Jan 2024 — Dec 2024');
});
it('handles same month and year', () => {
expect(formatMonthYearRange('2024-06-01T00:00:00Z', '2024-06-30T00:00:00Z')).toBe('Jun 2024');
});
});
describe('error cases', () => {
it('throws if start date is invalid', () => { it('throws if start date is invalid', () => {
const start = 'not-a-date'; expect(() => formatMonthYearRange('not-a-date', null)).toThrow('Invalid start date');
const end = '2024-01-01';
expect(() => formatYearRange(start, end)).toThrow('Invalid start date');
}); });
it('throws if end date is provided but invalid', () => { it('throws if end date is provided but invalid', () => {
const start = '2024-01-01'; expect(() => formatMonthYearRange('2024-01-01T00:00:00Z', 'invalid')).toThrow('Invalid end date');
const end = 'invalid';
expect(() => formatYearRange(start, end)).toThrow('Invalid end date');
}); });
it('throws if start year is after end year', () => { it('throws if start is after end', () => {
const start = '2024-01-01'; expect(() => formatMonthYearRange('2024-01-01T00:00:00Z', '2020-01-01T00:00:00Z')).toThrow(
const end = '2020-01-01'; 'Start date cannot be after end date',
expect(() => formatYearRange(start, end)).toThrow('Start year cannot be after end year'); );
}); });
it('handles empty strings by throwing', () => { it('throws on empty string', () => {
expect(() => formatYearRange('', null)).toThrow('Invalid start date'); expect(() => formatMonthYearRange('', null)).toThrow('Invalid start date');
}); });
}); });
}); });
+17 -10
View File
@@ -1,31 +1,38 @@
const MONTH_FMT = new Intl.DateTimeFormat('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' });
function formatMonthYear(date: Date): string {
return MONTH_FMT.format(date);
}
/** /**
* Formats a PocketBase date string into a localized year string or "Present". * Formats a PocketBase date string into a localized month+year range or "Present".
* @throws {Error} if any date is invalid or if the range is logically impossible. * @throws {Error} if any date is invalid or if the range is logically impossible.
*/ */
export function formatYearRange(start: string, end: string | null): string { export function formatMonthYearRange(start: string, end: string | null): string {
const startDate = new Date(start); const startDate = new Date(start);
if (Number.isNaN(startDate.getTime())) { if (Number.isNaN(startDate.getTime())) {
throw new Error('Invalid start date'); throw new Error('Invalid start date');
} }
const startYear = startDate.getFullYear();
if (end === null) { if (end === null) {
return `${startYear} — Present`; return `${formatMonthYear(startDate)} — Present`;
} }
const endDate = new Date(end); const endDate = new Date(end);
if (Number.isNaN(endDate.getTime())) { if (Number.isNaN(endDate.getTime())) {
throw new Error('Invalid end date'); throw new Error('Invalid end date');
} }
const endYear = endDate.getFullYear();
if (startYear > endYear) { if (startDate > endDate) {
throw new Error('Start year cannot be after end year'); throw new Error('Start date cannot be after end date');
} }
if (startYear === endYear) { const startLabel = formatMonthYear(startDate);
return `${startYear}`; const endLabel = formatMonthYear(endDate);
if (startLabel === endLabel) {
return startLabel;
} }
return `${startYear}${endYear}`; return `${startLabel}${endLabel}`;
} }
@@ -22,7 +22,7 @@ describe('groupByKey', () => {
{ category: 'A', order: 1 }, { category: 'A', order: 1 },
{ category: 'A', order: 2 }, { category: 'A', order: 2 },
]; ];
expect(groupByKey(items, 'category')['A']).toEqual([ expect(groupByKey(items, 'category').A).toEqual([
{ category: 'A', order: 1 }, { category: 'A', order: 1 },
{ category: 'A', order: 2 }, { category: 'A', order: 2 },
]); ]);
@@ -41,7 +41,7 @@ describe('groupByKey', () => {
]; ];
const result = groupByKey(items, 'type'); const result = groupByKey(items, 'type');
expect(Object.keys(result)).toHaveLength(1); expect(Object.keys(result)).toHaveLength(1);
expect(result['X']).toHaveLength(2); expect(result.X).toHaveLength(2);
}); });
it('handles single item', () => { it('handles single item', () => {
+308 -59
View File
@@ -20,36 +20,38 @@
--font-weight-body: 600; --font-weight-body: 600;
--font-weight-normal: 400; --font-weight-normal: 400;
/* Fluid section title: scales from 2rem at ~267px to 8rem at ~1707px */
--text-section-title: clamp(2rem, 7.5vw, 8rem);
/* === LINE HEIGHT === */ /* === LINE HEIGHT === */
--line-height-tight: 1.2; --line-height-tight: 1.2;
--line-height-normal: 1.5; --line-height-normal: 1.5;
--line-height-relaxed: 1.65;
/* === FRAUNCES VARIABLE AXES === */ /* === FRAUNCES VARIABLE AXES === */
--fraunces-wonk: 1; --fraunces-wonk: 1;
--fraunces-soft: 0; --fraunces-soft: 0;
/* === COLOR PALETTE === */ /* === COLOR PALETTE: 2-color system === */
--ochre-clay: #d9b48f; --cream: #f4f0e8;
--slate-indigo: #3b4a59; --blue: #041cf3;
--burnt-oxide: #a64b35;
--carbon-black: #121212;
/* === SEMANTIC COLORS === */ /* === SEMANTIC COLORS === */
--background: var(--ochre-clay); --background: var(--cream);
--foreground: var(--carbon-black); --foreground: var(--blue);
--card: var(--ochre-clay); --card: var(--cream);
--card-foreground: var(--carbon-black); --card-foreground: var(--blue);
--primary: var(--burnt-oxide); --primary: var(--blue);
--primary-foreground: var(--ochre-clay); --primary-foreground: var(--cream);
--secondary: var(--slate-indigo); --secondary: var(--cream);
--secondary-foreground: var(--ochre-clay); --secondary-foreground: var(--blue);
--muted: var(--slate-indigo); --muted: var(--cream);
--muted-foreground: var(--ochre-clay); --muted-foreground: rgba(4, 28, 243, 0.5);
--accent: var(--burnt-oxide); --accent: var(--blue);
--accent-foreground: var(--ochre-clay); --accent-foreground: var(--cream);
--destructive: #d4183d; --destructive: var(--blue);
--border: var(--carbon-black); --border: var(--blue);
--ring: var(--carbon-black); --ring: var(--blue);
/* === SPACING (8pt Linear Scale) === */ /* === SPACING (8pt Linear Scale) === */
--space-0: 0; --space-0: 0;
@@ -71,22 +73,37 @@
--radius: 0px; --radius: 0px;
/* === BRUTALIST SHADOWS === */ /* === BRUTALIST SHADOWS === */
--shadow-brutal: 8px 8px 0 var(--carbon-black); --shadow-brutal-xs: 1px 1px 0 var(--blue);
--shadow-brutal-sm: 4px 4px 0 var(--carbon-black); --shadow-brutal-sm: 3px 3px 0 var(--blue);
--shadow-brutal-lg: 12px 12px 0 var(--carbon-black); --shadow-brutal: 5px 5px 0 var(--blue);
--shadow-brutal-md: 7px 7px 0 var(--blue);
--shadow-brutal-lg: 8px 8px 0 var(--blue);
--shadow-brutal-xl: 10px 10px 0 var(--blue);
--shadow-brutal-2xl: 12px 12px 0 var(--blue);
/* === GRID === */ /* === GRID === */
--grid-gap: var(--space-3); --grid-gap: var(--space-3);
--section-content-width: 72rem;
/* === ANIMATION === */
--ease-default: ease;
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-decelerate: cubic-bezier(0.25, 0, 0, 1);
--ease-micro: cubic-bezier(0.22, 1, 0.36, 1);
--duration-fast: 100ms;
--duration-normal: 150ms;
--duration-slow: 350ms;
--duration-spring: 220ms;
--delay-normal: 200ms;
--slide-section-body-in: clamp(1.25rem, 5vw, 3rem);
} }
@theme inline { @theme inline {
--font-heading: var(--font-fraunces); --font-heading: var(--font-fraunces);
--font-body: var(--font-public-sans); --font-body: var(--font-public-sans);
--color-ochre-clay: var(--ochre-clay); --color-cream: var(--cream);
--color-slate-indigo: var(--slate-indigo); --color-blue: var(--blue);
--color-burnt-oxide: var(--burnt-oxide);
--color-carbon-black: var(--carbon-black);
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
@@ -105,6 +122,18 @@
--radius-sm: var(--radius); --radius-sm: var(--radius);
--radius-md: var(--radius); --radius-md: var(--radius);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--container-section: var(--section-content-width);
--spacing-footer: 5rem;
--spacing-footer-wide: 4rem;
--text-section-title: var(--text-section-title);
--shadow-brutal-xs: var(--shadow-brutal-xs);
--shadow-brutal-sm: var(--shadow-brutal-sm);
--shadow-brutal: var(--shadow-brutal);
--shadow-brutal-md: var(--shadow-brutal-md);
--shadow-brutal-lg: var(--shadow-brutal-lg);
--shadow-brutal-xl: var(--shadow-brutal-xl);
--shadow-brutal-2xl: var(--shadow-brutal-2xl);
} }
@layer base { @layer base {
@@ -112,8 +141,22 @@
@apply border-border; @apply border-border;
} }
::selection {
background-color: var(--blue);
color: var(--cream);
}
:focus-visible {
outline: var(--border-width) solid var(--blue);
outline-offset: 2px;
}
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 {
@@ -124,18 +167,16 @@
overflow-x: hidden; overflow-x: hidden;
} }
/* Paper grain texture */ /* 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(0, 0, 0, 0.02) 2px, rgba(0, 0, 0, 0.02) 4px),
repeating-linear-gradient(90deg, transparent, transparent 2px, rgba(0, 0, 0, 0.02) 2px, rgba(0, 0, 0, 0.02) 4px);
opacity: 0.4;
display: block;
pointer-events: none; pointer-events: none;
z-index: 1; z-index: 100;
} }
h1, h1,
@@ -150,7 +191,7 @@
font-variation-settings: font-variation-settings:
"WONK" var(--fraunces-wonk), "WONK" var(--fraunces-wonk),
"SOFT" var(--fraunces-soft); "SOFT" var(--fraunces-soft);
color: var(--carbon-black); color: var(--blue);
} }
h1 { h1 {
@@ -173,29 +214,28 @@
font-family: var(--font-body); font-family: var(--font-body);
font-size: var(--text-base); font-size: var(--text-base);
font-weight: var(--font-weight-body); font-weight: var(--font-weight-body);
color: var(--carbon-black); color: var(--blue);
} }
a { a {
color: var(--burnt-oxide); color: var(--blue);
text-decoration: none; text-decoration: none;
border-bottom: 2px solid var(--carbon-black);
transition: all 0.2s;
}
a:hover {
border-bottom-width: 4px;
} }
blockquote { blockquote {
font-family: var(--font-heading); font-family: var(--font-heading);
font-size: var(--text-xl); font-size: var(--text-xl);
border-left: var(--border-width) solid var(--carbon-black); border-left: var(--border-width) solid var(--blue);
padding-left: var(--space-4); padding-left: var(--space-4);
margin: var(--space-6) 0; margin: var(--space-6) 0;
} }
} }
/* Button elevation transition — only transform animates; shadow snaps instantly */
.btn-transition {
transition: transform 0.13s var(--ease-micro);
}
/* Brutalist utility classes */ /* Brutalist utility classes */
.brutal-shadow { .brutal-shadow {
box-shadow: var(--shadow-brutal); box-shadow: var(--shadow-brutal);
@@ -206,33 +246,242 @@
.brutal-shadow-lg { .brutal-shadow-lg {
box-shadow: var(--shadow-brutal-lg); box-shadow: var(--shadow-brutal-lg);
} }
.brutal-border { @utility brutal-border {
border: var(--border-width) solid var(--carbon-black); border: var(--border-width) solid var(--blue);
} }
.brutal-border-top { @utility brutal-border-top {
border-top: var(--border-width) solid var(--carbon-black); border-top: var(--border-width) solid var(--blue);
} }
.brutal-border-bottom { @utility brutal-border-bottom {
border-bottom: var(--border-width) solid var(--carbon-black); border-bottom: var(--border-width) solid var(--blue);
} }
.brutal-border-left { @utility brutal-border-left {
border-left: var(--border-width) solid var(--carbon-black); border-left: var(--border-width) solid var(--blue);
} }
.brutal-border-right { @utility brutal-border-right {
border-right: var(--border-width) solid var(--carbon-black); 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:
"WONK" var(--fraunces-wonk),
"SOFT" var(--fraunces-soft);
} }
/* Animations */ /* Sidebar divider: bottom border on mobile, right border on desktop */
@keyframes fadeIn { .brutal-border-sidebar {
border-bottom: var(--border-width) solid var(--blue);
}
@media (min-width: 1024px) {
.brutal-border-sidebar {
border-bottom: none;
border-right: var(--border-width) solid var(--blue);
}
}
/* Editorial rich-text typography */
.rich-text {
max-width: 65ch;
line-height: var(--line-height-relaxed);
font-feature-settings: "onum";
hanging-punctuation: first last;
text-wrap: pretty;
}
.rich-text a {
border-bottom: var(--border-width) solid var(--blue);
opacity: 1;
transition: opacity var(--duration-normal);
}
.rich-text a:hover {
opacity: 0.6;
}
.rich-text p + p {
margin-top: 1.2em;
}
.rich-text ul {
list-style: none;
padding-left: 1.5em;
margin: 1em 0;
}
.rich-text ul li {
text-indent: -1.5em;
margin-top: 0.5em;
}
.rich-text ul li:first-child {
margin-top: 0;
}
.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 */
line-height: calc(var(--line-height-relaxed) / 0.55);
}
/* Cross-section view transition (navigation between sections) */
::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-title) {
animation-name: section-fade-in;
animation-duration: var(--duration-spring);
animation-timing-function: var(--ease-spring);
animation-fill-mode: both;
}
@keyframes section-fade-out {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-8px);
}
}
@keyframes section-fade-in {
from { from {
opacity: 0; opacity: 0;
transform: translateY(10px); transform: translateY(12px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
} }
.animate-fadeIn {
animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1); /* 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;
}
} }
+1 -1
View File
@@ -1,2 +1,2 @@
export type { BadgeVariant } from './ui/Badge'; export type { BadgeSize, BadgeVariant } from './ui/Badge';
export { Badge } from './ui/Badge'; export { Badge } from './ui/Badge';
+26 -4
View File
@@ -18,17 +18,17 @@ describe('Badge', () => {
it('applies default variant classes', () => { it('applies default variant classes', () => {
render(<Badge variant="default">Tag</Badge>); render(<Badge variant="default">Tag</Badge>);
const el = screen.getByText('Tag'); const el = screen.getByText('Tag');
expect(el).toHaveClass('bg-carbon-black', 'text-ochre-clay'); expect(el).toHaveClass('bg-blue', 'text-cream');
}); });
it('applies primary variant classes', () => { it('applies primary variant classes', () => {
render(<Badge variant="primary">Tag</Badge>); render(<Badge variant="primary">Tag</Badge>);
expect(screen.getByText('Tag')).toHaveClass('bg-burnt-oxide'); expect(screen.getByText('Tag')).toHaveClass('bg-blue');
}); });
it('applies secondary variant classes', () => { it('applies secondary variant classes', () => {
render(<Badge variant="secondary">Tag</Badge>); render(<Badge variant="secondary">Tag</Badge>);
expect(screen.getByText('Tag')).toHaveClass('bg-slate-indigo'); expect(screen.getByText('Tag')).toHaveClass('bg-blue');
}); });
it('applies outline variant classes', () => { it('applies outline variant classes', () => {
@@ -38,7 +38,29 @@ describe('Badge', () => {
it('defaults to default variant when unspecified', () => { it('defaults to default variant when unspecified', () => {
render(<Badge>Tag</Badge>); render(<Badge>Tag</Badge>);
expect(screen.getByText('Tag')).toHaveClass('bg-carbon-black'); expect(screen.getByText('Tag')).toHaveClass('bg-blue');
});
});
describe('sizes', () => {
it('defaults to sm size', () => {
render(<Badge>Tag</Badge>);
expect(screen.getByText('Tag')).toHaveClass('px-3', 'py-1', 'text-xs');
});
it('applies xs size classes', () => {
render(<Badge size="xs">Tag</Badge>);
expect(screen.getByText('Tag')).toHaveClass('px-2', 'py-0.5');
});
it('applies sm size classes', () => {
render(<Badge size="sm">Tag</Badge>);
expect(screen.getByText('Tag')).toHaveClass('px-3', 'py-1', 'text-xs');
});
it('applies md size classes', () => {
render(<Badge size="md">Tag</Badge>);
expect(screen.getByText('Tag')).toHaveClass('px-4', 'py-2', 'text-sm');
}); });
}); });
+18 -6
View File
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
import { cn } from '$shared/lib'; import { cn } from '$shared/lib';
export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'outline'; export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'outline';
export type BadgeSize = 'xs' | 'sm' | 'md';
interface Props { interface Props {
/** /**
@@ -13,6 +14,11 @@ interface Props {
* @default 'default' * @default 'default'
*/ */
variant?: BadgeVariant; variant?: BadgeVariant;
/**
* Size preset
* @default 'sm'
*/
size?: BadgeSize;
/** /**
* Additional CSS classes * Additional CSS classes
*/ */
@@ -20,18 +26,24 @@ interface Props {
} }
const VARIANTS: Record<BadgeVariant, string> = { const VARIANTS: Record<BadgeVariant, string> = {
default: 'brutal-border bg-carbon-black text-ochre-clay', default: 'brutal-border bg-blue text-cream',
primary: 'brutal-border bg-burnt-oxide text-ochre-clay', primary: 'brutal-border bg-blue text-cream',
secondary: 'brutal-border bg-slate-indigo text-ochre-clay', secondary: 'brutal-border bg-blue text-cream',
outline: 'brutal-border bg-transparent text-carbon-black', outline: 'brutal-border bg-transparent text-blue',
};
const SIZES: Record<BadgeSize, string> = {
xs: 'px-2 py-0.5 text-[10px]',
sm: 'px-3 py-1 text-xs',
md: 'px-4 py-2 text-sm',
}; };
/** /**
* Small label for categorization or status. * Small label for categorization or status.
*/ */
export function Badge({ children, variant = 'default', className }: Props) { export function Badge({ children, variant = 'default', size = 'sm', className }: Props) {
return ( return (
<span className={cn('inline-block px-3 py-1 text-xs uppercase tracking-wider', VARIANTS[variant], className)}> <span className={cn('inline-block uppercase tracking-wider', SIZES[size], VARIANTS[variant], className)}>
{children} {children}
</span> </span>
); );
+32 -5
View File
@@ -16,19 +16,19 @@ describe('Button', () => {
describe('variants', () => { describe('variants', () => {
it('applies primary variant by default', () => { it('applies primary variant by default', () => {
render(<Button>Go</Button>); render(<Button>Go</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-burnt-oxide'); expect(screen.getByRole('button')).toHaveClass('bg-blue');
}); });
it('applies secondary variant', () => { it('applies secondary variant', () => {
render(<Button variant="secondary">Go</Button>); render(<Button variant="secondary">Go</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-slate-indigo'); expect(screen.getByRole('button')).toHaveClass('bg-blue');
}); });
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-ochre-clay'); expect(screen.getByRole('button')).toHaveClass('bg-transparent');
}); });
}); });
describe('sizes', () => { describe('sizes', () => {
@@ -38,7 +38,7 @@ describe('Button', () => {
}); });
it('applies sm size', () => { it('applies sm size', () => {
render(<Button size="sm">Go</Button>); render(<Button size="sm">Go</Button>);
expect(screen.getByRole('button')).toHaveClass('px-4', 'py-2'); expect(screen.getByRole('button')).toHaveClass('px-3', 'py-1.5', 'sm:px-4', 'sm:py-2');
}); });
it('applies lg size', () => { it('applies lg size', () => {
render(<Button size="lg">Go</Button>); render(<Button size="lg">Go</Button>);
@@ -63,4 +63,31 @@ describe('Button', () => {
expect(screen.getByRole('button')).toHaveClass('w-full'); expect(screen.getByRole('button')).toHaveClass('w-full');
}); });
}); });
describe('as anchor', () => {
it('renders an anchor when href is provided', () => {
render(<Button href="/cv.pdf">Download</Button>);
expect(screen.getByRole('link', { name: 'Download' })).toBeInTheDocument();
});
it('sets href on the anchor', () => {
render(<Button href="/cv.pdf">Download</Button>);
expect(screen.getByRole('link')).toHaveAttribute('href', '/cv.pdf');
});
it('sets download attribute when provided', () => {
render(
<Button href="/cv.pdf" download>
Download
</Button>,
);
expect(screen.getByRole('link')).toHaveAttribute('download');
});
it('applies the same variant and size classes as button', () => {
render(
<Button href="/test" variant="primary" size="sm">
Go
</Button>,
);
const link = screen.getByRole('link');
expect(link).toHaveClass('bg-blue', 'px-3', 'py-1.5', 'sm:px-4', 'sm:py-2');
});
});
}); });
+50 -14
View File
@@ -1,10 +1,10 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'; import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from '$shared/lib'; import { cn } from '$shared/lib';
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost'; export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
export type ButtonSize = 'sm' | 'md' | 'lg'; export type ButtonSize = 'sm' | 'md' | 'lg';
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> { type BaseProps = {
/** /**
* Visual variant * Visual variant
* @default 'primary' * @default 'primary'
@@ -19,30 +19,66 @@ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
* Button content * Button content
*/ */
children: ReactNode; children: ReactNode;
/**
* CSS classes
*/
className?: string;
};
type AsButton = BaseProps & ButtonHTMLAttributes<HTMLButtonElement> & { href?: never };
type AsAnchor = BaseProps & AnchorHTMLAttributes<HTMLAnchorElement> & { href: string };
type Props = AsButton | AsAnchor;
type RestButton = Omit<AsButton, keyof BaseProps>;
type RestAnchor = Omit<AsAnchor, keyof BaseProps>;
/**
* Narrows spread props to anchor shape when href is a non-undefined string.
*/
function isAnchorProps(props: RestButton | RestAnchor): props is RestAnchor {
return typeof props.href === 'string';
} }
const VARIANTS: Record<ButtonVariant, string> = { const VARIANTS = {
primary: 'bg-burnt-oxide text-ochre-clay', primary:
secondary: 'bg-slate-indigo text-ochre-clay', 'brutal-border bg-blue text-cream shadow-[5px_5px_0_rgba(4,28,243,0.35)] hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-[7px_7px_0_rgba(4,28,243,0.35)] active:translate-x-0.5 active:translate-y-0.5 active:shadow-[1px_1px_0_rgba(4,28,243,0.35)]',
outline: 'bg-transparent text-carbon-black border-carbon-black', secondary:
ghost: 'bg-ochre-clay text-carbon-black border-carbon-black', '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 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: Record<ButtonSize, string> = { const SIZES = {
sm: 'px-4 py-2 text-sm', sm: 'px-3 py-1.5 sm:px-4 sm:py-2 text-sm',
md: 'px-6 py-3 text-base', md: 'px-6 py-3 text-base',
lg: 'px-8 py-4 text-lg', lg: 'px-8 py-4 text-lg',
}; } as const satisfies Record<ButtonSize, string>;
const BASE = /* box-shadow excluded from transition intentionally snaps instantly so the
'brutal-border brutal-shadow transition-all duration-200 hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[6px_6px_0_var(--carbon-black)] active:translate-x-[4px] active:translate-y-[4px] active:shadow-[4px_4px_0_var(--carbon-black)] uppercase tracking-wider'; * eye follows the 130ms button movement, not the shadow change. */
const BASE = 'cursor-pointer btn-transition uppercase tracking-wider';
/** /**
* Brutalist button with variants and sizes. * Brutalist button with variants and sizes.
* Renders as <a> when href is provided, <button> otherwise.
*/ */
export function Button({ variant = 'primary', size = 'md', className, children, ...props }: Props) { export function Button({ variant = 'primary', size = 'md', className, children, ...props }: Props) {
const cls = cn(BASE, VARIANTS[variant], SIZES[size], className);
if (isAnchorProps(props)) {
const { href, ...anchorProps } = props;
return (
<a href={href} rel="noopener noreferrer" target="_blank" className={cls} {...anchorProps}>
{children}
</a>
);
}
return ( return (
<button className={cn(BASE, VARIANTS[variant], SIZES[size], className)} {...props}> <button className={cls} {...props}>
{children} {children}
</button> </button>
); );
+1 -1
View File
@@ -1,2 +1,2 @@
export type { CardBackground } from './ui/Card'; export type { CardBackground } from './ui/Card';
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './ui/Card'; export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './ui/Card';
+9 -16
View File
@@ -13,24 +13,17 @@ type Story = StoryObj<typeof Card>;
export const AllBackgrounds: Story = { export const AllBackgrounds: Story = {
render: () => ( render: () => (
<div className="flex gap-6 flex-wrap p-8 bg-white"> <div className="flex gap-6 flex-wrap p-8 bg-white">
<Card background="ochre" className="w-64"> <Card background="cream" className="w-64">
<CardHeader> <CardHeader>
<CardTitle>Ochre Card</CardTitle> <CardTitle>Cream Card</CardTitle>
<CardDescription>Background ochre-clay variant</CardDescription> <CardDescription>Default cream background variant</CardDescription>
</CardHeader> </CardHeader>
<CardFooter>Footer content</CardFooter> <CardFooter>Footer content</CardFooter>
</Card> </Card>
<Card background="slate" className="w-64"> <Card background="blue" className="w-64">
<CardHeader> <CardHeader>
<CardTitle>Slate Card</CardTitle> <CardTitle>Blue Card</CardTitle>
<CardDescription>Background slate-indigo variant</CardDescription> <CardDescription>Blue background variant</CardDescription>
</CardHeader>
<CardFooter>Footer content</CardFooter>
</Card>
<Card background="white" className="w-64">
<CardHeader>
<CardTitle>White Card</CardTitle>
<CardDescription>Background white variant</CardDescription>
</CardHeader> </CardHeader>
<CardFooter>Footer content</CardFooter> <CardFooter>Footer content</CardFooter>
</Card> </Card>
@@ -40,9 +33,9 @@ export const AllBackgrounds: Story = {
export const NoPadding: Story = { export const NoPadding: Story = {
render: () => ( render: () => (
<div className="p-8 bg-ochre-clay"> <div className="p-8">
<Card noPadding className="w-64 overflow-hidden"> <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> </Card>
</div> </div>
), ),
@@ -51,7 +44,7 @@ export const NoPadding: Story = {
export const FullComposition: Story = { export const FullComposition: Story = {
render: () => ( render: () => (
<div className="p-8 bg-white max-w-md"> <div className="p-8 bg-white max-w-md">
<Card background="ochre"> <Card background="cream">
<CardHeader> <CardHeader>
<CardTitle>Full Composition</CardTitle> <CardTitle>Full Composition</CardTitle>
<CardDescription>A card using all available slot components</CardDescription> <CardDescription>A card using all available slot components</CardDescription>
+56 -12
View File
@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './Card'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './Card';
describe('Card', () => { describe('Card', () => {
describe('rendering', () => { describe('rendering', () => {
@@ -13,17 +13,13 @@ describe('Card', () => {
}); });
}); });
describe('background variants', () => { describe('background variants', () => {
it('defaults to ochre background', () => { it('defaults to cream background', () => {
const { container } = render(<Card>Content</Card>); const { container } = render(<Card>Content</Card>);
expect(container.firstChild).toHaveClass('bg-ochre-clay'); expect(container.firstChild).toHaveClass('bg-cream');
}); });
it('applies slate background', () => { it('applies blue background', () => {
const { container } = render(<Card background="slate">Content</Card>); const { container } = render(<Card background="blue">Content</Card>);
expect(container.firstChild).toHaveClass('bg-slate-indigo'); expect(container.firstChild).toHaveClass('bg-blue');
});
it('applies white background', () => {
const { container } = render(<Card background="white">Content</Card>);
expect(container.firstChild).toHaveClass('bg-white');
}); });
}); });
describe('padding', () => { describe('padding', () => {
@@ -46,7 +42,7 @@ describe('Card', () => {
describe('CardHeader', () => { describe('CardHeader', () => {
it('renders children with bottom margin', () => { it('renders children with bottom margin', () => {
render(<CardHeader>Header</CardHeader>); render(<CardHeader>Header</CardHeader>);
expect(screen.getByText('Header')).toHaveClass('mb-4'); expect(screen.getByText('Header')).toHaveClass('mb-6');
}); });
}); });
describe('CardTitle', () => { describe('CardTitle', () => {
@@ -73,6 +69,54 @@ describe('CardFooter', () => {
it('renders children with top border', () => { it('renders children with top border', () => {
render(<CardFooter>Footer</CardFooter>); render(<CardFooter>Footer</CardFooter>);
const el = screen.getByText('Footer'); const el = screen.getByText('Footer');
expect(el).toHaveClass('brutal-border-top', 'mt-6', 'pt-6'); expect(el).toHaveClass('brutal-border-top', 'mt-6', 'pt-6', 'md:mt-8', 'md:pt-8');
});
});
describe('CardSidebar', () => {
describe('rendering', () => {
it('renders sidebar content', () => {
render(<CardSidebar sidebar={<span>Sidebar</span>}>Main</CardSidebar>);
expect(screen.getByText('Sidebar')).toBeInTheDocument();
});
it('renders main content', () => {
render(<CardSidebar sidebar={<span>Sidebar</span>}>Main</CardSidebar>);
expect(screen.getByText('Main')).toBeInTheDocument();
});
});
describe('structure', () => {
it('root wrapper is a flex container', () => {
const { container } = render(<CardSidebar sidebar={<span>S</span>}>M</CardSidebar>);
expect(container.firstChild).toHaveClass('flex');
});
it('sidebar column has brutal-border-sidebar class', () => {
render(<CardSidebar sidebar={<span>Sidebar</span>}>Main</CardSidebar>);
const sidebar = screen.getByText('Sidebar').parentElement;
expect(sidebar).toHaveClass('brutal-border-sidebar');
});
it('sidebar column has fixed width on lg', () => {
render(<CardSidebar sidebar={<span>Sidebar</span>}>Main</CardSidebar>);
const sidebar = screen.getByText('Sidebar').parentElement;
expect(sidebar).toHaveClass('lg:w-64');
});
it('main column fills remaining space', () => {
render(<CardSidebar sidebar={<span>Sidebar</span>}>Main</CardSidebar>);
expect(screen.getByText('Main')).toHaveClass('flex-1');
});
});
describe('className passthrough', () => {
it('forwards className to the root wrapper', () => {
const { container } = render(
<CardSidebar sidebar={<span>S</span>} className="custom">
M
</CardSidebar>,
);
expect(container.firstChild).toHaveClass('custom');
});
}); });
}); });
+36 -8
View File
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { cn } from '$shared/lib'; import { cn } from '$shared/lib';
export type CardBackground = 'ochre' | 'slate' | 'white'; export type CardBackground = 'cream' | 'blue';
interface CardProps { interface CardProps {
/** /**
@@ -14,7 +14,7 @@ interface CardProps {
className?: string; className?: string;
/** /**
* Background color preset * Background color preset
* @default 'ochre' * @default 'cream'
*/ */
background?: CardBackground; background?: CardBackground;
/** /**
@@ -25,15 +25,14 @@ interface CardProps {
} }
const BG: Record<CardBackground, string> = { const BG: Record<CardBackground, string> = {
ochre: 'bg-ochre-clay', cream: 'bg-cream',
slate: 'bg-slate-indigo text-ochre-clay', blue: 'bg-blue text-cream',
white: 'bg-white',
}; };
/** /**
* Brutalist card container with background and padding variants. * Brutalist card container with background and padding variants.
*/ */
export function Card({ children, className, background = 'ochre', noPadding = false }: CardProps) { export function Card({ children, className, background = 'cream', noPadding = false }: CardProps) {
return ( return (
<div className={cn('brutal-border brutal-shadow', BG[background], !noPadding && 'p-6 md:p-8', className)}> <div className={cn('brutal-border brutal-shadow', BG[background], !noPadding && 'p-6 md:p-8', className)}>
{children} {children}
@@ -56,7 +55,7 @@ interface SlotProps {
* Card header wrapper adds bottom margin. * Card header wrapper adds bottom margin.
*/ */
export function CardHeader({ children, className }: SlotProps) { export function CardHeader({ children, className }: SlotProps) {
return <div className={cn('mb-4', className)}>{children}</div>; return <div className={cn('mb-6 md:mb-8', className)}>{children}</div>;
} }
/** /**
@@ -84,5 +83,34 @@ export function CardContent({ children, className }: SlotProps) {
* Card footer separated by a brutal border-top. * Card footer separated by a brutal border-top.
*/ */
export function CardFooter({ children, className }: SlotProps) { export function CardFooter({ children, className }: SlotProps) {
return <div className={cn('mt-6 pt-6 brutal-border-top', className)}>{children}</div>; return <div className={cn('mt-6 md:mt-8 pt-6 md:pt-8 brutal-border-top', className)}>{children}</div>;
}
interface CardSidebarProps {
/**
* Left sidebar content metadata such as period, company, stack
*/
sidebar: ReactNode;
/**
* Main content primary info such as role title and description
*/
children: ReactNode;
/**
* Additional CSS classes for the root wrapper
*/
className?: string;
}
/**
* Two-column card layout: narrow sidebar on the left, main content on the right.
* On mobile the columns stack vertically with a bottom border separator;
* on md+ they sit side-by-side with a right border separator.
*/
export function CardSidebar({ sidebar, children, className }: CardSidebarProps) {
return (
<div className={cn('flex flex-col lg:flex-row', className)}>
<div className="shrink-0 lg:w-64 brutal-border-sidebar pb-6 lg:pb-0 lg:pr-8 mb-6 lg:mb-0">{sidebar}</div>
<div className="flex-1 min-w-0 lg:pl-8">{children}</div>
</div>
);
} }
+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>
</>
);
}
+1
View File
@@ -0,0 +1 @@
export { InlineSvg } from './ui/InlineSvg';
+25
View File
@@ -0,0 +1,25 @@
import parse from 'html-react-parser';
import { cn } from '$shared/lib';
export interface Props {
/**
* SVG markup string to inline as React elements
*/
svg: string;
/**
* Additional CSS classes on the wrapper span
*/
className?: string;
}
/**
* Parses an SVG markup string into React elements.
* Inherits color from parent via currentColor.
*/
export function InlineSvg({ svg, className }: Props) {
if (!svg) {
return null;
}
return <span className={cn('inline-flex items-center', className)}>{parse(svg)}</span>;
}
+5 -5
View File
@@ -13,7 +13,7 @@ interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
} }
const INPUT_BASE = const INPUT_BASE =
'brutal-border bg-white px-4 py-3 text-carbon-black focus:outline-none focus:ring-2 focus:ring-burnt-oxide focus:ring-offset-2 focus:ring-offset-ochre-clay transition-all'; 'brutal-border bg-cream px-4 py-3 text-blue focus:outline-none focus:ring-2 focus:ring-blue focus:ring-offset-2 focus:ring-offset-cream transition-all';
/** /**
* Text input with optional label and error state. * Text input with optional label and error state.
@@ -26,7 +26,7 @@ export function Input({ label, error, className, id, ...props }: InputProps) {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{label && ( {label && (
<label htmlFor={inputId} className="text-carbon-black"> <label htmlFor={inputId} className="text-blue">
{label} {label}
</label> </label>
)} )}
@@ -37,7 +37,7 @@ export function Input({ label, error, className, id, ...props }: InputProps) {
{...props} {...props}
/> />
{error && ( {error && (
<span id={errorId} className="text-sm text-burnt-oxide"> <span id={errorId} className="text-sm text-blue">
{error} {error}
</span> </span>
)} )}
@@ -72,7 +72,7 @@ export function Textarea({ label, error, rows = 4, className, id, ...props }: Te
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{label && ( {label && (
<label htmlFor={textareaId} className="text-carbon-black"> <label htmlFor={textareaId} className="text-blue">
{label} {label}
</label> </label>
)} )}
@@ -84,7 +84,7 @@ export function Textarea({ label, error, rows = 4, className, id, ...props }: Te
{...props} {...props}
/> />
{error && ( {error && (
<span id={errorId} className="text-sm text-burnt-oxide"> <span id={errorId} className="text-sm text-blue">
{error} {error}
</span> </span>
)} )}
+2
View File
@@ -0,0 +1,2 @@
export type { LinkVariant } from './ui/Link/Link';
export { Link } from './ui/Link/Link';
@@ -0,0 +1,59 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { Link } from './Link';
const meta: Meta<typeof Link> = {
title: 'Shared/Link',
component: Link,
};
export default meta;
type Story = StoryObj<typeof Link>;
const decorator = (Story: React.ComponentType) => (
<div className="p-8 bg-cream">
<Story />
</div>
);
export const Primary: Story = {
args: {
href: '/about',
children: 'Internal page',
},
decorators: [decorator],
};
export const PrimaryExternal: Story = {
args: {
href: 'https://example.com',
external: true,
children: 'External site',
},
decorators: [decorator],
};
export const Secondary: Story = {
args: {
href: 'https://github.com',
external: true,
variant: 'secondary',
children: 'GitHub',
},
decorators: [decorator],
};
export const SecondaryWithIcon: Story = {
args: {
href: 'https://github.com',
external: true,
variant: 'secondary',
className: 'flex items-center gap-1.5 text-sm',
children: (
<>
<span className="hidden sm:block">GitHub</span>
</>
),
},
decorators: [decorator],
};
+118
View File
@@ -0,0 +1,118 @@
vi.mock('next/link', () => ({
default: ({ href, children, className }: { href: string; children: React.ReactNode; className?: string }) => (
<a href={href} className={className}>
{children}
</a>
),
}));
import { render, screen } from '@testing-library/react';
import type React from 'react';
import { Link } from './Link';
const PRIMARY = 'underline underline-offset-2 hover:opacity-60 transition-opacity';
const SECONDARY = 'no-underline opacity-60 hover:opacity-100 sm:brutal-border-bottom transition-opacity';
describe('internal link', () => {
it('renders an anchor element', () => {
render(<Link href="/about">About</Link>);
expect(screen.getByRole('link', { name: 'About' })).toBeInTheDocument();
});
it('has correct href', () => {
render(<Link href="/about">About</Link>);
expect(screen.getByRole('link', { name: 'About' })).toHaveAttribute('href', '/about');
});
it('does not have target attribute', () => {
render(<Link href="/about">About</Link>);
expect(screen.getByRole('link', { name: 'About' })).not.toHaveAttribute('target');
});
it('applies primary classes by default', () => {
render(<Link href="/about">About</Link>);
const link = screen.getByRole('link', { name: 'About' });
for (const cls of PRIMARY.split(' ')) {
expect(link).toHaveClass(cls);
}
});
});
describe('external link', () => {
it('has target="_blank"', () => {
render(
<Link href="https://example.com" external>
External
</Link>,
);
expect(screen.getByRole('link', { name: 'External' })).toHaveAttribute('target', '_blank');
});
it('has rel="noopener noreferrer"', () => {
render(
<Link href="https://example.com" external>
External
</Link>,
);
expect(screen.getByRole('link', { name: 'External' })).toHaveAttribute('rel', 'noopener noreferrer');
});
it('has correct href', () => {
render(
<Link href="https://example.com" external>
External
</Link>,
);
expect(screen.getByRole('link', { name: 'External' })).toHaveAttribute('href', 'https://example.com');
});
});
describe('variant', () => {
it('primary applies underline classes', () => {
render(
<Link href="/about" variant="primary">
About
</Link>,
);
const link = screen.getByRole('link', { name: 'About' });
for (const cls of PRIMARY.split(' ')) {
expect(link).toHaveClass(cls);
}
});
it('secondary applies secondary classes', () => {
render(
<Link href="/about" variant="secondary">
About
</Link>,
);
const link = screen.getByRole('link', { name: 'About' });
for (const cls of SECONDARY.split(' ')) {
expect(link).toHaveClass(cls);
}
});
it('secondary does not apply underline', () => {
render(
<Link href="/about" variant="secondary">
About
</Link>,
);
expect(screen.getByRole('link', { name: 'About' })).not.toHaveClass('underline');
});
});
describe('className passthrough', () => {
it('merges custom className with variant classes', () => {
render(
<Link href="/about" className="text-red-500">
Styled
</Link>,
);
const link = screen.getByRole('link', { name: 'Styled' });
expect(link).toHaveClass('text-red-500');
for (const cls of PRIMARY.split(' ')) {
expect(link).toHaveClass(cls);
}
});
});
+61
View File
@@ -0,0 +1,61 @@
import NextLink from 'next/link';
import type { ReactNode } from 'react';
import { cn } from '$shared/lib';
export type LinkVariant = 'primary' | 'secondary';
/**
* Props for Link.
*/
interface Props {
/**
* Destination URL. Use a path (e.g. /about) for internal routes, or a full URL for external.
*/
href: string;
/**
* Link content
*/
children: ReactNode;
/**
* CSS classes
*/
className?: string;
/**
* Visual variant.
* primary text-decoration underline.
* secondary border-bottom at sm+, no underline on mobile (for icon+label links).
* @default 'primary'
*/
variant?: LinkVariant;
/**
* When true, renders a plain <a> with target="_blank" rel="noopener noreferrer".
* Use for links that open outside the app.
*/
external?: boolean;
}
const VARIANTS = {
primary: 'underline underline-offset-2 hover:opacity-60 transition-opacity',
secondary: 'no-underline opacity-60 hover:opacity-100 sm:brutal-border-bottom transition-opacity',
} as const satisfies Record<LinkVariant, string>;
/**
* Inline text link.
* Renders as Next.js Link for internal routes, plain <a> for external links.
*/
export function Link({ href, children, className, variant = 'primary', external }: Props) {
const cls = cn(VARIANTS[variant], className);
if (external) {
return (
<a href={href} target="_blank" rel="noopener noreferrer" className={cls}>
{children}
</a>
);
}
return (
<NextLink href={href} className={cls}>
{children}
</NextLink>
);
}
+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>
);
});
+1
View File
@@ -0,0 +1 @@
export { RichText } from './ui/RichText';
@@ -0,0 +1,45 @@
import { render, screen } from '@testing-library/react';
import { RichText } from './RichText';
describe('RichText', () => {
describe('rendering', () => {
it('renders a paragraph from <p> tag', () => {
render(<RichText html="<p>Hello world</p>" />);
expect(screen.getByText('Hello world').tagName).toBe('P');
});
it('renders bold text from <strong> tag', () => {
render(<RichText html="<strong>Bold</strong>" />);
expect(screen.getByText('Bold').tagName).toBe('STRONG');
});
it('renders a link from <a> tag', () => {
render(<RichText html='<a href="https://example.com">Link</a>' />);
const link = screen.getByRole('link', { name: 'Link' });
expect(link).toHaveAttribute('href', 'https://example.com');
});
it('renders nested tags', () => {
render(<RichText html="<p>Text with <em>emphasis</em></p>" />);
expect(screen.getByText('emphasis').tagName).toBe('EM');
});
it('renders nothing for empty string', () => {
const { container } = render(<RichText html="" />);
expect(container.firstChild).toBeNull();
});
it('renders multiple sibling elements', () => {
render(<RichText html="<p>First</p><p>Second</p>" />);
expect(screen.getByText('First')).toBeInTheDocument();
expect(screen.getByText('Second')).toBeInTheDocument();
});
});
describe('className passthrough', () => {
it('applies className to the wrapper', () => {
const { container } = render(<RichText html="<p>text</p>" className="prose" />);
expect(container.firstChild).toHaveClass('prose');
});
});
});
+25
View File
@@ -0,0 +1,25 @@
import parse from 'html-react-parser';
import { cn } from '$shared/lib';
export interface Props {
/**
* HTML string from PocketBase rich-text editor
*/
html: string;
/**
* Additional CSS classes merged onto the wrapper div
*/
className?: string;
}
/**
* Renders a PocketBase rich-text HTML string as React elements.
* Always applies editorial magazine typography via the rich-text CSS class.
*/
export function RichText({ html, className }: Props) {
if (!html) {
return null;
}
return <div className={cn('rich-text', className)}>{parse(html)}</div>;
}
+5 -14
View File
@@ -13,27 +13,18 @@ type Story = StoryObj<typeof Section>;
export const AllBackgrounds: Story = { export const AllBackgrounds: Story = {
render: () => ( render: () => (
<div> <div>
<Section background="ochre" className="py-12"> <Section background="cream" className="py-12">
<Container> <Container>
<h2>Ochre Section</h2> <h2>Cream Section</h2>
<p> <p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. dolore magna aliqua.
</p> </p>
</Container> </Container>
</Section> </Section>
<Section background="slate" className="py-12"> <Section background="blue" className="py-12">
<Container> <Container>
<h2>Slate 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.
</p>
</Container>
</Section>
<Section background="white" className="py-12">
<Container>
<h2>White Section</h2>
<p> <p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. dolore magna aliqua.
@@ -46,7 +37,7 @@ export const AllBackgrounds: Story = {
export const Bordered: Story = { export const Bordered: Story = {
render: () => ( render: () => (
<Section background="ochre" bordered className="py-12"> <Section background="cream" bordered className="py-12">
<Container> <Container>
<h2>Bordered Section</h2> <h2>Bordered Section</h2>
<p> <p>
+5 -9
View File
@@ -18,17 +18,13 @@ describe('Section', () => {
}); });
describe('background variants', () => { describe('background variants', () => {
it('defaults to ochre background', () => { it('defaults to cream background', () => {
const { container } = render(<Section>x</Section>); const { container } = render(<Section>x</Section>);
expect(container.querySelector('section')).toHaveClass('bg-ochre-clay', 'text-carbon-black'); expect(container.querySelector('section')).toHaveClass('bg-cream', 'text-blue');
}); });
it('applies slate background', () => { it('applies blue background', () => {
const { container } = render(<Section background="slate">x</Section>); const { container } = render(<Section background="blue">x</Section>);
expect(container.querySelector('section')).toHaveClass('bg-slate-indigo', 'text-ochre-clay'); expect(container.querySelector('section')).toHaveClass('bg-blue', 'text-cream');
});
it('applies white background', () => {
const { container } = render(<Section background="white">x</Section>);
expect(container.querySelector('section')).toHaveClass('bg-white', 'text-carbon-black');
}); });
}); });
+5 -6
View File
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { cn } from '$shared/lib'; import { cn } from '$shared/lib';
export type SectionBackground = 'ochre' | 'slate' | 'white'; export type SectionBackground = 'cream' | 'blue';
export type ContainerSize = 'default' | 'wide' | 'ultra-wide'; export type ContainerSize = 'default' | 'wide' | 'ultra-wide';
interface SectionProps { interface SectionProps {
@@ -11,7 +11,7 @@ interface SectionProps {
children: ReactNode; children: ReactNode;
/** /**
* Background color variant * Background color variant
* @default 'ochre' * @default 'cream'
*/ */
background?: SectionBackground; background?: SectionBackground;
/** /**
@@ -26,15 +26,14 @@ interface SectionProps {
} }
const BACKGROUNDS: Record<SectionBackground, string> = { const BACKGROUNDS: Record<SectionBackground, string> = {
ochre: 'bg-ochre-clay text-carbon-black', cream: 'bg-cream text-blue',
slate: 'bg-slate-indigo text-ochre-clay', blue: 'bg-blue text-cream',
white: 'bg-white text-carbon-black',
}; };
/** /**
* Full-width page section with background and optional borders. * Full-width page section with background and optional borders.
*/ */
export function Section({ children, background = 'ochre', bordered = false, className }: SectionProps) { export function Section({ children, background = 'cream', bordered = false, className }: SectionProps) {
return ( return (
<section className={cn(BACKGROUNDS[background], bordered && 'brutal-border-top brutal-border-bottom', className)}> <section className={cn(BACKGROUNDS[background], bordered && 'brutal-border-top brutal-border-bottom', className)}>
{children} {children}
+1 -1
View File
@@ -18,7 +18,7 @@ export function TechStackBrick({ name, className }: TechStackBrickProps) {
return ( return (
<div <div
className={cn( className={cn(
'brutal-border brutal-shadow bg-white px-4 py-3 text-center', 'brutal-border brutal-shadow bg-cream px-4 py-3 text-center',
'transition-all duration-200 hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px]', 'transition-all duration-200 hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px]',
className, className,
)} )}
@@ -0,0 +1 @@
export { ViewTransitionWrapper } from './ui/ViewTransitionWrapper';
@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react';
import { ViewTransitionWrapper } from './ViewTransitionWrapper';
describe('ViewTransitionWrapper', () => {
it('renders children', () => {
render(
<ViewTransitionWrapper name="test">
<p>Hello</p>
</ViewTransitionWrapper>,
);
expect(screen.getByText('Hello')).toBeInTheDocument();
});
it('renders multiple children', () => {
render(
<ViewTransitionWrapper name="test">
<p>First</p>
<p>Second</p>
</ViewTransitionWrapper>,
);
expect(screen.getByText('First')).toBeInTheDocument();
expect(screen.getByText('Second')).toBeInTheDocument();
});
it('does not add an extra wrapper DOM element', () => {
const { container } = render(
<ViewTransitionWrapper name="test">
<p>Content</p>
</ViewTransitionWrapper>,
);
expect(container.firstChild?.nodeName).toBe('P');
});
});
@@ -0,0 +1,26 @@
import { Fragment, type ReactNode, ViewTransition as VT } from 'react';
/**
* VT is undefined in stable react-dom (test env / non-experimental builds).
* Fall back to Fragment so children render and the name prop is silently ignored.
*/
const Transition = (VT ?? Fragment) as typeof VT;
export interface Props {
/**
* Maps to the view-transition-name CSS property
*/
name: string;
/**
* Content to animate
*/
children: ReactNode;
}
/**
* Wraps children in React's ViewTransition when available,
* falling back to a Fragment in environments where ViewTransition is undefined.
*/
export function ViewTransitionWrapper({ name, children }: Props) {
return <Transition name={name}>{children}</Transition>;
}
+9 -4
View File
@@ -1,12 +1,17 @@
export type { BadgeVariant } from './Badge'; export type { BadgeSize, BadgeVariant } from './Badge';
export { Badge } from './Badge'; export { Badge } from './Badge';
export type { ButtonSize, ButtonVariant } from './Button'; 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, CardTitle } 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 { 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 type { ContainerSize, SectionBackground } from './Section';
export { Container, Section } from './Section'; export { Container, Section } from './Section';
export { TechStackBrick, TechStackGrid } from './TechStack'; export { TechStackBrick, TechStackGrid } from './TechStack';
export { ViewTransitionWrapper } from './ViewTransitionWrapper';
@@ -1,22 +1,18 @@
import { notFound } from 'next/navigation';
import type { PageContentRecord } from '$shared/api'; import type { PageContentRecord } from '$shared/api';
import { getFirstRecord } from '$shared/api'; import { getFirstRecord } from '$shared/api';
import { RichText } from '$shared/ui';
/** /**
* Bio section component. * Bio section component.
* Displays personal biography content from PocketBase. * Displays personal biography content from PocketBase.
*/ */
export default async function BioSection() { export default async function BioSection() {
const data = await getFirstRecord<PageContentRecord>('bio', { const data = await getFirstRecord<PageContentRecord>('bio', { tags: ['bio'] });
filter: 'slug = "bio"',
});
if (!data) { if (!data) {
return <p>Loading bio content...</p>; notFound();
} }
return ( return <RichText html={data.content} />;
<div className="prose prose-lg dark:prose-invert max-w-none">
<p>{data.content}</p>
</div>
);
} }
+1
View File
@@ -0,0 +1 @@
export { default as ExperienceSection } from './ui/ExperienceSection/ExperienceSection';
@@ -0,0 +1,89 @@
vi.mock('$shared/api', () => ({
getCollection: vi.fn(),
}));
import { render, screen } from '@testing-library/react';
import { getCollection } from '$shared/api';
import ExperienceSection from './ExperienceSection';
const mockItems = [
{
id: '1',
collectionId: 'c1',
collectionName: 'experience',
created: '',
updated: '',
company: 'Acme Corp',
role: 'Senior Developer',
start_date: '2022-01-01T00:00:00Z',
end_date: null,
description: 'Built critical systems.',
stack: ['React', 'TypeScript'],
order: 1,
},
{
id: '2',
collectionId: 'c1',
collectionName: 'experience',
created: '',
updated: '',
company: 'Beta Ltd',
role: 'Junior Developer',
start_date: '2020-01-01T00:00:00Z',
end_date: '2021-12-31T00:00:00Z',
description: 'Learned the ropes.',
stack: [],
order: 2,
},
];
const listResponse = (items: typeof mockItems) => ({
items,
page: 1,
perPage: 50,
totalItems: items.length,
totalPages: 1,
});
describe('ExperienceSection', () => {
beforeEach(() => {
vi.mocked(getCollection).mockResolvedValue(listResponse(mockItems) as never);
});
describe('rendering', () => {
it('renders a card for each experience record', async () => {
render(await ExperienceSection());
expect(screen.getByText('Senior Developer')).toBeInTheDocument();
expect(screen.getByText('Junior Developer')).toBeInTheDocument();
});
it('renders company names', async () => {
render(await ExperienceSection());
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
expect(screen.getByText('Beta Ltd')).toBeInTheDocument();
});
it('formats open-ended period as "Present"', async () => {
render(await ExperienceSection());
expect(screen.getByText('Jan 2022 — Present')).toBeInTheDocument();
});
it('formats closed period with month and year range', async () => {
render(await ExperienceSection());
expect(screen.getByText('Jan 2020 — Dec 2021')).toBeInTheDocument();
});
it('renders description text', async () => {
render(await ExperienceSection());
expect(screen.getByText('Built critical systems.')).toBeInTheDocument();
});
});
describe('empty state', () => {
it('renders empty container when no items', async () => {
vi.mocked(getCollection).mockResolvedValue(listResponse([]) as never);
const { container } = render(await ExperienceSection());
expect(container.firstChild).toBeEmptyDOMElement();
});
});
});
@@ -0,0 +1,30 @@
import { ExperienceCard } from '$entities/experience';
import type { ExperienceRecord } from '$shared/api';
import { getCollection } from '$shared/api';
import { formatMonthYearRange } from '$shared/lib';
/**
* Experience section component.
* Lists work history entries sorted by order field.
*/
export default async function ExperienceSection() {
const { items } = await getCollection<ExperienceRecord>('experience', {
sort: 'order',
tags: ['experience'],
});
return (
<div className="space-y-6 max-w-section">
{items.map((exp) => (
<ExperienceCard
key={exp.id}
title={exp.role}
company={exp.company}
period={formatMonthYearRange(exp.start_date, exp.end_date)}
description={exp.description}
stack={exp.stack}
/>
))}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More