Compare commits

...

131 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
Ilia Mashkov 41edc7edf7 refactor: reorganize shared/lib into per-function subfolders 2026-04-24 11:43:08 +03:00
Ilia Mashkov f3b4e1d064 refactor: extract contact links to shared config 2026-04-24 10:11:34 +03:00
Ilia Mashkov fce4672218 feat: add groupByKey utility 2026-04-24 09:34:27 +03:00
Ilia Mashkov d89dc2ee70 chore: add vitest/globals types, remove redundant vitest imports, fix pre-existing lint issues 2026-04-24 09:34:27 +03:00
Ilia Mashkov f0fccd55f1 fix: replace revalidate with cache force-cache for SSG compatibility 2026-04-24 09:34:27 +03:00
Ilia Mashkov 5dbf5e34c2 fix: mock next/font in tests and remove broken shared export 2026-04-23 21:01:46 +03:00
Ilia Mashkov 68d5de3716 chore: setup lefthook for pre-commit checks 2026-04-23 21:00:43 +03:00
Ilia Mashkov 1d333fd945 chore: format codebase and move SectionAccordion to entities/Section 2026-04-23 20:52:43 +03:00
Ilia Mashkov 8aff27f8ac chore: configure biome for linting and formatting 2026-04-23 20:40:11 +03:00
Ilia Mashkov 9c139adbf5 feat: define PocketBase types and fetch client 2026-04-23 20:35:46 +03:00
157 changed files with 5397 additions and 1843 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
+4
View File
@@ -20,6 +20,8 @@
# production
/build
/docs
# misc
.DS_Store
*.pem
@@ -52,6 +54,8 @@ next-env.d.ts
!/.vscode
!/.gitattributes
!/.gitignore
!/.dockerignore
!/.gitea
!/biome.json
*storybook.log
-21
View File
@@ -1,21 +0,0 @@
import type { Preview } from '@storybook/nextjs-vite'
import '../app/globals.css'
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: 'todo',
},
},
}
export default preview
+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

