- Refactor Hooks and Types for safer, more-flexible ingestion - Update Component imports - Create /Work page - Integrate ProtoPro - Improve Card* Component consistency - Bug fixes in fluid typography

+11 -28
plugins/fluidType.ts
··· 87 87 360, 88 88 1200, 89 89 ], 90 + // base: default text (body copy, paragraphs) 91 + base: [ 92 + 16, 93 + 32, 94 + 360, 95 + 1200, 96 + ], 90 97 // md: Medium text (body copy, paragraphs) 91 98 md: [ 92 - 16, 93 99 24, 100 + 40, 94 101 360, 95 102 1200, 96 103 ], 97 104 // lg: Large text (subheadings, lead paragraphs) 98 105 lg: [ 99 - 24, 106 + 32, 100 107 48, 101 108 360, 102 109 1200, 103 110 ], 104 111 // xl: Extra large text (headings) 105 112 xl: [ 106 - 32, 113 + 40, 107 114 56, 108 115 360, 109 116 1200, ··· 115 122 360, 116 123 1200, 117 124 ], 118 - // Legacy presets for backward compatibility 119 - h1: [ 120 - 28, 121 - 88, 122 - 360, 123 - 1200, 124 - ], 125 - h2: [ 126 - 22, 127 - 48, 128 - 360, 129 - 1200, 130 - ], 131 - h3: [ 132 - 18, 133 - 32, 134 - 360, 135 - 1200, 136 - ], 137 - body: [ 138 - 16, 139 - 24, 140 - 360, 141 - 1200, 142 - ], 125 + 143 126 }; 144 127 145 128 matchUtilities(
+3 -1
src/App.tsx
··· 1 1 import AboutPage from '@/pages/AboutPage'; 2 2 import HomePage from '@/pages/HomePage'; 3 - import WorksPage from '@/pages/WorksPage'; 3 + import WorksPage from '@/pages/PortfolioPage'; 4 + import WorkPage from '@/pages/WorkPage'; 4 5 import WritingPage from '@/pages/WritingPage'; 5 6 import React, { useEffect } from 'react'; 6 7 import { Route, Routes } from 'react-router-dom'; ··· 37 38 <Routes> 38 39 <Route path="/" element={<HomePage />} /> 39 40 <Route path="/about" element={<AboutPage />} /> 41 + <Route path="/work" element={<WorkPage />} /> 40 42 <Route path="/works" element={<WorksPage />} /> 41 43 <Route path="/writing" element={<WritingPage />} /> 42 44 {/* <Route path="/studies" element={<Studies />} /> */}
+9 -9
src/components/CardArticle/CardArticle.tsx
··· 19 19 20 20 {/* Content */} 21 21 <div className={styles.contentContainer}> 22 + <div className={styles.detailContainer}> 23 + {/* Title */} 24 + <Heading level={3} size="md"> 25 + {article.title} 26 + </Heading> 27 + 28 + {/* Description */} 29 + {article.subtitle && <Paragraph size="base">{article.subtitle}</Paragraph>} 30 + </div> 22 31 {/* Publication Name (left) and Date (right) */} 23 32 <div className={styles.metaContainer}> 24 33 <div className={styles.publicationContainer}> ··· 26 35 <span className={styles.publicationName}>{article.publication}</span> 27 36 </div> 28 37 <span className={styles.date}>{formattedDate}</span> 29 - </div> 30 - <div className={styles.detailContainer}> 31 - {/* Title */} 32 - <Heading level={3} size="lg"> 33 - {article.title} 34 - </Heading> 35 - 36 - {/* Description */} 37 - {article.subtitle && <Paragraph>{article.subtitle}</Paragraph>} 38 38 </div> 39 39 </div> 40 40 </a>
+6
src/components/CardEvent/CardEvent.styles.ts
··· 1 + export const cardEventStyles = { 2 + wrapper: 'px-8 py-12 bg-bones-white dark:bg-bones-black flex flex-col gap-8', 3 + title: 'text-2xl font-bold font-dm-sans text-neutral-900', 4 + subtitle: 'text-lg font-normal font-dm-sans text-neutral-700 italic mt-2', 5 + meta: 'text-sm font-medium font-dm-sans text-neutral-500 uppercase mt-4', 6 + } as const;
+32
src/components/CardEvent/CardEvent.tsx
··· 1 + import { Heading } from '@/components/Heading/Heading'; 2 + import { Paragraph } from '@/components/Paragraph/Paragraph'; 3 + import { cn } from '@/lib/utils'; 4 + import React from 'react'; 5 + import { cardEventStyles } from './CardEvent.styles'; 6 + import { CardEventProps } from './CardEvent.types'; 7 + 8 + export const CardEvent: React.FC<CardEventProps> = ({ 9 + eventType, 10 + eventTitle, 11 + eventDescription, 12 + eventStartYear, 13 + eventEndYear, 14 + eventAffiliation, 15 + className = '', 16 + }) => ( 17 + <div className={cn(cardEventStyles.wrapper, className)}> 18 + <div className="flex items-center justify-between"> 19 + <Paragraph size="sm">{eventAffiliation}</Paragraph> 20 + <Paragraph size="sm"> 21 + {eventStartYear} &ndash; {eventEndYear} 22 + </Paragraph> 23 + </div> 24 + <div className="flex flex-col gap-2"> 25 + <Heading level={3} size="lg"> 26 + {eventTitle} 27 + </Heading> 28 + <Paragraph size="md">{eventDescription}</Paragraph> 29 + </div> 30 + <Paragraph size="sm">{eventType}</Paragraph> 31 + </div> 32 + );
+9
src/components/CardEvent/CardEvent.types.ts
··· 1 + export interface CardEventProps { 2 + eventType: string; 3 + eventTitle: string; 4 + eventDescription: string; 5 + eventStartYear: string; 6 + eventEndYear: string; 7 + eventAffiliation: string; 8 + className?: string; 9 + }
+3
src/components/CardFact/CardFact.styles.ts
··· 1 + export const cardFactStyles = { 2 + wrapper: 'px-8 py-6 bg-neutral-50 dark:bg-neutral-800 rounded-lg shadow-sm', 3 + };
+15
src/components/CardFact/CardFact.tsx
··· 1 + import { Heading } from '@/components/Heading/Heading'; 2 + import { Paragraph } from '@/components/Paragraph/Paragraph'; 3 + import { cn } from '@/lib/utils'; 4 + import React from 'react'; 5 + import { cardFactStyles } from './CardFact.styles'; 6 + import { CardFactProps } from './CardFact.types'; 7 + 8 + export const CardFact: React.FC<CardFactProps> = ({ title, subtitle, className = '' }) => ( 9 + <div className={cn(cardFactStyles.wrapper, className)}> 10 + <Heading level={3} size="lg"> 11 + {title} 12 + </Heading> 13 + <Paragraph size="md">{subtitle}</Paragraph> 14 + </div> 15 + );
+5
src/components/CardFact/CardFact.types.ts
··· 1 + export interface CardFactProps { 2 + title: string; 3 + subtitle: string; 4 + className?: string; 5 + }
+1 -1
src/components/CardNote/CardNote.tsx
··· 32 32 </div> 33 33 34 34 {/* Title */} 35 - <Heading level={3}>{title}</Heading> 35 + <Heading level={3} size="lg">{title}</Heading> 36 36 {/* Description */} 37 37 {description && <Paragraph>{description}</Paragraph>} 38 38 </div>
+1 -1
src/components/CardRole/CardRole.styles.ts
··· 1 1 export const cardWrapper = 2 - 'group relative flex flex-col h-full bg-bones-white dark:bg-bones-black text-left w-full cursor-pointer hover:bg-bones-white-10 dark:hover:bg-bones-black-10 transition-colors'; 2 + 'group relative flex flex-col h-full bg-bones-white dark:bg-bones-black text-left w-full hover:bg-bones-white-10 dark:hover:bg-bones-black-10 transition-colors'; 3 3 export const contentContainer = 'flex flex-col flex-grow gap-4 p-8 justify-between'; 4 4 export const coverImage = 'object-cover w-full h-full transition-transform group-hover:scale-105'; 5 5 export const coverImageContainer = 'relative aspect-[16/9] w-full overflow-hidden bg-neutral-100 dark:bg-neutral-700';
+24 -18
src/components/CardRole/CardRole.tsx
··· 1 1 import { Heading } from '@/components/Heading/Heading'; 2 2 import { Paragraph } from '@/components/Paragraph/Paragraph'; 3 3 import React from 'react'; 4 - import { useNavigate } from 'react-router-dom'; 5 - import { ROLES_BASE_PATH } from './CardRole.constants'; 6 4 import * as styles from './CardRole.styles'; 7 5 import { CardRoleProps } from './CardRole.types'; 8 6 7 + /** 8 + * Format date string to readable format 9 + */ 10 + function formatDate(dateString: string): string { 11 + const date = new Date(dateString); 12 + if (isNaN(date.getTime())) return dateString; 13 + 14 + return date.toLocaleDateString('en-US', { 15 + year: 'numeric', 16 + month: 'short', 17 + }); 18 + } 19 + 9 20 export const CardRole: React.FC<CardRoleProps> = ({ role }) => { 10 - const navigate = useNavigate(); 21 + const startDate = formatDate(role.startDate); 22 + const endDate = role.endDate ? formatDate(role.endDate) : 'Present'; 23 + const dateRange = `${startDate} — ${endDate}`; 11 24 12 25 return ( 13 - <button onClick={() => navigate(`${ROLES_BASE_PATH}/${role.slug}`)} className={styles.cardWrapper}> 14 - {/* Cover Image (if available) */} 15 - {role.coverImage && ( 16 - <div className={styles.coverImageContainer}> 17 - <img src={role.coverImage} alt="" className={styles.coverImage} /> 18 - </div> 19 - )} 20 - 26 + <div className={styles.cardWrapper}> 21 27 {/* Content */} 22 28 <div className={styles.contentContainer}> 23 29 {/* Company Name (left) and Date (right) */} 24 30 <div className={styles.metaContainer}> 25 31 <span className={styles.companyName}>{role.company}</span> 26 - <span className={styles.date}>{role.date}</span> 32 + <span className={styles.date}>{dateRange}</span> 27 33 </div> 28 34 <div className={styles.detailContainer}> 29 - {/* Title */} 30 - <Heading level={3} size="lg"> 31 - {role.title} 35 + {/* Position Title */} 36 + <Heading level={3} size="md"> 37 + {role.position} 32 38 </Heading> 33 39 34 - {/* Subtitle/Description */} 35 - {role.subtitle && <Paragraph>{role.subtitle}</Paragraph>} 40 + {/* Description */} 41 + {role.description && <Paragraph size="base">{role.description}</Paragraph>} 36 42 </div> 37 43 </div> 38 - </button> 44 + </div> 39 45 ); 40 46 };
+3 -8
src/components/CardRole/CardRole.types.ts
··· 1 + import type { JobHistoryEntry } from '@/types/atproto'; 2 + 1 3 export interface CardRoleProps { 2 - role: { 3 - title: string; 4 - company: string; 5 - subtitle: string; 6 - date: string; 7 - coverImage?: string; 8 - slug: string; 9 - }; 4 + role: JobHistoryEntry; 10 5 }
+2 -2
src/components/Divider/Divider.styles.ts
··· 1 1 // Divider inherits color from page theme context 2 - export const dividerHorizontal = 'border-t-[1px] border-l-0 border-r-0 border-b-0'; 3 - export const dividerVertical = 'border-l-[1px] border-t-0 border-r-0 border-b-0 inline-block h-full align-middle'; 2 + export const dividerHorizontal = 'border-t-[2px] border-l-0 border-r-0 border-b-0'; 3 + export const dividerVertical = 'border-l-[2px] border-t-0 border-r-0 border-b-0 inline-block h-full align-middle';
+1 -1
src/components/Divider/Divider.tsx
··· 11 11 // Accent: white border 12 12 // Default: black border in light mode, white in dark 13 13 const themeClasses = 14 - pageTheme === 'accent' ? 'border-bones-white' : 'border-bones-black-30 dark:border-bones-white-30'; 14 + pageTheme === 'accent' ? 'border-bones-white' : 'border-bones-black-20 dark:border-bones-white-20'; 15 15 16 16 const orientationStyles = orientation === 'vertical' ? dividerVertical : dividerHorizontal; 17 17
+2 -2
src/components/Events/Events.tsx
··· 1 1 import { cn } from '@/lib/utils'; 2 2 import React from 'react'; 3 - import { Event } from '@/components/Event/Event'; 3 + import { CardEvent } from '@/components/CardEvent/CardEvent'; 4 4 import { eventsStyles } from './Events.styles'; 5 5 import { EventsProps } from './Events.types'; 6 6 7 7 export const Events: React.FC<EventsProps> = ({ items, className = '' }) => ( 8 8 <div className={cn(eventsStyles.wrapper, className)}> 9 9 {items.map((item) => ( 10 - <Event 10 + <CardEvent 11 11 key={item.eventTitle} 12 12 eventType={item.eventType} 13 13 eventTitle={item.eventTitle}
+2 -2
src/components/Events/Events.types.ts
··· 1 - import { EventProps } from './Event.types'; 1 + import { CardEventProps } from '@/components/CardEvent/CardEvent.types'; 2 2 3 3 export interface EventsProps { 4 - items: EventProps[]; 4 + items: CardEventProps[]; 5 5 className?: string; 6 6 }
+2 -2
src/components/Facts/Facts.tsx
··· 1 1 import { cn } from '@/lib/utils'; 2 2 import React from 'react'; 3 - import { Fact } from '@/components/Fact/Fact'; 3 + import { CardFact } from '@/components/CardFact/CardFact'; 4 4 import { factsStyles } from './Facts.styles'; 5 5 import { FactsProps } from './Facts.types'; 6 6 ··· 8 8 return ( 9 9 <div className={cn(factsStyles.wrapper, className)}> 10 10 {items.map((item) => ( 11 - <Fact key={item.title} title={item.title} subtitle={item.subtitle} /> 11 + <CardFact key={item.title} title={item.title} subtitle={item.subtitle} /> 12 12 ))} 13 13 </div> 14 14 );
+2 -2
src/components/Facts/Facts.types.ts
··· 1 - import { FactProps } from './Fact.types'; 1 + import { CardFactProps } from '@/components/CardFact/CardFact.types'; 2 2 3 3 export interface FactsProps { 4 - items: FactProps[]; 4 + items: CardFactProps[]; 5 5 className?: string; 6 6 }
+1
src/components/Heading/Heading.styles.ts
··· 10 10 base: 'font-dm-sans', 11 11 sizes: { 12 12 sm: 'heading-sm', 13 + base: 'heading-base', 13 14 md: 'heading-md', 14 15 lg: 'heading-lg', 15 16 xl: 'heading-xl',
+1 -1
src/components/Heading/Heading.types.ts
··· 1 1 import { ReactNode } from 'react'; 2 2 3 3 export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; 4 - export type HeadingSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl'; 4 + export type HeadingSize = 'sm' | 'base' | 'md' | 'lg' | 'xl' | '2xl'; 5 5 6 6 export interface HeadingProps { 7 7 children: ReactNode;
+4 -7
src/components/Layout/Layout.styles.ts
··· 3 3 4 4 export const getLayoutStyles = (theme: PageTheme = 'default') => 5 5 cn( 6 - 'grid grid-cols-1 md:grid-cols-3 min-h-screen gap-0', 6 + 'grid grid-cols-1 md:grid-cols-[2fr_auto_1fr] min-h-screen gap-0', 7 7 // Accent theme: blue (light) / mediumblue (dark), white text 8 8 theme === 'accent' && 'bg-bones-blue dark:bg-bones-mediumblue text-bones-white', 9 9 // Default theme: white (light) / black (dark), black/white text 10 10 theme === 'default' && 'bg-bones-white dark:bg-bones-black text-bones-black dark:text-bones-white', 11 11 ); 12 12 13 - export const main = 'md:col-span-2 p-12 md:order-1'; 13 + export const main = 'p-12'; 14 14 15 15 export const getAsideStyles = (theme: PageTheme = 'default') => 16 16 cn( 17 - 'md:col-span-1 p-12 md:order-2', 18 - // Accent theme: white border with transparency 19 - theme === 'accent' && 'border-l border-bones-white-20', 20 - // Default theme: black/white borders based on mode 21 - theme === 'default' && 'border-l border-bones-black-20 dark:border-bones-white-20', 17 + 'p-12', 18 + // Theme-specific styles (border removed - now using Divider component) 22 19 );
+15 -1
src/components/Layout/Layout.tsx
··· 2 2 import * as styles from './Layout.styles'; 3 3 import { DEFAULT_THEME } from './Layout.constants'; 4 4 import { AsideProps, LayoutProps, PageTheme, MainProps } from './Layout.types'; 5 + import { Divider } from '@/components/Divider/Divider'; 5 6 6 7 // Context to share page theme with child components 7 8 const PageThemeContext = createContext<PageTheme>(DEFAULT_THEME); ··· 9 10 export const usePageTheme = () => useContext(PageThemeContext); 10 11 11 12 export const Layout: React.FC<LayoutProps> = ({ children, theme = DEFAULT_THEME }) => { 13 + // Split children into Main and Aside 14 + const childArray = React.Children.toArray(children); 15 + const mainComponent = childArray.find( 16 + (child) => React.isValidElement(child) && child.type === Main 17 + ); 18 + const asideComponent = childArray.find( 19 + (child) => React.isValidElement(child) && child.type === Aside 20 + ); 21 + 12 22 return ( 13 23 <PageThemeContext.Provider value={theme}> 14 - <div className={styles.getLayoutStyles(theme)}>{children}</div> 24 + <div className={styles.getLayoutStyles(theme)}> 25 + {mainComponent} 26 + {asideComponent && <Divider orientation="vertical" className="hidden md:block" />} 27 + {asideComponent} 28 + </div> 15 29 </PageThemeContext.Provider> 16 30 ); 17 31 };
+1
src/components/ListItem/ListItem.styles.ts
··· 8 8 9 9 export const listItemSizes = { 10 10 sm: 'paragraph-sm', 11 + base: 'paragraph-base', 11 12 md: 'paragraph-md', 12 13 lg: 'paragraph-lg', 13 14 } as const;
+1
src/components/Paragraph/Paragraph.styles.ts
··· 7 7 base: 'font-dm-sans', 8 8 sizes: { 9 9 sm: 'paragraph-sm', 10 + base: 'paragraph-base', 10 11 md: 'paragraph-md', 11 12 lg: 'paragraph-lg', 12 13 xl: 'paragraph-xl',
+1 -1
src/components/Paragraph/Paragraph.types.ts
··· 1 1 import { ReactNode } from 'react'; 2 2 3 - export type ParagraphSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl'; 3 + export type ParagraphSize = 'sm' | 'base' | 'md' | 'lg' | 'xl' | '2xl'; 4 4 5 5 export interface ParagraphProps { 6 6 children: ReactNode;
+1 -1
src/components/Skills/Skills.types.ts
··· 1 - import { SkillProps } from './Skill.types'; 1 + import { SkillProps } from '@/components/Skill/Skill.types'; 2 2 3 3 export interface SkillsProps { 4 4 items: SkillProps[];
+2 -1
src/components/TLDRProfile/TLDRProfile.constants.ts
··· 7 7 } as const; 8 8 9 9 export const SECTIONS = { 10 - specializations: 'Specializations', 10 + skills: 'Skills', 11 + languages: 'Languages', 11 12 connect: 'Connect', 12 13 } as const; 13 14
+32 -10
src/components/TLDRProfile/TLDRProfile.tsx
··· 1 1 import { Link } from '@/components/Link/Link'; 2 2 import { Tags } from '@/components/Tags/Tags'; 3 - import { specializations } from '@/data/specializations'; 3 + import { useProtopro } from '@/hooks/atproto'; 4 4 import type { JSX } from 'react'; 5 5 import { Heading } from '../Heading/Heading'; 6 6 import { Paragraph } from '../Paragraph/Paragraph'; ··· 13 13 * @returns JSX element with profile photo and summary information 14 14 */ 15 15 export default function TLDRProfile(): JSX.Element { 16 + const { data: profile } = useProtopro(); 17 + 16 18 return ( 17 19 <div className={styles.container}> 18 20 <div className={styles.innerContainer}> ··· 22 24 <Heading level={3} size="md"> 23 25 {PROFILE.name} 24 26 </Heading> 25 - <Paragraph size="sm">{PROFILE.title}</Paragraph> 26 - <Paragraph size="sm">{PROFILE.location}</Paragraph> 27 - <Paragraph size="sm">{PROFILE.experience}</Paragraph> 27 + <Paragraph size="base">{PROFILE.location}</Paragraph> 28 + <Paragraph size="base">{profile?.overview || PROFILE.title}</Paragraph> 29 + {/* <Paragraph size="base">{PROFILE.experience}</Paragraph> */} 28 30 </div> 29 31 30 - <div className={styles.section}> 31 - <Paragraph size="md">{SECTIONS.specializations}</Paragraph> 32 - <Tags tags={specializations} className="flex-wrap" /> 33 - </div> 32 + {profile?.skills && profile.skills.length > 0 && ( 33 + <div className={styles.section}> 34 + <Heading level={4} size="base"> 35 + {SECTIONS.skills} 36 + </Heading> 37 + <Tags tags={[...profile.skills].sort().join(', ')} className="flex-wrap" /> 38 + </div> 39 + )} 40 + 41 + {profile?.languages && profile.languages.length > 0 && ( 42 + <div className={styles.section}> 43 + <Heading level={4} size="base"> 44 + {SECTIONS.languages} 45 + </Heading> 46 + <Tags 47 + tags={[...profile.languages] 48 + .map((lang) => lang.code) 49 + .sort() 50 + .join(', ')} 51 + className="flex-wrap" 52 + /> 53 + </div> 54 + )} 34 55 35 56 <div className={styles.section}> 36 - <Paragraph size="md">{SECTIONS.connect}</Paragraph> 57 + <Heading level={4} size="base"> 58 + {SECTIONS.connect} 59 + </Heading> 37 60 38 - <Paragraph className={styles.sectionTitle}>{SECTIONS.connect}</Paragraph> 39 61 <div className={styles.connectLinks}> 40 62 {CONNECT_LINKS.map((link) => ( 41 63 <Link key={link.href} href={link.href} target="_blank" rel="noopener noreferrer">
+1 -1
src/components/Tag/Tag.styles.ts
··· 1 1 export const tagStyles = { 2 - base: 'px-2 py-1 bg-bones-black-5 text-bones-black font-medium dark:bg-bones-white-10 dark:text-bones-white', 2 + base: 'px-2 py-1 font-medium', 3 3 } as const;
+11 -1
src/components/Tag/Tag.tsx
··· 1 + import { usePageTheme } from '@/components/Layout/Layout'; 1 2 import { cn } from '@/lib/utils'; 2 3 import React from 'react'; 3 4 import { Paragraph } from '../Paragraph/Paragraph'; ··· 5 6 import { TagProps } from './Tag.types'; 6 7 7 8 export const Tag: React.FC<TagProps> = ({ label, className = '' }) => { 9 + const pageTheme = usePageTheme(); 10 + 11 + // Theme-based tag styling: 12 + // Accent: white border with semi-transparent white background 13 + // Default: light backgrounds with dark/light text based on mode 14 + const themeClasses = pageTheme === 'accent' 15 + ? 'border border-bones-white bg-bones-white-20 text-bones-white' 16 + : 'bg-bones-black-5 text-bones-black dark:bg-bones-white-10 dark:text-bones-white'; 17 + 8 18 return ( 9 - <div className={cn(tagStyles.base, className)}> 19 + <div className={cn(tagStyles.base, themeClasses, className)}> 10 20 <Paragraph size="sm">{label}</Paragraph> 11 21 </div> 12 22 );
+8 -7
src/hooks/atproto/fetchPublications.ts src/hooks/atproto/useLeaflet.ts
··· 1 1 import { useEffect, useState } from 'react'; 2 2 import { ATPROTO_COLLECTIONS, buildBlobUrl } from '@/config/atproto'; 3 3 import type { ATProtocolDocument, ATProtocolPublication, FetchResult } from '@/types/atproto'; 4 - import { fetchRecords } from './fetchRecords'; 4 + import { useRecords } from './useRecords'; 5 5 6 6 /** 7 7 * Publication data with resolved metadata ··· 28 28 } 29 29 30 30 /** 31 - * Hook for fetching publications and their associated documents 31 + * Hook for fetching Leaflet publications and their associated documents 32 + * Fetches from pub.leaflet.publication and pub.leaflet.document collections 32 33 * Automatically resolves publication references in documents 33 34 * 34 35 * @returns FetchResult with combined documents and publications data 35 36 * 36 37 * @example 37 38 * ```tsx 38 - * const { data: posts, loading, error } = fetchPublications(); 39 + * const { data: posts, loading, error } = useLeaflet(); 39 40 * 40 41 * if (loading) return <div>Loading...</div>; 41 42 * if (error) return <div>Error: {error}</div>; ··· 48 49 * )); 49 50 * ``` 50 51 */ 51 - export function fetchPublications(): FetchResult<Document[]> { 52 + export function useLeaflet(): FetchResult<Document[]> { 52 53 const { 53 54 data: publicationsData, 54 55 loading: publicationsLoading, 55 56 error: publicationsError, 56 - } = fetchRecords<ATProtocolPublication['value']>(ATPROTO_COLLECTIONS.PUBLICATION); 57 + } = useRecords<ATProtocolPublication['value']>(ATPROTO_COLLECTIONS.PUBLICATION); 57 58 58 59 const { 59 60 data: documentsData, 60 61 loading: documentsLoading, 61 62 error: documentsError, 62 - } = fetchRecords<ATProtocolDocument['value']>(ATPROTO_COLLECTIONS.DOCUMENT); 63 + } = useRecords<ATProtocolDocument['value']>(ATPROTO_COLLECTIONS.DOCUMENT); 63 64 64 65 const [data, setData] = useState<Document[] | null>(null); 65 66 const [loading, setLoading] = useState(true); ··· 119 120 publishedAt: record.value.publishedAt, 120 121 articleUrl: `https://${publication.basePath}/${slug}`, 121 122 publication, 122 - }; 123 + } as Document; 123 124 }) 124 125 .filter((doc): doc is Document => doc !== null) 125 126 // Sort by published date, newest first
+4 -4
src/hooks/atproto/fetchRecords.ts src/hooks/atproto/useRecords.ts
··· 11 11 * 12 12 * @example 13 13 * ```tsx 14 - * const { data: publications, loading, error } = fetchRecords<PublicationValue>( 14 + * const { data: publications, loading, error } = useRecords<PublicationValue>( 15 15 * 'pub.leaflet.publication' 16 16 * ); 17 17 * ``` 18 18 */ 19 - export function fetchRecords<T = unknown>(collection: string, repo?: string): FetchResult<ATProtocolRecord<T>[]> { 19 + export function useRecords<T = unknown>(collection: string, repo?: string): FetchResult<ATProtocolRecord<T>[]> { 20 20 const [data, setData] = useState<ATProtocolRecord<T>[] | null>(null); 21 21 const [loading, setLoading] = useState(true); 22 22 const [error, setError] = useState<string | null>(null); 23 23 24 24 useEffect(() => { 25 - const fetchRecords = async () => { 25 + const loadRecords = async () => { 26 26 try { 27 27 setLoading(true); 28 28 setError(null); ··· 44 44 } 45 45 }; 46 46 47 - fetchRecords(); 47 + loadRecords(); 48 48 }, [collection, repo]); 49 49 50 50 return { data, loading, error };
+5 -3
src/hooks/atproto/index.ts
··· 2 2 * AT Protocol hooks exports 3 3 */ 4 4 5 - export { fetchPublications } from './fetchPublications'; 6 - export type { Document, Publication } from './fetchPublications'; 7 - export { fetchRecords } from './fetchRecords'; 5 + export { useLeaflet } from './useLeaflet'; 6 + export type { Document, Publication } from './useLeaflet'; 7 + export { useProtopro } from './useProtopro'; 8 + export type { Profile } from './useProtopro'; 9 + export { useRecords } from './useRecords';
+89
src/hooks/atproto/useProtopro.ts
··· 1 + import { useEffect, useState } from 'react'; 2 + import type { FetchResult, JobHistoryEntry, LanguageProficiency, ProfileValue } from '@/types/atproto'; 3 + import { useRecords } from './useRecords'; 4 + 5 + /** 6 + * Profile data from Protopro collection 7 + */ 8 + export interface Profile { 9 + overview?: string; 10 + jobHistory: JobHistoryEntry[]; 11 + languages: LanguageProficiency[]; 12 + skills: string[]; 13 + educationHistory: unknown[]; 14 + } 15 + 16 + /** 17 + * Hook for fetching Protopro actor profile from AT Protocol 18 + * Fetches from blue.protopro.actor.profile collection 19 + * Returns: overview, jobHistory, languages, skills, educationHistory 20 + * 21 + * @returns FetchResult with profile data 22 + * 23 + * @example 24 + * ```tsx 25 + * const { data: profile, loading, error } = useProtopro(); 26 + * 27 + * if (loading) return <div>Loading...</div>; 28 + * if (error) return <div>Error: {error}</div>; 29 + * 30 + * return ( 31 + * <div> 32 + * {profile?.jobHistory.map(job => <JobCard key={job.company} job={job} />)} 33 + * </div> 34 + * ); 35 + * ``` 36 + */ 37 + export function useProtopro(): FetchResult<Profile | null> { 38 + const { 39 + data: profileData, 40 + loading: profileLoading, 41 + error: profileError, 42 + } = useRecords<ProfileValue>('blue.protopro.actor.profile'); 43 + 44 + const [data, setData] = useState<Profile | null>(null); 45 + const [loading, setLoading] = useState(true); 46 + const [error, setError] = useState<string | null>(null); 47 + 48 + useEffect(() => { 49 + if (profileLoading) { 50 + setLoading(true); 51 + return; 52 + } 53 + 54 + if (profileError) { 55 + setError(profileError); 56 + setLoading(false); 57 + return; 58 + } 59 + 60 + if (!profileData || profileData.length === 0) { 61 + setLoading(false); 62 + return; 63 + } 64 + 65 + try { 66 + // Get the first (and likely only) profile record 67 + const profileRecord = profileData[0]; 68 + const profileValue = profileRecord.value; 69 + 70 + const profile: Profile = { 71 + overview: profileValue.overview, 72 + jobHistory: profileValue.jobHistory || [], 73 + languages: profileValue.languages || [], 74 + skills: profileValue.skills || [], 75 + educationHistory: profileValue.educationHistory || [], 76 + }; 77 + 78 + setData(profile); 79 + setError(null); 80 + } catch (err) { 81 + console.error('Error processing profile data:', err); 82 + setError(err instanceof Error ? err.message : 'An error occurred'); 83 + } finally { 84 + setLoading(false); 85 + } 86 + }, [profileData, profileLoading, profileError]); 87 + 88 + return { data, loading, error }; 89 + }
+26 -8
src/index.css
··· 58 58 line-height: 1.62; 59 59 } 60 60 61 - /* md: Body copy, paragraphs (16px → 18px) */ 61 + /* base: Default text (16px → 32px) */ 62 + .type-base { 63 + @apply fluid-preset-base; 64 + line-height: 1.5; 65 + } 66 + 67 + /* md: Body copy, paragraphs (24px → 40px) */ 62 68 .type-md { 63 69 @apply fluid-preset-md; 64 70 line-height: 1.38; ··· 88 94 */ 89 95 90 96 .heading-display { 91 - @apply type-2xl font-semibold font-sans; 97 + @apply type-2xl font-bold font-sans; 92 98 } 93 99 94 100 .heading-2xl { ··· 107 113 @apply type-md font-extrabold font-sans; 108 114 } 109 115 116 + .heading-base { 117 + @apply type-base font-black font-sans; 118 + } 119 + 110 120 .heading-sm { 111 - @apply type-sm font-semibold font-sans; 121 + @apply type-sm font-black font-sans; 112 122 } 113 123 114 124 /** ··· 132 142 @apply type-md font-normal font-sans; 133 143 } 134 144 145 + .paragraph-base { 146 + @apply type-base font-normal font-sans; 147 + } 148 + 135 149 .paragraph-sm { 136 150 @apply type-sm font-normal font-sans; 137 151 } ··· 141 155 * Uses Tailwind's native font-serif, italic 142 156 */ 143 157 144 - .quote-xl { 158 + .quote-2xl { 145 159 @apply type-2xl font-normal font-sans italic; 160 + } 161 + 162 + .quote-xl { 163 + @apply type-xl font-normal font-sans italic; 146 164 } 147 165 148 166 .quote-lg { 149 - @apply type-xl font-normal font-serif italic; 167 + @apply type-lg font-normal font-serif italic; 150 168 } 151 169 152 170 .quote-md { 153 - @apply type-lg font-normal font-serif italic; 171 + @apply type-md font-normal font-serif italic; 154 172 } 155 173 156 174 /** ··· 159 177 */ 160 178 161 179 .code-inline { 162 - @apply type-sm font-normal font-mono; 180 + @apply type-base font-normal font-mono; 163 181 } 164 182 165 183 .code-block { 166 - @apply type-sm font-normal font-mono; 184 + @apply type-base font-normal font-mono; 167 185 line-height: 1.75; 168 186 } 169 187
+2 -2
src/pages/AboutPage.tsx
··· 164 164 Recent Work 165 165 </Heading> 166 166 167 - <div className="grid grid-cols-1 border-2 border-bones-white-30"> 167 + <div className="grid grid-cols-1 border-2 border-bones-black-20 dark:border-bones-white-20"> 168 168 {latestJobs.map((job, index) => { 169 169 const startYear = new Date(job.startDate).getFullYear(); 170 170 const endYear = job.endDate ? new Date(job.endDate).getFullYear() : 'Present'; ··· 200 200 <Paragraph size="lg">My approach is straightforward and outcome-focused.</Paragraph> 201 201 202 202 <UnorderedList bullet="disc"> 203 - <ListItem size="lg">I start by understanding users and the problems they face</ListItem> 203 + <ListItem>I start by understanding users and the problems they face</ListItem> 204 204 <ListItem>I design and prototype quickly to validate ideas early</ListItem> 205 205 <ListItem>I measure results and refine based on evidence</ListItem> 206 206 <ListItem>I document decisions to help teams move with clarity</ListItem>
+2 -5
src/pages/HomePage.tsx
··· 63 63 </Paragraph> 64 64 65 65 <Paragraph size="2xl"> 66 - I{' '} 67 - <Link href="https://www.linkedin.com/in/barrymprendergast/details/experience/" target="_blank"> 68 - work 69 - </Link>{' '} 70 - with nonprofits and startups to ease their growing pains, and to market faster. 66 + I <Link href="/work">work</Link> with nonprofits and startups to ease their growing pains, and to market 67 + faster. 71 68 </Paragraph> 72 69 73 70 <Paragraph size="2xl">
+130
src/pages/WorkPage.tsx
··· 1 + import { CardRole } from '@/components/CardRole/CardRole'; 2 + import { Divider } from '@/components/Divider/Divider'; 3 + import { Heading } from '@/components/Heading/Heading'; 4 + import { Aside, Layout, Main } from '@/components/Layout/Layout'; 5 + import { Link } from '@/components/Link/Link'; 6 + import { Paragraph } from '@/components/Paragraph/Paragraph'; 7 + import Section from '@/components/Section/Section'; 8 + import TLDRProfile from '@/components/TLDRProfile/TLDRProfile'; 9 + import { useProtopro } from '@/hooks/atproto'; 10 + import type { JSX } from 'react'; 11 + import { Helmet } from 'react-helmet-async'; 12 + 13 + /** 14 + * Work page component - displays CV/work history from AT Protocol PDS 15 + * 16 + * @returns JSX element with work page content 17 + */ 18 + export default function WorkPage(): JSX.Element { 19 + const { data: profile, loading, error } = useProtopro(); 20 + 21 + // Split jobs into current and past 22 + const currentJobs = profile?.jobHistory.filter((job) => !job.endDate) || []; 23 + const pastJobs = 24 + profile?.jobHistory 25 + .filter((job) => job.endDate) 26 + .sort((a, b) => { 27 + // Sort by end date, newest first 28 + const dateA = a.endDate ? new Date(a.endDate).getTime() : 0; 29 + const dateB = b.endDate ? new Date(b.endDate).getTime() : 0; 30 + return dateB - dateA; 31 + }) || []; 32 + 33 + const jsonLd = { 34 + '@context': 'https://schema.org', 35 + '@type': 'ProfilePage', 36 + mainEntity: { 37 + '@type': 'Person', 38 + name: 'Barry Prendergast', 39 + jobTitle: currentJobs[0]?.position || 'Product Designer', 40 + description: 'Independent product designer and strategist', 41 + url: 'https://renderg.host/work', 42 + }, 43 + }; 44 + 45 + return ( 46 + <> 47 + <Helmet> 48 + <title>Work | Barry Prendergast</title> 49 + <meta 50 + name="description" 51 + content="Independent product designer helping organisations deliver better products through clear thinking, practical design, and meaningful collaboration." 52 + /> 53 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> 54 + </Helmet> 55 + 56 + <Layout theme="default"> 57 + <Main> 58 + <div className="flex flex-col gap-16"> 59 + {/* Heading */} 60 + <Section> 61 + <Heading level={2} size="base"> 62 + work / <Link href="/">renderg.host</Link> 63 + </Heading> 64 + </Section> 65 + 66 + {/* Loading/Error States */} 67 + {loading && ( 68 + <Section> 69 + <Paragraph size="2xl">Loading profile...</Paragraph> 70 + </Section> 71 + )} 72 + 73 + {error && ( 74 + <Section> 75 + <Paragraph size="2xl">Error loading profile: {error}</Paragraph> 76 + </Section> 77 + )} 78 + 79 + {/* Current Work Section */} 80 + {!loading && !error && currentJobs.length > 0 && ( 81 + <Section> 82 + <Heading level={2} size="lg"> 83 + Current roles 84 + </Heading> 85 + 86 + <div className="grid grid-cols-1 border-2 border-bones-black-20 dark:border-bones-white-20"> 87 + {currentJobs.map((job, index) => ( 88 + <> 89 + <CardRole key={`current-${job.company}-${index}`} role={job} /> 90 + {index < currentJobs.length - 1 && <Divider />} 91 + </> 92 + ))} 93 + </div> 94 + </Section> 95 + )} 96 + 97 + {/* Past Work Section */} 98 + {!loading && !error && pastJobs.length > 0 && ( 99 + <Section> 100 + <Heading level={2} size="lg"> 101 + Previous roles 102 + </Heading> 103 + 104 + <div className="grid grid-cols-1 border-2 border-bones-black-20 dark:border-bones-white-20"> 105 + {pastJobs.map((job, index) => ( 106 + <> 107 + <CardRole key={`past-${job.company}-${index}`} role={job} /> 108 + {index < pastJobs.length - 1 && <Divider />} 109 + </> 110 + ))} 111 + </div> 112 + </Section> 113 + )} 114 + 115 + {/* Exit */} 116 + <Section> 117 + <Paragraph size="md"> 118 + Return to <Link href="/">renderg.host</Link> 119 + </Paragraph> 120 + </Section> 121 + </div> 122 + </Main> 123 + 124 + <Aside> 125 + <TLDRProfile /> 126 + </Aside> 127 + </Layout> 128 + </> 129 + ); 130 + }
src/pages/WorksPage.tsx src/pages/PortfolioPage.tsx
+5 -8
src/pages/WritingPage.tsx
··· 5 5 import { Link } from '@/components/Link/Link'; 6 6 import { Paragraph } from '@/components/Paragraph/Paragraph'; 7 7 import TLDRProfile from '@/components/TLDRProfile/TLDRProfile'; 8 - import { fetchPublications } from '@/hooks/atproto'; 8 + import { useLeaflet } from '@/hooks/atproto'; 9 9 import type { JSX } from 'react'; 10 10 import { Helmet } from 'react-helmet-async'; 11 11 ··· 16 16 */ 17 17 18 18 export default function WritingPage(): JSX.Element { 19 - const { data: documents, loading, error } = fetchPublications(); 19 + const { data: documents, loading, error } = useLeaflet(); 20 20 21 21 const jsonLd = { 22 22 '@context': 'https://schema.org', ··· 45 45 <Layout theme="default"> 46 46 <Main> 47 47 <div className="flex flex-col gap-12"> 48 - <Paragraph size="md"> 49 - <Link href="/">←Home </Link> 50 - </Paragraph> 51 - <Heading level={1} size="md"> 52 - My Writing 48 + <Heading level={2} size="md"> 49 + writing / <Link href="/">renderg.host</Link> 53 50 </Heading> 54 51 55 52 <Paragraph size="lg">Thoughts on design strategy, product design, and building better products.</Paragraph> ··· 63 60 )} 64 61 65 62 {!loading && !error && documents && documents.length > 0 && ( 66 - <div className="grid grid-cols-1 border-2 border-bones-white-30"> 63 + <div className="grid grid-cols-1 border-2 border-bones-black-20 dark:border-bones-white-20"> 67 64 {documents.map((doc, index) => ( 68 65 <> 69 66 <CardArticle
+43
src/types/atproto/defineBase.ts
··· 1 + /** 2 + * Base AT Protocol type definitions 3 + * Generic types used across all AT Protocol collections 4 + */ 5 + 6 + /** 7 + * AT Protocol Blob reference 8 + * Used for binary data like images 9 + */ 10 + export interface ATProtocolBlob { 11 + $type: 'blob'; 12 + ref: { 13 + $link: string; 14 + }; 15 + mimeType: string; 16 + size: number; 17 + } 18 + 19 + /** 20 + * Base AT Protocol record structure 21 + */ 22 + export interface ATProtocolRecord<T = unknown> { 23 + uri: string; 24 + cid: string; 25 + value: T; 26 + } 27 + 28 + /** 29 + * Response from listRecords XRPC endpoint 30 + */ 31 + export interface ListRecordsResponse<T = unknown> { 32 + records: ATProtocolRecord<T>[]; 33 + cursor?: string; 34 + } 35 + 36 + /** 37 + * Generic fetch result with loading and error states 38 + */ 39 + export interface FetchResult<T> { 40 + data: T | null; 41 + loading: boolean; 42 + error: string | null; 43 + }
+38
src/types/atproto/defineLeaflet.ts
··· 1 + /** 2 + * Leaflet collection type definitions 3 + * Types for pub.leaflet.publication and pub.leaflet.document 4 + */ 5 + 6 + import type { ATProtocolBlob, ATProtocolRecord } from './defineBase'; 7 + 8 + /** 9 + * Publication record value structure 10 + */ 11 + export interface PublicationValue { 12 + name: string; 13 + base_path: string; 14 + icon?: ATProtocolBlob; 15 + description?: string; 16 + $type: string; 17 + } 18 + 19 + /** 20 + * Document record value structure 21 + */ 22 + export interface DocumentValue { 23 + title: string; 24 + description?: string; 25 + publishedAt: string; 26 + publication: string; // URI reference to the publication 27 + $type: string; 28 + } 29 + 30 + /** 31 + * Full Publication record 32 + */ 33 + export type ATProtocolPublication = ATProtocolRecord<PublicationValue>; 34 + 35 + /** 36 + * Full Document record 37 + */ 38 + export type ATProtocolDocument = ATProtocolRecord<DocumentValue>;
+45
src/types/atproto/defineProtopro.ts
··· 1 + /** 2 + * Protopro collection type definitions 3 + * Types for blue.protopro.actor.profile 4 + */ 5 + 6 + import type { ATProtocolRecord } from './defineBase'; 7 + 8 + /** 9 + * Language proficiency structure 10 + */ 11 + export interface LanguageProficiency { 12 + code: string; 13 + level: number; 14 + } 15 + 16 + /** 17 + * Job history entry structure 18 + */ 19 + export interface JobHistoryEntry { 20 + company: string; 21 + position: string; 22 + startDate: string; 23 + endDate?: string; 24 + description?: string; 25 + } 26 + 27 + /** 28 + * Actor Profile record value structure 29 + */ 30 + export interface ProfileValue { 31 + name: string; 32 + skills: string[]; 33 + overview?: string; 34 + languages?: LanguageProficiency[]; 35 + jobHistory?: JobHistoryEntry[]; 36 + socialLinks?: string[]; 37 + educationHistory?: unknown[]; 38 + updatedAt?: string; 39 + $type: string; 40 + } 41 + 42 + /** 43 + * Full Profile record 44 + */ 45 + export type ATProtocolProfile = ATProtocolRecord<ProfileValue>;
-66
src/types/atproto/defineRecords.ts
··· 1 - /** 2 - * AT Protocol record type definitions 3 - * Type-safe interfaces for AT Protocol data structures 4 - */ 5 - 6 - /** 7 - * AT Protocol Blob reference 8 - * Used for binary data like images 9 - */ 10 - export interface ATProtocolBlob { 11 - $type: 'blob'; 12 - ref: { 13 - $link: string; 14 - }; 15 - mimeType: string; 16 - size: number; 17 - } 18 - 19 - /** 20 - * Base AT Protocol record structure 21 - */ 22 - export interface ATProtocolRecord<T = unknown> { 23 - uri: string; 24 - cid: string; 25 - value: T; 26 - } 27 - 28 - /** 29 - * Publication record value structure 30 - */ 31 - export interface PublicationValue { 32 - name: string; 33 - base_path: string; 34 - icon?: ATProtocolBlob; 35 - description?: string; 36 - $type: string; 37 - } 38 - 39 - /** 40 - * Document record value structure 41 - */ 42 - export interface DocumentValue { 43 - title: string; 44 - description?: string; 45 - publishedAt: string; 46 - publication: string; // URI reference to the publication 47 - $type: string; 48 - } 49 - 50 - /** 51 - * Full Publication record 52 - */ 53 - export type ATProtocolPublication = ATProtocolRecord<PublicationValue>; 54 - 55 - /** 56 - * Full Document record 57 - */ 58 - export type ATProtocolDocument = ATProtocolRecord<DocumentValue>; 59 - 60 - /** 61 - * Response from listRecords XRPC endpoint 62 - */ 63 - export interface ListRecordsResponse<T = unknown> { 64 - records: ATProtocolRecord<T>[]; 65 - cursor?: string; 66 - }
-12
src/types/atproto/defineResults.ts
··· 1 - /** 2 - * AT Protocol fetch result types 3 - */ 4 - 5 - /** 6 - * Generic fetch result with loading and error states 7 - */ 8 - export interface FetchResult<T> { 9 - data: T | null; 10 - loading: boolean; 11 - error: string | null; 12 - }
+12 -10
src/types/atproto/index.ts
··· 2 2 * AT Protocol type exports 3 3 */ 4 4 5 - export type { 6 - ATProtocolBlob, 7 - ATProtocolDocument, 8 - ATProtocolPublication, 9 - ATProtocolRecord, 10 - DocumentValue, 11 - ListRecordsResponse, 12 - PublicationValue, 13 - } from './defineRecords'; 5 + // Base types 6 + export type { ATProtocolBlob, ATProtocolRecord, FetchResult, ListRecordsResponse } from './defineBase'; 14 7 15 - export type { FetchResult } from './defineResults'; 8 + // Leaflet types 9 + export type { ATProtocolDocument, ATProtocolPublication, DocumentValue, PublicationValue } from './defineLeaflet'; 10 + 11 + // Protopro types 12 + export type { 13 + ATProtocolProfile, 14 + JobHistoryEntry, 15 + LanguageProficiency, 16 + ProfileValue, 17 + } from './defineProtopro';
+5 -1
tsconfig.json
··· 25 25 "types": ["react"], 26 26 "useDefineForClassFields": true 27 27 }, 28 - "include": ["src", "src/components/Sidebar/.Sidebar.tsx.off"], 28 + "include": [ 29 + "src", 30 + "src/components/Sidebar/.Sidebar.tsx.off", 31 + "src/components/CardArticle/CardArticle.tsx" 32 + ], 29 33 "references": [{ "path": "./tsconfig.node.json" }] 30 34 }