Imported from eduardopaulcs/eduardopaulcs-web (
AGENTS.md). Install upstream withnpx skills add eduardopaulcs/eduardopaulcs-web. Copyright stays with the author.
Project Context
Personal portfolio at eduardopaulcs.com. React 18 + TypeScript + Create React App + MUI v5. No backend — all content from translation files and environment variables. Bilingual (EN/ES) via URL prefix /:lang/. Deployed to GitHub Pages via GitHub Actions on push to main.
For detailed patterns and code skeletons: .claude/patterns.md
For system architecture and non-obvious design decisions: .claude/architecture.md
File Structure
src/
├── components/
│ ├── layout/ # Global UI: Layout, Navbar, Content, Footer, NavbarList, NavbarListItem
│ └── pages/
│ ├── Portfolio/ # Section components: AboutMeSection, ExperienceSection, ToolsSection, ContactSection
│ ├── Blog/ # BlogPostCard, BlogPostDetail, BlogDateFilter, BlogTitleFilter
│ └── Fun/ # GameCard, GameSearchFilter
├── hooks/ # Custom hooks (camelCase filenames)
├── images/ # Static assets
├── pages/ # Routable pages: Landing, Portfolio, Blog, BlogPost, Fun, FunGame, Error
├── routes/ # router.tsx — createBrowserRouter setup
├── styles/ # theme.ts + customTheme.json
├── translations/
│ ├── en/common.json
│ └── es/common.json
├── utils/ # Pure mappers and helpers (camelCase filenames)
└── constants.ts # HOME_SECTIONS, SITE_SECTIONS, CONTACT_MEANS, LANGUAGES, DEFAULT_LANG
Games (standalone HTML in iframes) live in public/fun/games/{gameId}/. See .claude/architecture.md for the full Fun system design.
Key distinction: src/pages/ = routable top-level pages. src/components/pages/ = section components used within pages.
Component Conventions
- Functional components only — no class components.
- Default exports for all components.
- Every component and every exported function must have a JSDoc block comment above it.
- Use inline comments to explain non-obvious logic. Prefix workarounds with
HACK!!. - Component filenames: PascalCase. Hook and utility filenames: camelCase.
/**
* Displays the about me section of the portfolio page.
*/
const AboutMeSection = () => {
return <Box>...</Box>;
};
export default AboutMeSection;
TypeScript Style
- Use
interfacefor component props. - Use
typealiases for data/domain models. - Extend MUI component props via
React.ComponentPropsWithoutRef<typeof MuiComponent>. - Generic hooks use the
<T,>trailing-comma syntax to avoid JSX ambiguity. - Strict mode is enabled — no implicit
any.
interface CardProps { title: string; onClick: () => void; } // Props → interface
type WorkExperience = { title: string; description: string[]; }; // Model → type
const useTranslationArray = <T,>(key: string) => { ... }; // Generic hook → trailing comma
Styling
- MUI theme-first: always use theme tokens — never hardcode colors, fonts, or sizes.
- Prefer the
sxprop for per-component styles. Usestyled()only for reusable primitives. - Custom theme values live under
theme.custom.*(defined insrc/styles/customTheme.json). - Responsive design is mobile-first, using
theme.breakpointsanduseMediaQuery. - The only custom theme token currently is
theme.custom.components.navbar.width("56px").
<Box
sx={(theme) => ({
width: theme.custom.components.navbar.width,
backgroundColor: "primary.dark",
"&:hover": { backgroundColor: "primary.main" },
[theme.breakpoints.down("sm")]: { width: "100%" },
})}
/>
Internationalization (i18n)
- Never hardcode user-facing text. All strings go through the translation system.
- Use the custom
useTranslation()hook — never import directly fromreact-i18next. - Use
useTranslationArray<T>(key)for arrays from translation files. - When adding a translation key, always add it to both
en/common.jsonandes/common.json. - Translation keys follow nested dot-notation:
"pages.portfolio.sections.cover.name". useTranslation()returns{ t, currentLang, setLang }.
State Management
- Local state with
useStateonly — no Redux, Zustand, or similar libraries. - Extract reusable stateful logic into custom hooks.
- Wrap third-party hooks and components to decouple the codebase from library internals.
useTranslationwrapsreact-i18next's hookuseLangParamwraps react-router'suseParams- Apply the same pattern to any new third-party hook
Routing
- The language is always encoded in the URL:
/:lang/. No routes exist outside a language prefix. - Use
relativeToAbsolutePath(route, lang)fromsrc/utils/— never build path strings inline. - Use
useNavigatewith{ replace: true }for language switching and same-page navigation. - All routes are defined in
src/routes/router.tsxunder the<Layout>parent route.
Constants
All semantic identifiers live in src/constants.ts. Never use magic strings in components.
PORTFOLIO_SECTIONS // sections on the /portfolio page: { aboutMe, experience, tools, contact }
SITE_SECTIONS // top-level site pages: { portfolio, blog, fun }
CONTACT_MEANS // contact links: { linkedin, github, email }
LANGUAGES // supported languages: ["en", "es"]
DEFAULT_LANG // "en"
Mapper Pattern
Section-to-component and key-to-icon mappings use switch-based functions in src/utils/. Never use inline conditionals or object literals for this — mappers need to return JSX and switch gives exhaustiveness safety.
// src/utils/portfolioSectionMappers.tsx
export const mapSectionKeyToIcon = (key: string): JSX.Element => {
switch (key) {
case "aboutMe": return <Info />;
case "experience": return <WorkHistory />;
default: return <QuestionMark />;
}
};
To add a new section to /portfolio:
- Add entry to
PORTFOLIO_SECTIONSinsrc/constants.ts - Add cases to both mappers in
src/utils/portfolioSectionMappers.tsx - Create
src/components/pages/Portfolio/NewSection.tsx - Add translation keys to both
en/common.jsonandes/common.json - Add
navbar.links.newKeyto both translation files
To add a new top-level page:
- Add entry to
SITE_SECTIONSinsrc/constants.ts - Add case to
src/utils/siteSectionMappers.tsx - Create
src/pages/NewPage.tsx - Add route in
src/routes/router.tsx - Add
pages.landing.sections.newKey.title/descriptionto both translation files
Layout System
The landing page (/:lang/) has no Navbar and no left margin. All other pages have both. This is controlled by Layout.tsx:
const isLandingPage = pathname.split("/").filter(Boolean).length <= 1;
// ...
{!isLandingPage && <Navbar />}
<Content hasNavbar={!isLandingPage} />
Content.tsx applies marginLeft: navbar.width only when hasNavbar is true. Do not add margin-left logic in individual pages — it belongs in Content.tsx via the hasNavbar prop.
Game pages (/:lang/fun/:gameId) are a special case: no Navbar, no Footer, no MUI Container padding. Layout.tsx detects isGamePage and passes disableContainer={true} to Content. The FunGame page component owns all floating controls (back-nav + lang switcher). See .claude/architecture.md for details.
Scroll System (on /portfolio page)
Two hooks work together to keep URL hash ↔ visible section in sync. Both are initialized in Navbar.tsx:
const syncHashRef = useRef<string | null>(null);
const isNavigatingRef = useScrollToLocation(syncHashRef); // hash → scroll
useScrollHashSync(isNavigatingRef, syncHashRef); // scroll → hash
isNavigatingRef:truewhile a programmatic scroll is running. Suppresses the IntersectionObserver.syncHashRef: stores the exact hash string the observer just navigated to.useScrollToLocationmatches against this to skip auto-scroll for observer-driven hash changes. Storing the string (not a boolean) prevents a race condition where a user click and an observer fire overlap.
See .claude/architecture.md for full details on this system.
Node Version Management
- Este proyecto usa nvm para manejar la versión de Node.js.
- Versión requerida:
18.20.8(definida en.nvmrc). - Antes de ejecutar cualquier comando npm/node, ejecutar
nvm use. - Si la versión no está instalada, ejecutar
nvm installprimero. - Verificar con
nvm currentpara confirmar la versión activa. - Si se cambió de versión, puede hacer falta
npm cipara recompilar dependencias nativas.
Environment Variables
All runtime config comes from REACT_APP_* env vars (CRA convention). Access via getEnvVariable(name) in src/utils/getEnvVariable.ts — it auto-prefixes REACT_APP_ and handles missing values gracefully.
For CRA special variables that don't carry the REACT_APP_ prefix (e.g. PUBLIC_URL), pass raw: true as the third argument: getEnvVariable("PUBLIC_URL", "", true). Do not use process.env.* directly.
Current variables:
REACT_APP_LINKEDIN_URLREACT_APP_GITHUB_URLREACT_APP_CONTACT_EMAILPUBLIC_URL(CRA built-in, accessed withraw: true)
Set as GitHub Actions secrets. Not available at runtime — build-time injection only.
What NOT To Do
- ❌ Hardcode colors, font sizes, or spacing — use theme tokens
- ❌ Import
useTranslationdirectly fromreact-i18next - ❌ Construct URL paths inline with string concatenation
- ❌ Add state management libraries (Redux, Zustand, Jotai, etc.)
- ❌ Add new npm dependencies for things achievable with React + MUI
- ❌ Use
document.body.style.overflow = "hidden"in pages — it blocks mobile scroll - ❌ Put section/icon mapping logic inline in components — it belongs in
src/utils/mappers - ❌ Create routes without the
/:lang/prefix - ❌ Omit JSDoc from components or exported functions
- ❌ Use class components
- ❌ Skip adding a translation key to one of the two language files
- ❌ Access
process.env.PUBLIC_URLdirectly — usegetEnvVariable("PUBLIC_URL", "", true) - ❌ Add a visual language badge or indicator on blog post cards or detail views —
langis HTML-only - ❌ Ejecutar npm/node commands sin hacer
nvm useprimero