+12 -9
View File
@@ -1,21 +1,24 @@
import type { Metadata } from 'next'
import { fraunces, publicSans } from '$shared/lib'
import './globals.css'
import type { Metadata } from 'next';
import { fraunces, publicSans } from '$shared/lib';
import { Footer } from '$widgets/Footer';
import './globals.css';
export const metadata: Metadata = {
title: 'Portfolio',
description: 'Portfolio',
}
title: 'Ilia Mashkov — Portfolio',
description: 'Portfolio of Ilia Mashkov, a frontend software engineer.',
icons: { icon: '/favicon.svg' },
};
/**
* Root layout — injects font CSS variables used by theme.css
*/
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className={`${fraunces.variable} ${publicSans.variable}`}>
<html lang="en" data-scroll-behavior="smooth">
<body className={`${fraunces.variable} ${publicSans.variable} pb-footer md:pb-footer-wide`}>
{children}
<Footer />
</body>
</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>
);
}
-65
View File
@@ -1,65 +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>
);
}
+57
View File
@@ -0,0 +1,57 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.13/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": ["src/**/*", "app/**/*", "*.ts", "*.tsx", "*.js", "*.jsx", "*.json", "*.css"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 120
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"noUnusedVariables": "warn"
},
"style": {
"noNonNullAssertion": "warn",
"useBlockStatements": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"jsxQuoteStyle": "double",
"semicolons": "always",
"trailingCommas": "all"
}
},
"json": {
"formatter": {
"trailingCommas": "none"
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
pre-commit:
parallel: true
commands:
biome-check:
glob: "*.{js,ts,jsx,tsx,json,css}"
run: yarn biome check --write {staged_files}
stage_fixed: true
tests:
run: yarn test
+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 = {
output: 'export',
images: { unoptimized: true },
}
output: 'standalone',
poweredByHeader: false,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: pbPublicHost,
pathname: '/api/files/**',
},
],
},
experimental: {
viewTransition: true,
},
};
export default nextConfig
export default nextConfig;
+11 -1
View File
@@ -1,12 +1,19 @@
{
"name": "portfolio",
"version": "0.1.0",
"packageManager": "yarn@4.11.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"export": "STATIC_EXPORT=true next build",
"start": "next start",
"lint": "eslint",
"lint": "biome lint --write .",
"format": "biome format --write .",
"check": "biome check --write .",
"lint:ci": "biome lint .",
"format:ci": "biome format .",
"check:ci": "biome check .",
"test": "vitest run",
"test:watch": "vitest",
"storybook": "storybook dev -p 6006",
@@ -14,12 +21,14 @@
},
"dependencies": {
"clsx": "^2.1.1",
"html-react-parser": "^6.1.0",
"next": "16.2.4",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.13",
"@chromatic-com/storybook": "^5.1.2",
"@storybook/addon-a11y": "^10.3.5",
"@storybook/addon-docs": "^10.3.5",
@@ -40,6 +49,7 @@
"eslint-config-next": "16.2.4",
"eslint-plugin-storybook": "^10.3.5",
"jsdom": "^29.0.2",
"lefthook": "^2.1.6",
"playwright": "^1.59.1",
"storybook": "^10.3.5",
"tailwindcss": "^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

+2
View File
@@ -0,0 +1,2 @@
export * from './model/types';
export * from './ui';
+19
View File
@@ -0,0 +1,19 @@
import type { BaseRecord } from '$shared/api';
/**
* PocketBase collection for site sections and routing.
*/
export type SectionRecord = BaseRecord & {
/**
* URL-friendly identifier used for routing
*/
slug: string;
/**
* Display name of the section
*/
title: string;
/**
* Sorting weight for section order
*/
order: number;
};
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { SectionAccordion } from './SectionAccordion'
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { SectionAccordion } from './SectionAccordion';
const meta: Meta<typeof SectionAccordion> = {
title: 'Shared/SectionAccordion',
@@ -11,11 +11,11 @@ const meta: Meta<typeof SectionAccordion> = {
</div>
),
],
}
};
export default meta
export default meta;
type Story = StoryObj<typeof SectionAccordion>
type Story = StoryObj<typeof SectionAccordion>;
export const Active: Story = {
args: {
@@ -23,12 +23,10 @@ export const Active: Story = {
title: 'Biography',
id: 'bio',
isActive: true,
onClick: () => {},
children: (
<p>This is the expanded section content. It is visible because isActive is true.</p>
),
href: '/bio',
children: <p>This is the expanded section content. It is visible because isActive is true.</p>,
},
}
};
export const Collapsed: Story = {
args: {
@@ -36,9 +34,7 @@ export const Collapsed: Story = {
title: 'Work',
id: 'work',
isActive: false,
onClick: () => console.log('section clicked'),
children: (
<p>This content is hidden in collapsed state.</p>
),
href: '/work',
children: <p>This content is hidden in collapsed state.</p>,
},
}
};
@@ -0,0 +1,59 @@
import { render, screen } from '@testing-library/react';
import { SectionAccordion } from './SectionAccordion';
const defaultProps = {
number: '01',
title: 'About',
id: 'about',
isActive: false,
href: '/about',
children: <p>Content here</p>,
};
describe('SectionAccordion', () => {
describe('collapsed state (isActive=false)', () => {
it('renders a section element with the given id', () => {
const { container } = render(<SectionAccordion {...defaultProps} />);
expect(container.querySelector('section#about')).toBeInTheDocument();
});
it('renders a link with number and title', () => {
render(<SectionAccordion {...defaultProps} />);
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', () => {
render(<SectionAccordion {...defaultProps} />);
expect(screen.queryByText('Content here')).not.toBeInTheDocument();
});
it('does not render a button', () => {
render(<SectionAccordion {...defaultProps} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});
describe('active state (isActive=true)', () => {
const activeProps = { ...defaultProps, isActive: true };
it('renders an h1 with number and title', () => {
render(<SectionAccordion {...activeProps} />);
expect(screen.getByRole('heading', { level: 1, name: /01.*About/i })).toBeInTheDocument();
});
it('renders children', () => {
render(<SectionAccordion {...activeProps} />);
expect(screen.getByText('Content here')).toBeInTheDocument();
});
it('does not render a link', () => {
render(<SectionAccordion {...activeProps} />);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
});
});
@@ -0,0 +1,64 @@
import Link from 'next/link';
import type { ReactNode } from 'react';
import { ViewTransitionWrapper } from '$shared/ui';
interface SectionAccordionProps {
/**
* Display number prefix (e.g. "01")
*/
number: string;
/**
* Section title
*/
title: string;
/**
* HTML id for anchor navigation
*/
id: string;
/**
* Whether this section is expanded
*/
isActive: boolean;
/**
* Navigation URL for the collapsed heading link
*/
href: string;
/**
* Section content, shown when active
*/
children: ReactNode;
}
/**
* Accordion-style section that collapses to a navigation link when inactive.
*/
export function SectionAccordion({ number, title, id, isActive, href, children }: SectionAccordionProps) {
const heading = `${number}. ${title}`;
return (
<section id={id} className="scroll-mt-8">
{isActive ? (
<div className="mb-6 sm:mb-12">
<ViewTransitionWrapper name="section-title">
<div className="mb-6 sm:mb-12">
<h1 className="font-heading font-black text-xl sm:text-section-title leading-[1.2] mb-0">{heading}</h1>
</div>
</ViewTransitionWrapper>
<ViewTransitionWrapper name="section-body">
<div>{children}</div>
</ViewTransitionWrapper>
</div>
) : (
<Link
href={href}
aria-label={heading}
className="block w-full text-left mb-1 py-1 sm:mb-3 sm:py-3 group border-b-0 hover:border-b-0"
>
<span className="block font-heading font-wonk font-black text-xl sm:text-3xl opacity-30 group-hover:opacity-60 transition-opacity duration-200">
{heading}
</span>
</Link>
)}
</section>
);
}
@@ -0,0 +1 @@
export * from './SectionAccordion';
+1
View File
@@ -0,0 +1 @@
export * from './SectionAccordion';
+1 -1
View File
@@ -1 +1 @@
export { ExperienceCard } from './ui/ExperienceCard'
export { ExperienceCard } from './ui';
@@ -1,72 +0,0 @@
import { describe, it, expect } from 'vitest'
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,44 +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>
)
}
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { ExperienceCard } from './ExperienceCard'
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { ExperienceCard } from './ExperienceCard';
const meta: Meta<typeof ExperienceCard> = {
title: 'Entities/ExperienceCard',
@@ -11,30 +11,29 @@ const meta: Meta<typeof ExperienceCard> = {
</div>
),
],
}
};
export default meta
export default meta;
type Story = StoryObj<typeof ExperienceCard>
type Story = StoryObj<typeof ExperienceCard>;
const baseArgs = {
title: 'Senior Frontend Engineer',
company: 'Acme Corp',
period: '2021 2024',
description: 'Led frontend development for the core product, established design system practices, and mentored junior engineers across two distributed teams.',
}
description:
'Led frontend development for the core product, established design system practices, and mentored junior engineers across two distributed teams.',
stack: ['React', 'TypeScript', 'Next.js'],
};
export const Default: Story = {
args: baseArgs,
}
};
export const SlateBackground: Story = {
render: () => (
<div className="bg-slate-indigo p-8 max-w-2xl">
<ExperienceCard
{...baseArgs}
className="border-ochre-clay"
/>
<ExperienceCard {...baseArgs} className="border-ochre-clay" />
</div>
),
}
};
@@ -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';
+2 -2
View File
@@ -1,2 +1,2 @@
export * from './project'
export * from './experience'
export * from './experience';
export * from './project';
+1 -3
View File
@@ -1,3 +1 @@
export { ProjectMetadata } from './ui/ProjectMetadata'
export { ProjectCard } from './ui/ProjectCard'
export { DetailedProjectCard } from './ui/DetailedProjectCard'
export { DetailedProjectCard, ProjectCard, ProjectMetadata } from './ui';
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { DetailedProjectCard } from './DetailedProjectCard'
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { DetailedProjectCard } from './DetailedProjectCard';
const meta: Meta<typeof DetailedProjectCard> = {
title: 'Entities/DetailedProjectCard',
@@ -11,32 +11,33 @@ const meta: Meta<typeof DetailedProjectCard> = {
</div>
),
],
}
};
export default meta
export default meta;
type Story = StoryObj<typeof DetailedProjectCard>
type Story = StoryObj<typeof DetailedProjectCard>;
const baseArgs = {
title: 'Design System',
year: '2024',
role: 'Lead Frontend Engineer',
stack: ['React', 'TypeScript', 'Tailwind CSS', 'Storybook'],
description: 'A comprehensive design system built for a large-scale SaaS product, covering components, tokens, and documentation.',
description:
'A comprehensive design system built for a large-scale SaaS product, covering components, tokens, and documentation.',
details: [
'Established token system covering color, spacing, and typography.',
'Built 40+ accessible components with full test coverage.',
'Integrated Storybook for visual regression testing and documentation.',
],
}
};
export const Default: Story = {
args: baseArgs,
}
};
export const WithImage: Story = {
args: {
...baseArgs,
imageUrl: 'https://placehold.co/800x450/3B4A59/D9B48F?text=Project',
},
}
};
@@ -1,6 +1,5 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { DetailedProjectCard } from './DetailedProjectCard'
import { render, screen } from '@testing-library/react';
import { DetailedProjectCard } from './DetailedProjectCard';
const DEFAULT_PROPS = {
title: 'Big Project',
@@ -9,83 +8,82 @@ const DEFAULT_PROPS = {
stack: ['Vue', 'Go'],
description: 'A detailed project description',
details: ['First detail point', 'Second detail point'],
}
};
describe('DetailedProjectCard', () => {
describe('rendering', () => {
it('renders the project title', () => {
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
expect(screen.getByText('Big Project')).toBeInTheDocument()
})
render(<DetailedProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByText('Big Project')).toBeInTheDocument();
});
it('renders the description', () => {
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
expect(screen.getByText('A detailed project description')).toBeInTheDocument()
})
render(<DetailedProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByText('A detailed project description')).toBeInTheDocument();
});
it('renders each detail item', () => {
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
expect(screen.getByText('First detail point')).toBeInTheDocument()
expect(screen.getByText('Second detail point')).toBeInTheDocument()
})
render(<DetailedProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByText('First detail point')).toBeInTheDocument();
expect(screen.getByText('Second detail point')).toBeInTheDocument();
});
it('renders ProjectMetadata with year, role, and stack', () => {
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
expect(screen.getByText('2023')).toBeInTheDocument()
expect(screen.getByText('Lead Dev')).toBeInTheDocument()
expect(screen.getByText('Vue')).toBeInTheDocument()
expect(screen.getByText('Go')).toBeInTheDocument()
})
})
render(<DetailedProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByText('2023')).toBeInTheDocument();
expect(screen.getByText('Lead Dev')).toBeInTheDocument();
expect(screen.getByText('Vue')).toBeInTheDocument();
expect(screen.getByText('Go')).toBeInTheDocument();
});
});
describe('structure', () => {
it('outer grid has grid-cols-1 and lg:grid-cols-12', () => {
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} />)
expect(container.firstChild).toHaveClass('grid', 'grid-cols-1', 'lg:grid-cols-12')
})
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} />);
expect(container.firstChild).toHaveClass('grid', 'grid-cols-1', 'lg:grid-cols-12');
});
it('title is rendered as an h3', () => {
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Big Project')
})
render(<DetailedProjectCard {...DEFAULT_PROPS} />);
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Big Project');
});
it('detail items are rendered as <p> tags with text-base', () => {
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
const detail = screen.getByText('First detail point')
expect(detail.tagName).toBe('P')
expect(detail).toHaveClass('text-base')
})
render(<DetailedProjectCard {...DEFAULT_PROPS} />);
const detail = screen.getByText('First detail point');
expect(detail.tagName).toBe('P');
expect(detail).toHaveClass('text-base');
});
it('details list has brutal-border-top and pt-6', () => {
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
const detail = screen.getByText('First detail point')
const detailList = detail.parentElement
expect(detailList).toHaveClass('brutal-border-top', 'pt-6')
})
render(<DetailedProjectCard {...DEFAULT_PROPS} />);
const detail = screen.getByText('First detail point');
const detailList = detail.parentElement;
expect(detailList).toHaveClass('brutal-border-top', 'pt-6');
});
it('description has text-lg and mb-6', () => {
render(<DetailedProjectCard {...DEFAULT_PROPS} />)
const desc = screen.getByText('A detailed project description')
expect(desc).toHaveClass('text-lg', 'mb-6')
})
})
render(<DetailedProjectCard {...DEFAULT_PROPS} />);
const desc = screen.getByText('A detailed project description');
expect(desc).toHaveClass('text-lg', 'mb-6');
});
});
describe('conditional image rendering', () => {
it('does not render image when imageUrl is absent', () => {
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} />)
expect(container.querySelector('img')).toBeNull()
})
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} />);
expect(container.querySelector('img')).toBeNull();
});
it('renders image when imageUrl is provided', () => {
render(<DetailedProjectCard {...DEFAULT_PROPS} imageUrl="/detail.jpg" />)
const img = screen.getByRole('img')
expect(img).toHaveAttribute('src', '/detail.jpg')
})
render(<DetailedProjectCard {...DEFAULT_PROPS} imageUrl="/detail.jpg" />);
expect(screen.getByRole('img')).toBeInTheDocument();
});
it('image wrapper has aspect-video and brutal-border when imageUrl is provided', () => {
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} imageUrl="/detail.jpg" />)
const imgWrapper = container.querySelector('img')!.parentElement
expect(imgWrapper).toHaveClass('aspect-video', 'brutal-border')
})
})
})
const { container } = render(<DetailedProjectCard {...DEFAULT_PROPS} imageUrl="/detail.jpg" />);
const imgWrapper = container.querySelector('img')?.parentElement;
expect(imgWrapper).toHaveClass('aspect-video', 'brutal-border');
});
});
});
@@ -1,54 +1,47 @@
import { Card } from '$shared/ui'
import { ProjectMetadata } from './ProjectMetadata'
import Image from 'next/image';
import { Card, RichText } from '$shared/ui';
import { ProjectMetadata } from '../ProjectMetadata/ProjectMetadata';
type Props = {
export interface Props {
/**
* Project name
*/
title: string
title: string;
/**
* Year the project was completed
*/
year: string
year: string;
/**
* Developer role on the project
*/
role: string
role: string;
/**
* Technology stack list
*/
stack: string[]
stack: string[];
/**
* Project description paragraph
* Project description as HTML from the PocketBase rich-text editor
*/
description: string
description: string;
/**
* Bullet-style detail points listed below the description
*/
details: string[]
details: string[];
/**
* Optional hero image URL
*/
imageUrl?: string
imageUrl?: string;
/**
* Reverse layout (reserved for future use)
* @default false
*/
reverse?: boolean
reverse?: boolean;
}
/**
* Full-width detailed project card with metadata sidebar.
*/
export function DetailedProjectCard({
title,
year,
role,
stack,
description,
details,
imageUrl,
}: Props) {
export function DetailedProjectCard({ title, year, role, stack, description, details, imageUrl }: Props) {
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 mb-16">
<div className="lg:col-span-2 order-2 lg:order-1">
@@ -56,23 +49,25 @@ export function DetailedProjectCard({
</div>
<div className="lg:col-span-10 order-1 lg:order-2">
<Card background="white">
<Card>
<h3>{title}</h3>
<p className="text-lg mb-6">{description}</p>
<RichText html={description} className="text-lg mb-6" />
{imageUrl && (
<div className="brutal-border aspect-video bg-slate-indigo overflow-hidden">
<img src={imageUrl} alt={title} className="w-full h-full object-cover" />
<div className="brutal-border aspect-video bg-blue overflow-hidden relative">
<Image src={imageUrl} alt={title} fill className="object-cover" />
</div>
)}
<div className="max-w-[700px] space-y-4 brutal-border-top pt-6">
{details.map((detail, index) => (
<p key={index} className="text-base">{detail}</p>
{details.map((detail) => (
<p key={detail} className="text-base">
{detail}
</p>
))}
</div>
</Card>
</div>
</div>
)
);
}
@@ -1,79 +0,0 @@
import { describe, it, expect } from 'vitest'
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" />)
const img = screen.getByRole('img')
expect(img).toHaveAttribute('src', '/project.jpg')
})
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')
})
})
})
-68
View File
@@ -1,68 +0,0 @@
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, Button } from '$shared/ui'
import { cn } from '$shared/lib'
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">
<img src={imageUrl} alt={title} className="w-full h-full 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>
)
}
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { ProjectCard } from './ProjectCard'
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { ProjectCard } from './ProjectCard';
const meta: Meta<typeof ProjectCard> = {
title: 'Entities/ProjectCard',
@@ -11,11 +11,11 @@ const meta: Meta<typeof ProjectCard> = {
</div>
),
],
}
};
export default meta
export default meta;
type Story = StoryObj<typeof ProjectCard>
type Story = StoryObj<typeof ProjectCard>;
export const Default: Story = {
args: {
@@ -24,7 +24,7 @@ export const Default: Story = {
description: 'A brutalist portfolio site built with Next.js and Tailwind CSS.',
tags: ['React', 'TypeScript', 'Next.js'],
},
}
};
export const WithImage: Story = {
args: {
@@ -34,4 +34,4 @@ export const WithImage: Story = {
tags: ['React', 'TypeScript', 'Next.js'],
imageUrl: 'https://placehold.co/800x450/3B4A59/D9B48F?text=Project',
},
}
};
@@ -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>
);
}
@@ -1,96 +0,0 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ProjectMetadata } from './ProjectMetadata'
const DEFAULT_PROPS = {
year: '2024',
role: 'Frontend Engineer',
stack: ['React', 'TypeScript', 'Tailwind'],
}
describe('ProjectMetadata', () => {
describe('rendering', () => {
it('renders the year value', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />)
expect(screen.getByText('2024')).toBeInTheDocument()
})
it('renders the YEAR label', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />)
expect(screen.getByText('YEAR')).toBeInTheDocument()
})
it('renders the role value', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />)
expect(screen.getByText('Frontend Engineer')).toBeInTheDocument()
})
it('renders the ROLE label', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />)
expect(screen.getByText('ROLE')).toBeInTheDocument()
})
it('renders the STACK label', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />)
expect(screen.getByText('STACK')).toBeInTheDocument()
})
it('renders each stack technology', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />)
expect(screen.getByText('React')).toBeInTheDocument()
expect(screen.getByText('TypeScript')).toBeInTheDocument()
expect(screen.getByText('Tailwind')).toBeInTheDocument()
})
})
describe('structure', () => {
it('outer div has space-y-6', () => {
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />)
expect(container.firstChild).toHaveClass('space-y-6')
})
it('year section has no brutal-border-top (first section)', () => {
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />)
const sections = container.firstChild!.childNodes
expect(sections[0]).not.toHaveClass('brutal-border-top')
})
it('role section has brutal-border-top and pt-6', () => {
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />)
const sections = container.firstChild!.childNodes
expect(sections[1]).toHaveClass('brutal-border-top', 'pt-6')
})
it('stack section has brutal-border-top and pt-6', () => {
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />)
const sections = container.firstChild!.childNodes
expect(sections[2]).toHaveClass('brutal-border-top', 'pt-6')
})
it('label has text-xs uppercase tracking-wider opacity-60', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />)
const yearLabel = screen.getByText('YEAR')
expect(yearLabel).toHaveClass('text-xs', 'uppercase', 'tracking-wider', 'opacity-60')
})
it('year value has text-base font-bold', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />)
const yearValue = screen.getByText('2024')
expect(yearValue).toHaveClass('text-base', 'font-bold')
})
it('each stack tech is rendered as a <p> with text-sm', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />)
const techEl = screen.getByText('React')
expect(techEl.tagName).toBe('P')
expect(techEl).toHaveClass('text-sm')
})
})
describe('className passthrough', () => {
it('merges custom className onto outer div', () => {
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} className="my-custom" />)
expect(container.firstChild).toHaveClass('my-custom')
})
})
})
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { ProjectMetadata } from './ProjectMetadata'
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { ProjectMetadata } from './ProjectMetadata';
const meta: Meta<typeof ProjectMetadata> = {
title: 'Entities/ProjectMetadata',
@@ -11,11 +11,11 @@ const meta: Meta<typeof ProjectMetadata> = {
</div>
),
],
}
};
export default meta
export default meta;
type Story = StoryObj<typeof ProjectMetadata>
type Story = StoryObj<typeof ProjectMetadata>;
export const Default: Story = {
args: {
@@ -23,4 +23,4 @@ export const Default: Story = {
role: 'Lead Frontend Engineer',
stack: ['React', 'TypeScript', 'Next.js', 'Tailwind'],
},
}
};
@@ -0,0 +1,95 @@
import { render, screen } from '@testing-library/react';
import { ProjectMetadata } from './ProjectMetadata';
const DEFAULT_PROPS = {
year: '2024',
role: 'Frontend Engineer',
stack: ['React', 'TypeScript', 'Tailwind'],
};
describe('ProjectMetadata', () => {
describe('rendering', () => {
it('renders the year value', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />);
expect(screen.getByText('2024')).toBeInTheDocument();
});
it('renders the YEAR label', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />);
expect(screen.getByText('YEAR')).toBeInTheDocument();
});
it('renders the role value', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />);
expect(screen.getByText('Frontend Engineer')).toBeInTheDocument();
});
it('renders the ROLE label', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />);
expect(screen.getByText('ROLE')).toBeInTheDocument();
});
it('renders the STACK label', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />);
expect(screen.getByText('STACK')).toBeInTheDocument();
});
it('renders each stack technology', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />);
expect(screen.getByText('React')).toBeInTheDocument();
expect(screen.getByText('TypeScript')).toBeInTheDocument();
expect(screen.getByText('Tailwind')).toBeInTheDocument();
});
});
describe('structure', () => {
it('outer div has space-y-6', () => {
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />);
expect(container.firstChild).toHaveClass('space-y-6');
});
it('year section has no brutal-border-top (first section)', () => {
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />);
const sections = container.firstChild?.childNodes as NodeListOf<Element>;
expect(sections[0]).not.toHaveClass('brutal-border-top');
});
it('role section has brutal-border-top and pt-6', () => {
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />);
const sections = container.firstChild?.childNodes as NodeListOf<Element>;
expect(sections[1]).toHaveClass('brutal-border-top', 'pt-6');
});
it('stack section has brutal-border-top and pt-6', () => {
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} />);
const sections = container.firstChild?.childNodes as NodeListOf<Element>;
expect(sections[2]).toHaveClass('brutal-border-top', 'pt-6');
});
it('label has text-xs uppercase tracking-wider opacity-60', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />);
const yearLabel = screen.getByText('YEAR');
expect(yearLabel).toHaveClass('text-xs', 'uppercase', 'tracking-wider', 'opacity-60');
});
it('year value has text-base font-bold', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />);
const yearValue = screen.getByText('2024');
expect(yearValue).toHaveClass('text-base', 'font-bold');
});
it('each stack tech is rendered as a <p> with text-sm', () => {
render(<ProjectMetadata {...DEFAULT_PROPS} />);
const techEl = screen.getByText('React');
expect(techEl.tagName).toBe('P');
expect(techEl).toHaveClass('text-sm');
});
});
describe('className passthrough', () => {
it('merges custom className onto outer div', () => {
const { container } = render(<ProjectMetadata {...DEFAULT_PROPS} className="my-custom" />);
expect(container.firstChild).toHaveClass('my-custom');
});
});
});
@@ -1,22 +1,22 @@
import { cn } from '$shared/lib'
import { cn } from '$shared/lib';
type Props = {
export interface Props {
/**
* Project year
*/
year: string
year: string;
/**
* Developer role on the project
*/
role: string
role: string;
/**
* Technology stack list
*/
stack: string[]
stack: string[];
/**
* Additional CSS classes
*/
className?: string
className?: string;
}
/**
@@ -36,9 +36,11 @@ export function ProjectMetadata({ year, role, stack, className }: Props) {
<div className="brutal-border-top pt-6">
<p className="text-xs uppercase tracking-wider opacity-60">STACK</p>
{stack.map((tech) => (
<p key={tech} className="text-sm">{tech}</p>
<p key={tech} className="text-sm">
{tech}
</p>
))}
</div>
</div>
)
);
}
+3
View File
@@ -0,0 +1,3 @@
export { DetailedProjectCard } from './DetailedProjectCard/DetailedProjectCard';
export { ProjectCard } from './ProjectCard/ProjectCard';
export { ProjectMetadata } from './ProjectMetadata/ProjectMetadata';
+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';
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './client/client';
export * from './types';
+226
View File
@@ -0,0 +1,226 @@
/**
* Common properties for all PocketBase records.
*/
export type BaseRecord = {
/**
* Unique record ID
*/
id: string;
/**
* ID of the collection this record belongs to
*/
collectionId: string;
/**
* Name of the collection this record belongs to
*/
collectionName: string;
/**
* Record creation timestamp (ISO 8601)
*/
created: string;
/**
* Record last update timestamp (ISO 8601)
*/
updated: string;
};
/**
* PocketBase collection for simple text blocks (Intro, Bio).
* Each collection is named after its section — no slug field.
*/
export type PageContentRecord = BaseRecord & {
/**
* HTML or Markdown content string
*/
content: string;
};
/**
* PocketBase collection for technology skills.
*/
export type SkillRecord = BaseRecord & {
/**
* Name of the technology or tool
*/
name: string;
/**
* Grouping category (e.g., 'Frontend', 'Backend')
*/
category: 'Frontend' | 'Backend' | 'Tools' | 'Design' | string;
/**
* Sorting weight within the category
*/
order: number;
};
/**
* PocketBase collection for work experience history.
*/
export type ExperienceRecord = BaseRecord & {
/**
* Name of the organization
*/
company: string;
/**
* Professional title held
*/
role: string;
/**
* Start date of the tenure
*/
start_date: string;
/**
* End date of the tenure, or null if currently employed
*/
end_date: string | null;
/**
* Rich text description of responsibilities and achievements
*/
description: string;
/**
* Technologies used during this role
*/
stack: string[];
/**
* Sorting weight for chronological display
*/
order: number;
};
/**
* PocketBase collection for portfolio projects.
*/
export type ProjectRecord = BaseRecord & {
/**
* Full title of the project
*/
title: string;
/**
* Completion or duration year (e.g., "2024")
*/
year: string;
/**
* Role performed on the project
*/
role: string;
/**
* Project description as HTML from the PocketBase rich-text editor
*/
description: string;
/**
* List of specific feature or achievement points
*/
details: string[];
/**
* List of SkillRecord IDs used in the project
*/
stack: string[];
/**
* Primary thumbnail or hero image filename
*/
image: string;
/**
* Project's url
*/
url: string;
/**
* Sorting weight for the project list
*/
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.
*/
export type ListResponse<T> = {
/**
* Current page index
*/
page: number;
/**
* Number of items per page
*/
perPage: number;
/**
* Total number of items across all pages
*/
totalItems: number;
/**
* Total number of pages available
*/
totalPages: number;
/**
* Array of records for the current page
*/
items: T[];
};
+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';
+3 -2
View File
@@ -1,2 +1,3 @@
export * from './ui'
export * from './lib'
export * from './api';
export * from './lib';
export * from './ui';
+21
View File
@@ -0,0 +1,21 @@
/**
* Static contact and social links shown in navigation.
*/
export const CONTACT_LINKS = {
/**
* Primary contact email address
*/
email: 'hello@allmy.work',
/**
* LinkedIn profile URL
*/
linkedin: 'https://linkedin.com',
/**
* Instagram profile URL
*/
instagram: 'https://instagram.com',
/**
* Are.na profile URL
*/
arena: 'https://are.na',
} as const;
@@ -1,4 +1,4 @@
import { Fraunces, Public_Sans } from 'next/font/google'
import { Fraunces, Public_Sans } from 'next/font/google';
/**
* Heading font variable axes for brutalist variation settings
@@ -7,7 +7,7 @@ export const fraunces = Fraunces({
subsets: ['latin'],
variable: '--font-fraunces',
axes: ['opsz', 'SOFT', 'WONK'],
})
});
/**
* Body font
@@ -15,4 +15,4 @@ export const fraunces = Fraunces({
export const publicSans = Public_Sans({
subsets: ['latin'],
variable: '--font-public-sans',
})
});
+7 -3
View File
@@ -1,3 +1,7 @@
export { cn } from './cn'
export type { ClassValue } from 'clsx'
export * from './fonts'
export type { ClassValue } from 'clsx';
export { CONTACT_LINKS } from './config/config';
export * from './fonts/fonts';
export { buildFileUrl } from './utils/buildFileUrl/buildFileUrl';
export { cn } from './utils/cn/cn';
export * from './utils/formatDate/formatDate';
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,40 +1,39 @@
import { describe, it, expect } from 'vitest'
import { cn } from './cn'
import { cn } from './cn';
describe('cn', () => {
describe('basic merging', () => {
it('returns single class unchanged', () => {
expect(cn('foo')).toBe('foo')
})
expect(cn('foo')).toBe('foo');
});
it('joins multiple classes', () => {
expect(cn('foo', 'bar')).toBe('foo bar')
})
})
expect(cn('foo', 'bar')).toBe('foo bar');
});
});
describe('conditional classes', () => {
it('includes truthy conditional', () => {
expect(cn('foo', true && 'bar')).toBe('foo bar')
})
expect(cn('foo', true && 'bar')).toBe('foo bar');
});
it('excludes falsy conditional', () => {
expect(cn('foo', false && 'bar')).toBe('foo')
})
})
expect(cn('foo', false && 'bar')).toBe('foo');
});
});
describe('object syntax', () => {
it('includes classes with truthy object values', () => {
expect(cn({ foo: true, bar: false })).toBe('foo')
})
})
expect(cn({ foo: true, bar: false })).toBe('foo');
});
});
describe('tailwind conflict resolution', () => {
it('last padding wins', () => {
expect(cn('px-2', 'px-4')).toBe('px-4')
})
expect(cn('px-2', 'px-4')).toBe('px-4');
});
it('last text color wins', () => {
expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500')
})
})
})
expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500');
});
});
});
@@ -1,9 +1,9 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* Merges Tailwind classes, resolving conflicts in favor of the last value.
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}
@@ -0,0 +1,47 @@
import { formatMonthYearRange } from './formatDate';
describe('formatMonthYearRange', () => {
describe('open-ended range', () => {
it('formats start date with Present when end is null', () => {
expect(formatMonthYearRange('2022-01-01T00:00:00Z', null)).toBe('Jan 2022 — Present');
});
it('uses abbreviated month name', () => {
expect(formatMonthYearRange('2020-08-15T00:00:00Z', null)).toBe('Aug 2020 — Present');
});
});
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', () => {
expect(() => formatMonthYearRange('not-a-date', null)).toThrow('Invalid start date');
});
it('throws if end date is provided but invalid', () => {
expect(() => formatMonthYearRange('2024-01-01T00:00:00Z', 'invalid')).toThrow('Invalid end date');
});
it('throws if start is after end', () => {
expect(() => formatMonthYearRange('2024-01-01T00:00:00Z', '2020-01-01T00:00:00Z')).toThrow(
'Start date cannot be after end date',
);
});
it('throws on empty string', () => {
expect(() => formatMonthYearRange('', null)).toThrow('Invalid start date');
});
});
});
@@ -0,0 +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 month+year range or "Present".
* @throws {Error} if any date is invalid or if the range is logically impossible.
*/
export function formatMonthYearRange(start: string, end: string | null): string {
const startDate = new Date(start);
if (Number.isNaN(startDate.getTime())) {
throw new Error('Invalid start date');
}
if (end === null) {
return `${formatMonthYear(startDate)} — Present`;
}
const endDate = new Date(end);
if (Number.isNaN(endDate.getTime())) {
throw new Error('Invalid end date');
}
if (startDate > endDate) {
throw new Error('Start date cannot be after end date');
}
const startLabel = formatMonthYear(startDate);
const endLabel = formatMonthYear(endDate);
if (startLabel === endLabel) {
return startLabel;
}
return `${startLabel}${endLabel}`;
}
@@ -0,0 +1,54 @@
import { groupByKey } from './groupByKey';
describe('groupByKey', () => {
describe('basic grouping', () => {
it('groups items by a string key', () => {
const items = [
{ category: 'Frontend', name: 'React' },
{ category: 'Backend', name: 'Node' },
{ category: 'Frontend', name: 'Vue' },
];
expect(groupByKey(items, 'category')).toEqual({
Frontend: [
{ category: 'Frontend', name: 'React' },
{ category: 'Frontend', name: 'Vue' },
],
Backend: [{ category: 'Backend', name: 'Node' }],
});
});
it('preserves insertion order within each group', () => {
const items = [
{ category: 'A', order: 1 },
{ category: 'A', order: 2 },
];
expect(groupByKey(items, 'category').A).toEqual([
{ category: 'A', order: 1 },
{ category: 'A', order: 2 },
]);
});
});
describe('edge cases', () => {
it('returns empty object for empty array', () => {
expect(groupByKey<{ category: string }>([], 'category')).toEqual({});
});
it('handles all items in same group', () => {
const items = [
{ type: 'X', id: 1 },
{ type: 'X', id: 2 },
];
const result = groupByKey(items, 'type');
expect(Object.keys(result)).toHaveLength(1);
expect(result.X).toHaveLength(2);
});
it('handles single item', () => {
const items = [{ category: 'Only', name: 'One' }];
expect(groupByKey(items, 'category')).toEqual({
Only: [{ category: 'Only', name: 'One' }],
});
});
});
});
@@ -0,0 +1,19 @@
/**
* Groups an array of objects by a shared key into a record of arrays.
* @param items - Array of objects to group
* @param key - Key whose value determines the group
* @returns Record mapping each unique key value to an array of matching items
*/
export function groupByKey<T>(items: T[], key: keyof T): Record<string, T[]> {
return items.reduce(
(acc, item) => {
const k = String(item[key]);
if (!acc[k]) {
acc[k] = [];
}
acc[k].push(item);
return acc;
},
{} as Record<string, T[]>,
);
}
+358 -68
View File
@@ -2,7 +2,7 @@
/* === TYPOGRAPHY SCALE (Augmented Fourth 1.414) === */
--font-size: 16px;
--text-xs: 0.707rem;
--text-sm: 0.840rem;
--text-sm: 0.84rem;
--text-base: 1rem;
--text-lg: 1.414rem;
--text-xl: 2rem;
@@ -20,36 +20,38 @@
--font-weight-body: 600;
--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-tight: 1.2;
--line-height-normal: 1.5;
--line-height-relaxed: 1.65;
/* === FRAUNCES VARIABLE AXES === */
--fraunces-wonk: 1;
--fraunces-soft: 0;
/* === COLOR PALETTE === */
--ochre-clay: #D9B48F;
--slate-indigo: #3B4A59;
--burnt-oxide: #A64B35;
--carbon-black: #121212;
/* === COLOR PALETTE: 2-color system === */
--cream: #f4f0e8;
--blue: #041cf3;
/* === SEMANTIC COLORS === */
--background: var(--ochre-clay);
--foreground: var(--carbon-black);
--card: var(--ochre-clay);
--card-foreground: var(--carbon-black);
--primary: var(--burnt-oxide);
--primary-foreground: var(--ochre-clay);
--secondary: var(--slate-indigo);
--secondary-foreground: var(--ochre-clay);
--muted: var(--slate-indigo);
--muted-foreground: var(--ochre-clay);
--accent: var(--burnt-oxide);
--accent-foreground: var(--ochre-clay);
--destructive: #d4183d;
--border: var(--carbon-black);
--ring: var(--carbon-black);
--background: var(--cream);
--foreground: var(--blue);
--card: var(--cream);
--card-foreground: var(--blue);
--primary: var(--blue);
--primary-foreground: var(--cream);
--secondary: var(--cream);
--secondary-foreground: var(--blue);
--muted: var(--cream);
--muted-foreground: rgba(4, 28, 243, 0.5);
--accent: var(--blue);
--accent-foreground: var(--cream);
--destructive: var(--blue);
--border: var(--blue);
--ring: var(--blue);
/* === SPACING (8pt Linear Scale) === */
--space-0: 0;
@@ -71,22 +73,37 @@
--radius: 0px;
/* === BRUTALIST SHADOWS === */
--shadow-brutal: 8px 8px 0 var(--carbon-black);
--shadow-brutal-sm: 4px 4px 0 var(--carbon-black);
--shadow-brutal-lg: 12px 12px 0 var(--carbon-black);
--shadow-brutal-xs: 1px 1px 0 var(--blue);
--shadow-brutal-sm: 3px 3px 0 var(--blue);
--shadow-brutal: 5px 5px 0 var(--blue);
--shadow-brutal-md: 7px 7px 0 var(--blue);
--shadow-brutal-lg: 8px 8px 0 var(--blue);
--shadow-brutal-xl: 10px 10px 0 var(--blue);
--shadow-brutal-2xl: 12px 12px 0 var(--blue);
/* === GRID === */
--grid-gap: var(--space-3);
--section-content-width: 72rem;
/* === ANIMATION === */
--ease-default: ease;
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--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 {
--font-heading: var(--font-fraunces);
--font-body: var(--font-public-sans);
--color-ochre-clay: var(--ochre-clay);
--color-slate-indigo: var(--slate-indigo);
--color-burnt-oxide: var(--burnt-oxide);
--color-carbon-black: var(--carbon-black);
--color-cream: var(--cream);
--color-blue: var(--blue);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
@@ -105,6 +122,18 @@
--radius-sm: var(--radius);
--radius-md: 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 {
@@ -112,8 +141,22 @@
@apply border-border;
}
::selection {
background-color: var(--blue);
color: var(--cream);
}
:focus-visible {
outline: var(--border-width) solid var(--blue);
outline-offset: 2px;
}
html {
font-size: var(--font-size);
scroll-behavior: smooth;
/* Reserve scrollbar gutter so locking body scroll (e.g. when a modal
* opens) doesn't widen the viewport and shift fixed elements. */
scrollbar-gutter: stable;
}
body {
@@ -124,74 +167,321 @@
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 {
content: '';
@apply grain-pattern;
content: "";
position: fixed;
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;
z-index: 1;
z-index: 100;
}
h1, h2, h3, h4, h5, h6 {
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-heading);
font-weight: var(--font-weight-heading);
line-height: var(--line-height-tight);
font-variation-settings: 'WONK' var(--fraunces-wonk), 'SOFT' var(--fraunces-soft);
color: var(--carbon-black);
font-variation-settings:
"WONK" var(--fraunces-wonk),
"SOFT" var(--fraunces-soft);
color: var(--blue);
}
h1 { font-size: var(--text-4xl); }
h2 { font-size: var(--text-3xl); }
h3 { font-size: var(--text-2xl); }
h4 { font-size: var(--text-xl); }
h5 { font-size: var(--text-lg); }
h1 {
font-size: var(--text-4xl);
}
h2 {
font-size: var(--text-3xl);
}
h3 {
font-size: var(--text-2xl);
}
h4 {
font-size: var(--text-xl);
}
h5 {
font-size: var(--text-lg);
}
p {
font-family: var(--font-body);
font-size: var(--text-base);
font-weight: var(--font-weight-body);
color: var(--carbon-black);
color: var(--blue);
}
a {
color: var(--burnt-oxide);
color: var(--blue);
text-decoration: none;
border-bottom: 2px solid var(--carbon-black);
transition: all 0.2s;
}
a:hover {
border-bottom-width: 4px;
}
blockquote {
font-family: var(--font-heading);
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);
margin: var(--space-6) 0;
}
}
/* Brutalist utility classes */
.brutal-shadow { box-shadow: var(--shadow-brutal); }
.brutal-shadow-sm { box-shadow: var(--shadow-brutal-sm); }
.brutal-shadow-lg { box-shadow: var(--shadow-brutal-lg); }
.brutal-border { border: var(--border-width) solid var(--carbon-black); }
.brutal-border-top { border-top: var(--border-width) solid var(--carbon-black); }
.brutal-border-bottom { border-bottom: var(--border-width) solid var(--carbon-black); }
.brutal-border-left { border-left: var(--border-width) solid var(--carbon-black); }
.brutal-border-right { border-right: var(--border-width) solid var(--carbon-black); }
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
/* Button elevation transition — only transform animates; shadow snaps instantly */
.btn-transition {
transition: transform 0.13s var(--ease-micro);
}
/* Brutalist utility classes */
.brutal-shadow {
box-shadow: var(--shadow-brutal);
}
.brutal-shadow-sm {
box-shadow: var(--shadow-brutal-sm);
}
.brutal-shadow-lg {
box-shadow: var(--shadow-brutal-lg);
}
@utility brutal-border {
border: var(--border-width) solid var(--blue);
}
@utility brutal-border-top {
border-top: var(--border-width) solid var(--blue);
}
@utility brutal-border-bottom {
border-bottom: var(--border-width) solid var(--blue);
}
@utility brutal-border-left {
border-left: var(--border-width) solid var(--blue);
}
@utility brutal-border-right {
border-right: var(--border-width) solid var(--blue);
}
/* Border drawn as an outline — painted after children, so an image's
* subpixel paint bleed can't cover it. Doesn't take layout space; the
* ancestor must not have overflow:hidden or the outline gets clipped. */
@utility brutal-outline {
outline: var(--border-width) solid var(--blue);
}
/* Tiled blue dot pattern — applied to body::before (page-wide) and reusable
* on any surface that should share the same paper-grain texture. The SVG
* tile is rasterized once and composited cheaply via repeating background. */
@utility grain-pattern {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Ccircle cx='1' cy='1' r='1' fill='%23041cf3' opacity='0.10'/%3E%3C/svg%3E");
}
/* Apply Fraunces variable axes to non-heading elements using the heading font */
.font-wonk {
font-variation-settings:
"WONK" var(--fraunces-wonk),
"SOFT" var(--fraunces-soft);
}
/* Sidebar divider: bottom border on mobile, right border on desktop */
.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 {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Disable group geometry interpolation — OLD and NEW live at different scroll
* positions, so morphing the container drags the slide-in across the viewport.
* Let old/new each animate at their own positions instead. */
::view-transition-group(section-body) {
animation: none;
}
/* Section body — snap OLD out and slide NEW in. Running an explicit OLD
* animation left a visible ghost of the previous section's content sitting
* behind the incoming slide; hiding it immediately keeps the transition
* clean and prevents the two-content overlap. */
::view-transition-old(section-body) {
animation: none;
opacity: 0;
}
::view-transition-new(section-body) {
animation-name: section-body-in;
animation-duration: var(--duration-spring);
animation-timing-function: var(--ease-spring);
animation-fill-mode: both;
/* Hold the start-state for this delay before the slide-in begins — gives
* the snap-out a beat to register visually before new content arrives. */
animation-delay: var(--delay-normal);
}
@keyframes section-body-in {
from {
opacity: 0;
transform: translateX(var(--slide-section-body-in)) scale(0.98);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
/* Page-load entry animation for the footer. Previously also carried
* `view-transition-name: site-footer` to layer above section-body's slide
* group, but the section-body group now has `animation: none` (purely
* horizontal slide of OLD/NEW snapshots), so the footer can live in the root
* snapshot during transitions — which also lets the lightbox dialog group
* stack cleanly above it. */
.footer-vt {
animation: footer-enter var(--duration-slow) var(--ease-spring) both;
}
@keyframes footer-enter {
from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Lightbox dialog backdrop — flat cream wash. No filter, no gradient.
* Cheapest possible; Firefox-friendly during view transitions. */
dialog.lightbox::backdrop {
background-color: color-mix(in srgb, var(--cream) 80%, transparent);
}
/* Give the lightbox dialog its own view-transition group so it can stack
* above the footer's named group (z=10) during the open/close transition.
* Closed dialogs have `display: none` (UA) and don't get snapshotted, so
* sharing the name across all `.lightbox` dialogs at rest is harmless —
* only the open one participates in any given transition. */
dialog.lightbox {
view-transition-name: lightbox-dialog;
}
::view-transition-group(lightbox-dialog) {
z-index: 20;
}
/* The image wrapper (and the thumb during the OLD snapshot of an open
* transition) shares this static name — imperatively assigned only during
* the morph, so there's never more than one element holding it at a time.
* z=30 keeps it above the dialog (z=20) and the footer (z=10) during VT. */
::view-transition-group(lightbox-frame) {
z-index: 30;
}
/* Lightbox image sizing — leaves vertical headroom for the fixed close button
* (sits at top-3, ~2.5rem tall). 8rem total = ~4rem top/bottom after centering,
* so the button never overlaps the image.
* box-sizing: content-box so max-w/max-h apply to the image pixels and the
* 3px brutal-border sits outside them — avoids subpixel clipping of the
* border by the dialog's overflow:hidden when both have border-box. */
@utility lightbox-image {
box-sizing: content-box;
max-width: calc(100vw - 2rem);
max-height: calc(100vh - 10rem);
}
/* Disable transition animation for Firefox
* since it isn't supported yet */
@supports (-moz-appearance: none) {
::view-transition-group(lightbox-frame),
::view-transition-old(lightbox-frame),
::view-transition-new(lightbox-frame) {
animation-duration: 0s !important;
animation-delay: 0s !important;
}
}
.animate-fadeIn { animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1); }
+2 -2
View File
@@ -1,2 +1,2 @@
export { Badge } from './ui/Badge'
export type { BadgeVariant } from './ui/Badge'
export type { BadgeSize, BadgeVariant } from './ui/Badge';
export { Badge } from './ui/Badge';
+6 -6
View File
@@ -1,14 +1,14 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { Badge } from './Badge'
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { Badge } from './Badge';
const meta: Meta<typeof Badge> = {
title: 'Shared/Badge',
component: Badge,
}
};
export default meta
export default meta;
type Story = StoryObj<typeof Badge>
type Story = StoryObj<typeof Badge>;
export const AllVariants: Story = {
render: () => (
@@ -19,4 +19,4 @@ export const AllVariants: Story = {
<Badge variant="outline">Outline</Badge>
</div>
),
}
};
+53 -32
View File
@@ -1,52 +1,73 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Badge } from './Badge'
import { render, screen } from '@testing-library/react';
import { Badge } from './Badge';
describe('Badge', () => {
describe('rendering', () => {
it('renders children', () => {
render(<Badge>React</Badge>)
expect(screen.getByText('React')).toBeInTheDocument()
})
render(<Badge>React</Badge>);
expect(screen.getByText('React')).toBeInTheDocument();
});
it('renders as inline span', () => {
render(<Badge>Tag</Badge>)
expect(screen.getByText('Tag').tagName).toBe('SPAN')
})
})
render(<Badge>Tag</Badge>);
expect(screen.getByText('Tag').tagName).toBe('SPAN');
});
});
describe('variants', () => {
it('applies default variant classes', () => {
render(<Badge variant="default">Tag</Badge>)
const el = screen.getByText('Tag')
expect(el).toHaveClass('bg-carbon-black', 'text-ochre-clay')
})
render(<Badge variant="default">Tag</Badge>);
const el = screen.getByText('Tag');
expect(el).toHaveClass('bg-blue', 'text-cream');
});
it('applies primary variant classes', () => {
render(<Badge variant="primary">Tag</Badge>)
expect(screen.getByText('Tag')).toHaveClass('bg-burnt-oxide')
})
render(<Badge variant="primary">Tag</Badge>);
expect(screen.getByText('Tag')).toHaveClass('bg-blue');
});
it('applies secondary variant classes', () => {
render(<Badge variant="secondary">Tag</Badge>)
expect(screen.getByText('Tag')).toHaveClass('bg-slate-indigo')
})
render(<Badge variant="secondary">Tag</Badge>);
expect(screen.getByText('Tag')).toHaveClass('bg-blue');
});
it('applies outline variant classes', () => {
render(<Badge variant="outline">Tag</Badge>)
expect(screen.getByText('Tag')).toHaveClass('bg-transparent')
})
render(<Badge variant="outline">Tag</Badge>);
expect(screen.getByText('Tag')).toHaveClass('bg-transparent');
});
it('defaults to default variant when unspecified', () => {
render(<Badge>Tag</Badge>)
expect(screen.getByText('Tag')).toHaveClass('bg-carbon-black')
})
})
render(<Badge>Tag</Badge>);
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');
});
});
describe('className passthrough', () => {
it('merges custom className', () => {
render(<Badge className="mt-4">Tag</Badge>)
expect(screen.getByText('Tag')).toHaveClass('mt-4')
})
})
})
render(<Badge className="mt-4">Tag</Badge>);
expect(screen.getByText('Tag')).toHaveClass('mt-4');
});
});
});
+26 -14
View File
@@ -1,38 +1,50 @@
import type { ReactNode } from 'react'
import { cn } from '$shared/lib'
import type { ReactNode } from 'react';
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 {
/**
* Badge content
*/
children: ReactNode
children: ReactNode;
/**
* Visual variant
* @default 'default'
*/
variant?: BadgeVariant
variant?: BadgeVariant;
/**
* Size preset
* @default 'sm'
*/
size?: BadgeSize;
/**
* Additional CSS classes
*/
className?: string
className?: string;
}
const VARIANTS: Record<BadgeVariant, string> = {
default: 'brutal-border bg-carbon-black text-ochre-clay',
primary: 'brutal-border bg-burnt-oxide text-ochre-clay',
secondary: 'brutal-border bg-slate-indigo text-ochre-clay',
outline: 'brutal-border bg-transparent text-carbon-black',
}
default: 'brutal-border bg-blue text-cream',
primary: 'brutal-border bg-blue text-cream',
secondary: 'brutal-border bg-blue text-cream',
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.
*/
export function Badge({ children, variant = 'default', className }: Props) {
export function Badge({ children, variant = 'default', size = 'sm', className }: Props) {
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}
</span>
)
);
}
+2 -2
View File
@@ -1,2 +1,2 @@
export { Button } from './ui/Button'
export type { ButtonVariant, ButtonSize } from './ui/Button'
export type { ButtonSize, ButtonVariant } from './ui/Button';
export { Button } from './ui/Button';
+29 -15
View File
@@ -1,35 +1,49 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { Button } from './Button'
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Shared/Button',
component: Button,
}
};
export default meta
export default meta;
type Story = StoryObj<typeof Button>
type Story = StoryObj<typeof Button>;
export const AllVariants: Story = {
render: () => (
<div className="flex gap-4 flex-wrap p-8 bg-ochre-clay">
<Button variant="primary" size="md">Primary</Button>
<Button variant="secondary" size="md">Secondary</Button>
<Button variant="outline" size="md">Outline</Button>
<Button variant="ghost" size="md">Ghost</Button>
<Button variant="primary" size="md">
Primary
</Button>
<Button variant="secondary" size="md">
Secondary
</Button>
<Button variant="outline" size="md">
Outline
</Button>
<Button variant="ghost" size="md">
Ghost
</Button>
</div>
),
}
};
export const Sizes: Story = {
render: () => (
<div className="flex gap-4 items-center flex-wrap p-8 bg-ochre-clay">
<Button variant="primary" size="sm">Small</Button>
<Button variant="primary" size="md">Medium</Button>
<Button variant="primary" size="lg">Large</Button>
<Button variant="primary" size="sm">
Small
</Button>
<Button variant="primary" size="md">
Medium
</Button>
<Button variant="primary" size="lg">
Large
</Button>
</div>
),
}
};
export const Disabled: Story = {
args: {
@@ -44,4 +58,4 @@ export const Disabled: Story = {
</div>
),
],
}
};
+74 -48
View File
@@ -1,67 +1,93 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from './Button'
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
describe('Button', () => {
describe('rendering', () => {
it('renders children', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
})
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
it('renders as button element', () => {
render(<Button>Click</Button>)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
render(<Button>Click</Button>);
expect(screen.getByRole('button')).toBeInTheDocument();
});
});
describe('variants', () => {
it('applies primary variant by default', () => {
render(<Button>Go</Button>)
expect(screen.getByRole('button')).toHaveClass('bg-burnt-oxide')
})
render(<Button>Go</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-blue');
});
it('applies secondary variant', () => {
render(<Button variant="secondary">Go</Button>)
expect(screen.getByRole('button')).toHaveClass('bg-slate-indigo')
})
render(<Button variant="secondary">Go</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-blue');
});
it('applies outline variant', () => {
render(<Button variant="outline">Go</Button>)
expect(screen.getByRole('button')).toHaveClass('bg-transparent')
})
render(<Button variant="outline">Go</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-cream');
});
it('applies ghost variant', () => {
render(<Button variant="ghost">Go</Button>)
expect(screen.getByRole('button')).toHaveClass('bg-ochre-clay')
})
})
render(<Button variant="ghost">Go</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-transparent');
});
});
describe('sizes', () => {
it('applies md size by default', () => {
render(<Button>Go</Button>)
expect(screen.getByRole('button')).toHaveClass('px-6', 'py-3')
})
render(<Button>Go</Button>);
expect(screen.getByRole('button')).toHaveClass('px-6', 'py-3');
});
it('applies sm size', () => {
render(<Button size="sm">Go</Button>)
expect(screen.getByRole('button')).toHaveClass('px-4', 'py-2')
})
render(<Button size="sm">Go</Button>);
expect(screen.getByRole('button')).toHaveClass('px-3', 'py-1.5', 'sm:px-4', 'sm:py-2');
});
it('applies lg size', () => {
render(<Button size="lg">Go</Button>)
expect(screen.getByRole('button')).toHaveClass('px-8', 'py-4')
})
})
render(<Button size="lg">Go</Button>);
expect(screen.getByRole('button')).toHaveClass('px-8', 'py-4');
});
});
describe('interactions', () => {
it('calls onClick when clicked', async () => {
const onClick = vi.fn()
render(<Button onClick={onClick}>Go</Button>)
await userEvent.click(screen.getByRole('button'))
expect(onClick).toHaveBeenCalledOnce()
})
const onClick = vi.fn();
render(<Button onClick={onClick}>Go</Button>);
await userEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledOnce();
});
it('is disabled when disabled prop is set', () => {
render(<Button disabled>Go</Button>)
expect(screen.getByRole('button')).toBeDisabled()
})
})
render(<Button disabled>Go</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
});
describe('className passthrough', () => {
it('merges custom className', () => {
render(<Button className="w-full">Go</Button>)
expect(screen.getByRole('button')).toHaveClass('w-full')
})
})
})
render(<Button className="w-full">Go</Button>);
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');
});
});
});
+57 -20
View File
@@ -1,48 +1,85 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import { cn } from '$shared/lib'
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from '$shared/lib';
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost'
export type ButtonSize = 'sm' | 'md' | 'lg'
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
export type ButtonSize = 'sm' | 'md' | 'lg';
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
type BaseProps = {
/**
* Visual variant
* @default 'primary'
*/
variant?: ButtonVariant
variant?: ButtonVariant;
/**
* Size preset
* @default 'md'
*/
size?: ButtonSize
size?: ButtonSize;
/**
* 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> = {
primary: 'bg-burnt-oxide text-ochre-clay',
secondary: 'bg-slate-indigo text-ochre-clay',
outline: 'bg-transparent text-carbon-black border-carbon-black',
ghost: 'bg-ochre-clay text-carbon-black border-carbon-black',
}
const VARIANTS = {
primary:
'brutal-border bg-blue text-cream shadow-[5px_5px_0_rgba(4,28,243,0.35)] hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-[7px_7px_0_rgba(4,28,243,0.35)] active:translate-x-0.5 active:translate-y-0.5 active:shadow-[1px_1px_0_rgba(4,28,243,0.35)]',
secondary:
'brutal-border bg-blue text-cream shadow-brutal hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-brutal-md active:translate-x-0.5 active:translate-y-0.5 active:shadow-brutal-xs',
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> = {
sm: 'px-4 py-2 text-sm',
const SIZES = {
sm: 'px-3 py-1.5 sm:px-4 sm:py-2 text-sm',
md: 'px-6 py-3 text-base',
lg: 'px-8 py-4 text-lg',
}
} as const satisfies Record<ButtonSize, string>;
const BASE = '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'
/* box-shadow excluded from transition intentionally — snaps instantly so the
* eye follows the 130ms button movement, not the shadow change. */
const BASE = 'cursor-pointer btn-transition uppercase tracking-wider';
/**
* 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) {
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 (
<button className={cn(BASE, VARIANTS[variant], SIZES[size], className)} {...props}>
<button className={cls} {...props}>
{children}
</button>
)
);
}
+2 -2
View File
@@ -1,2 +1,2 @@
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './ui/Card'
export type { CardBackground } from './ui/Card'
export type { CardBackground } from './ui/Card';
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './ui/Card';
+17 -26
View File
@@ -1,59 +1,50 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './Card';
const meta: Meta<typeof Card> = {
title: 'Shared/Card',
component: Card,
}
};
export default meta
export default meta;
type Story = StoryObj<typeof Card>
type Story = StoryObj<typeof Card>;
export const AllBackgrounds: Story = {
render: () => (
<div className="flex gap-6 flex-wrap p-8 bg-white">
<Card background="ochre" className="w-64">
<Card background="cream" className="w-64">
<CardHeader>
<CardTitle>Ochre Card</CardTitle>
<CardDescription>Background ochre-clay variant</CardDescription>
<CardTitle>Cream Card</CardTitle>
<CardDescription>Default cream background variant</CardDescription>
</CardHeader>
<CardFooter>Footer content</CardFooter>
</Card>
<Card background="slate" className="w-64">
<Card background="blue" className="w-64">
<CardHeader>
<CardTitle>Slate Card</CardTitle>
<CardDescription>Background slate-indigo variant</CardDescription>
</CardHeader>
<CardFooter>Footer content</CardFooter>
</Card>
<Card background="white" className="w-64">
<CardHeader>
<CardTitle>White Card</CardTitle>
<CardDescription>Background white variant</CardDescription>
<CardTitle>Blue Card</CardTitle>
<CardDescription>Blue background variant</CardDescription>
</CardHeader>
<CardFooter>Footer content</CardFooter>
</Card>
</div>
),
}
};
export const NoPadding: Story = {
render: () => (
<div className="p-8 bg-ochre-clay">
<div className="p-8">
<Card noPadding className="w-64 overflow-hidden">
<div className="h-40 bg-slate-indigo flex items-center justify-center text-ochre-clay">
Image placeholder
</div>
<div className="h-40 bg-blue flex items-center justify-center">Image placeholder</div>
</Card>
</div>
),
}
};
export const FullComposition: Story = {
render: () => (
<div className="p-8 bg-white max-w-md">
<Card background="ochre">
<Card background="cream">
<CardHeader>
<CardTitle>Full Composition</CardTitle>
<CardDescription>A card using all available slot components</CardDescription>
@@ -67,4 +58,4 @@ export const FullComposition: Story = {
</Card>
</div>
),
}
};
+101 -58
View File
@@ -1,79 +1,122 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card'
import { render, screen } from '@testing-library/react';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './Card';
describe('Card', () => {
describe('rendering', () => {
it('renders children', () => {
render(<Card>Content</Card>)
expect(screen.getByText('Content')).toBeInTheDocument()
})
render(<Card>Content</Card>);
expect(screen.getByText('Content')).toBeInTheDocument();
});
it('has brutal-border and brutal-shadow classes', () => {
const { container } = render(<Card>Content</Card>)
expect(container.firstChild).toHaveClass('brutal-border', 'brutal-shadow')
})
})
const { container } = render(<Card>Content</Card>);
expect(container.firstChild).toHaveClass('brutal-border', 'brutal-shadow');
});
});
describe('background variants', () => {
it('defaults to ochre background', () => {
const { container } = render(<Card>Content</Card>)
expect(container.firstChild).toHaveClass('bg-ochre-clay')
})
it('applies slate background', () => {
const { container } = render(<Card background="slate">Content</Card>)
expect(container.firstChild).toHaveClass('bg-slate-indigo')
})
it('applies white background', () => {
const { container } = render(<Card background="white">Content</Card>)
expect(container.firstChild).toHaveClass('bg-white')
})
})
it('defaults to cream background', () => {
const { container } = render(<Card>Content</Card>);
expect(container.firstChild).toHaveClass('bg-cream');
});
it('applies blue background', () => {
const { container } = render(<Card background="blue">Content</Card>);
expect(container.firstChild).toHaveClass('bg-blue');
});
});
describe('padding', () => {
it('has default padding', () => {
const { container } = render(<Card>Content</Card>)
expect(container.firstChild).toHaveClass('p-6')
})
const { container } = render(<Card>Content</Card>);
expect(container.firstChild).toHaveClass('p-6');
});
it('removes padding when noPadding is true', () => {
const { container } = render(<Card noPadding>Content</Card>)
expect(container.firstChild).not.toHaveClass('p-6')
})
})
const { container } = render(<Card noPadding>Content</Card>);
expect(container.firstChild).not.toHaveClass('p-6');
});
});
describe('className passthrough', () => {
it('merges custom className', () => {
const { container } = render(<Card className="group">Content</Card>)
expect(container.firstChild).toHaveClass('group')
})
})
})
const { container } = render(<Card className="group">Content</Card>);
expect(container.firstChild).toHaveClass('group');
});
});
});
describe('CardHeader', () => {
it('renders children with bottom margin', () => {
render(<CardHeader>Header</CardHeader>)
expect(screen.getByText('Header')).toHaveClass('mb-4')
})
})
render(<CardHeader>Header</CardHeader>);
expect(screen.getByText('Header')).toHaveClass('mb-6');
});
});
describe('CardTitle', () => {
it('renders children as h3', () => {
render(<CardTitle>Title</CardTitle>)
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Title')
})
})
render(<CardTitle>Title</CardTitle>);
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('Title');
});
});
describe('CardDescription', () => {
it('renders children as paragraph with opacity', () => {
render(<CardDescription>Desc</CardDescription>)
const el = screen.getByText('Desc')
expect(el.tagName).toBe('P')
expect(el).toHaveClass('opacity-80')
})
})
render(<CardDescription>Desc</CardDescription>);
const el = screen.getByText('Desc');
expect(el.tagName).toBe('P');
expect(el).toHaveClass('opacity-80');
});
});
describe('CardContent', () => {
it('renders children in a div', () => {
render(<CardContent>Body</CardContent>)
expect(screen.getByText('Body')).toBeInTheDocument()
})
})
render(<CardContent>Body</CardContent>);
expect(screen.getByText('Body')).toBeInTheDocument();
});
});
describe('CardFooter', () => {
it('renders children with top border', () => {
render(<CardFooter>Footer</CardFooter>)
const el = screen.getByText('Footer')
expect(el).toHaveClass('brutal-border-top', 'mt-6', 'pt-6')
})
})
render(<CardFooter>Footer</CardFooter>);
const el = screen.getByText('Footer');
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');
});
});
});
+49 -21
View File
@@ -1,88 +1,116 @@
import type { ReactNode } from 'react'
import { cn } from '$shared/lib'
import type { ReactNode } from 'react';
import { cn } from '$shared/lib';
export type CardBackground = 'ochre' | 'slate' | 'white'
export type CardBackground = 'cream' | 'blue';
interface CardProps {
/**
* Card content
*/
children: ReactNode
children: ReactNode;
/**
* Additional CSS classes
*/
className?: string
className?: string;
/**
* Background color preset
* @default 'ochre'
* @default 'cream'
*/
background?: CardBackground
background?: CardBackground;
/**
* Remove default padding
* @default false
*/
noPadding?: boolean
noPadding?: boolean;
}
const BG: Record<CardBackground, string> = {
ochre: 'bg-ochre-clay',
slate: 'bg-slate-indigo text-ochre-clay',
white: 'bg-white',
}
cream: 'bg-cream',
blue: 'bg-blue text-cream',
};
/**
* 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 (
<div className={cn('brutal-border brutal-shadow', BG[background], !noPadding && 'p-6 md:p-8', className)}>
{children}
</div>
)
);
}
interface SlotProps {
/**
* Slot content
*/
children: ReactNode
children: ReactNode;
/**
* Additional CSS classes
*/
className?: string
className?: string;
}
/**
* Card header wrapper — adds bottom margin.
*/
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>;
}
/**
* Card title — renders as h3.
*/
export function CardTitle({ children, className }: SlotProps) {
return <h3 className={className}>{children}</h3>
return <h3 className={className}>{children}</h3>;
}
/**
* Card description — muted paragraph below the title.
*/
export function CardDescription({ children, className }: SlotProps) {
return <p className={cn('mt-2 opacity-80', className)}>{children}</p>
return <p className={cn('mt-2 opacity-80', className)}>{children}</p>;
}
/**
* Card body content area.
*/
export function CardContent({ children, className }: SlotProps) {
return <div className={className}>{children}</div>
return <div className={className}>{children}</div>;
}
/**
* Card footer — separated by a brutal border-top.
*/
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 border→children→outline, so an `outline` is drawn ON TOP
* of any subpixel image bleed and stays fully visible. The dialog
* uses `overflow-visible` because outline can be clipped by an
* ancestor's overflow:hidden.
* The wrapper is the named VT element so the brutalist frame
* participates in the morph.
* aria-hidden: the dialog element itself carries the accessible label. */}
<div ref={dialogFrameRef} className="brutal-outline block w-fit">
{/* Explicit width/height are placeholders next/image requires when not using `fill`; CSS
* (`lightbox-image` max-w/max-h + `w-auto h-auto`) drives the actual rendered size, so
* the dialog still hugs the image's intrinsic dimensions (capped at viewport bounds).
* `sizes="100vw"` hints the browser to fetch the srcset variant matching viewport width. */}
<Image
src={src}
alt={alt}
width={2000}
height={2000}
loading="lazy"
sizes="100vw"
aria-hidden={true}
className="lightbox-image block w-auto h-auto"
/>
</div>
<Button variant="outline" size="sm" onClick={close} aria-label="Close image" className="fixed top-3 right-3">
<CloseIcon />
</Button>
</Modal>
</>
);
}
+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>;
}
+1 -1
View File
@@ -1 +1 @@
export { Input, Textarea } from './ui/Input'
export { Input, Textarea } from './ui/Input';
+11 -11
View File
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { Input, Textarea } from './Input'
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { Input, Textarea } from './Input';
const meta: Meta<typeof Input> = {
title: 'Shared/Input',
@@ -11,35 +11,35 @@ const meta: Meta<typeof Input> = {
</div>
),
],
}
};
export default meta
export default meta;
type Story = StoryObj<typeof Input>
type Story = StoryObj<typeof Input>;
export const Default: Story = {
args: {},
}
};
export const WithLabel: Story = {
args: {
label: 'Email address',
},
}
};
export const WithError: Story = {
args: {
label: 'Email',
error: 'This field is required',
},
}
};
export const WithPlaceholder: Story = {
args: {
placeholder: 'Enter your email',
type: 'email',
},
}
};
export const TextareaStory: Story = {
name: 'Textarea',
@@ -48,7 +48,7 @@ export const TextareaStory: Story = {
<Textarea label="Message" rows={4} />
</div>
),
}
};
export const TextareaWithError: Story = {
render: () => (
@@ -56,4 +56,4 @@ export const TextareaWithError: Story = {
<Textarea label="Message" error="Too short" rows={4} />
</div>
),
}
};
+79 -80
View File
@@ -1,110 +1,109 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Input, Textarea } from './Input'
import { render, screen } from '@testing-library/react';
import { Input, Textarea } from './Input';
describe('Input', () => {
describe('rendering', () => {
it('renders an input element', () => {
render(<Input />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
render(<Input />);
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
it('renders label when provided', () => {
render(<Input label="Email" />)
expect(screen.getByText('Email')).toBeInTheDocument()
})
render(<Input label="Email" />);
expect(screen.getByText('Email')).toBeInTheDocument();
});
it('does not render label when omitted', () => {
const { container } = render(<Input />)
expect(container.querySelector('label')).toBeNull()
})
const { container } = render(<Input />);
expect(container.querySelector('label')).toBeNull();
});
it('renders error message when provided', () => {
render(<Input error="Required" />)
expect(screen.getByText('Required')).toBeInTheDocument()
})
render(<Input error="Required" />);
expect(screen.getByText('Required')).toBeInTheDocument();
});
it('does not render error when omitted', () => {
render(<Input />)
expect(screen.queryByText('Required')).toBeNull()
})
})
render(<Input />);
expect(screen.queryByText('Required')).toBeNull();
});
});
describe('accessibility', () => {
it('label is associated with input via htmlFor/id', () => {
render(<Input label="Email" />)
expect(screen.getByLabelText('Email')).toBeInTheDocument()
})
render(<Input label="Email" />);
expect(screen.getByLabelText('Email')).toBeInTheDocument();
});
it('error span is referenced by aria-describedby', () => {
render(<Input error="Required" />)
const input = screen.getByRole('textbox')
const errorId = input.getAttribute('aria-describedby')
expect(errorId).toBeTruthy()
expect(document.getElementById(errorId!)).toHaveTextContent('Required')
})
render(<Input error="Required" />);
const input = screen.getByRole('textbox');
const errorId = input.getAttribute('aria-describedby');
expect(errorId).toBeTruthy();
expect(document.getElementById(errorId as string)).toHaveTextContent('Required');
});
it('no aria-describedby when no error', () => {
render(<Input />)
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby')
})
render(<Input />);
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby');
});
it('uses provided id prop', () => {
render(<Input id="my-input" label="Email" />)
expect(screen.getByLabelText('Email')).toHaveAttribute('id', 'my-input')
})
})
render(<Input id="my-input" label="Email" />);
expect(screen.getByLabelText('Email')).toHaveAttribute('id', 'my-input');
});
});
describe('styling', () => {
it('has brutal-border class', () => {
render(<Input />)
expect(screen.getByRole('textbox')).toHaveClass('brutal-border')
})
render(<Input />);
expect(screen.getByRole('textbox')).toHaveClass('brutal-border');
});
it('applies custom className', () => {
render(<Input className="w-full" />)
expect(screen.getByRole('textbox')).toHaveClass('w-full')
})
})
render(<Input className="w-full" />);
expect(screen.getByRole('textbox')).toHaveClass('w-full');
});
});
describe('forwarded props', () => {
it('passes placeholder to input', () => {
render(<Input placeholder="Enter email" />)
expect(screen.getByPlaceholderText('Enter email')).toBeInTheDocument()
})
render(<Input placeholder="Enter email" />);
expect(screen.getByPlaceholderText('Enter email')).toBeInTheDocument();
});
it('passes type to input', () => {
render(<Input type="email" />)
expect(screen.getByRole('textbox')).toHaveAttribute('type', 'email')
})
})
})
render(<Input type="email" />);
expect(screen.getByRole('textbox')).toHaveAttribute('type', 'email');
});
});
});
describe('Textarea', () => {
describe('rendering', () => {
it('renders a textarea element', () => {
render(<Textarea />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
render(<Textarea />);
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
it('renders label when provided', () => {
render(<Textarea label="Message" />)
expect(screen.getByText('Message')).toBeInTheDocument()
})
render(<Textarea label="Message" />);
expect(screen.getByText('Message')).toBeInTheDocument();
});
it('renders error when provided', () => {
render(<Textarea error="Too short" />)
expect(screen.getByText('Too short')).toBeInTheDocument()
})
render(<Textarea error="Too short" />);
expect(screen.getByText('Too short')).toBeInTheDocument();
});
it('defaults to 4 rows', () => {
render(<Textarea />)
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '4')
})
render(<Textarea />);
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '4');
});
it('accepts custom rows', () => {
render(<Textarea rows={8} />)
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '8')
})
})
render(<Textarea rows={8} />);
expect(screen.getByRole('textbox')).toHaveAttribute('rows', '8');
});
});
describe('accessibility', () => {
it('label is associated with textarea via htmlFor/id', () => {
render(<Textarea label="Message" />)
expect(screen.getByLabelText('Message')).toBeInTheDocument()
})
render(<Textarea label="Message" />);
expect(screen.getByLabelText('Message')).toBeInTheDocument();
});
it('error span is referenced by aria-describedby', () => {
render(<Textarea error="Too short" />)
const textarea = screen.getByRole('textbox')
const errorId = textarea.getAttribute('aria-describedby')
expect(errorId).toBeTruthy()
expect(document.getElementById(errorId!)).toHaveTextContent('Too short')
})
render(<Textarea error="Too short" />);
const textarea = screen.getByRole('textbox');
const errorId = textarea.getAttribute('aria-describedby');
expect(errorId).toBeTruthy();
expect(document.getElementById(errorId as string)).toHaveTextContent('Too short');
});
it('no aria-describedby when no error', () => {
render(<Textarea />)
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby')
})
})
})
render(<Textarea />);
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-describedby');
});
});
});
+37 -20
View File
@@ -1,68 +1,81 @@
import { useId, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react'
import { cn } from '$shared/lib'
import { type InputHTMLAttributes, type TextareaHTMLAttributes, useId } from 'react';
import { cn } from '$shared/lib';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
/**
* Visible label rendered above the input
*/
label?: string
label?: string;
/**
* Validation error shown below the input
*/
error?: string
error?: string;
}
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'
const INPUT_BASE =
'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.
*/
export function Input({ label, error, className, id, ...props }: InputProps) {
const generatedId = useId()
const inputId = id ?? generatedId
const errorId = `${inputId}-error`
const generatedId = useId();
const inputId = id ?? generatedId;
const errorId = `${inputId}-error`;
return (
<div className="flex flex-col gap-2">
{label && <label htmlFor={inputId} className="text-carbon-black">{label}</label>}
{label && (
<label htmlFor={inputId} className="text-blue">
{label}
</label>
)}
<input
id={inputId}
className={cn(INPUT_BASE, className)}
aria-describedby={error ? errorId : undefined}
{...props}
/>
{error && <span id={errorId} className="text-sm text-burnt-oxide">{error}</span>}
{error && (
<span id={errorId} className="text-sm text-blue">
{error}
</span>
)}
</div>
)
);
}
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
/**
* Visible label rendered above the textarea
*/
label?: string
label?: string;
/**
* Validation error shown below the textarea
*/
error?: string
error?: string;
/**
* Number of visible rows
* @default 4
*/
rows?: number
rows?: number;
}
/**
* Multiline textarea with optional label and error state.
*/
export function Textarea({ label, error, rows = 4, className, id, ...props }: TextareaProps) {
const generatedId = useId()
const textareaId = id ?? generatedId
const errorId = `${textareaId}-error`
const generatedId = useId();
const textareaId = id ?? generatedId;
const errorId = `${textareaId}-error`;
return (
<div className="flex flex-col gap-2">
{label && <label htmlFor={textareaId} className="text-carbon-black">{label}</label>}
{label && (
<label htmlFor={textareaId} className="text-blue">
{label}
</label>
)}
<textarea
id={textareaId}
rows={rows}
@@ -70,7 +83,11 @@ export function Textarea({ label, error, rows = 4, className, id, ...props }: Te
aria-describedby={error ? errorId : undefined}
{...props}
/>
{error && <span id={errorId} className="text-sm text-burnt-oxide">{error}</span>}
{error && (
<span id={errorId} className="text-sm text-blue">
{error}
</span>
)}
</div>
)
);
}
+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';

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