GitHub - pablodalvarezg/pablodagdev: Website Professional Developer Portfolio · GitHub
Skip to content

About

Website Professional Developer Portfolio

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

pablodagdev

Portfolio of Pablo Álvarez Graña, full-stack and low/no-code developer in Buenos Aires. A Next.js site in English and Spanish, built as a static export: every page is plain HTML with the content already in it, and each project gets a page that doubles as its case study, styled by its own theme.

Working agreements live in CLAUDE.md; scope and phases in PORTFOLIO_BRIEF.md; the decisions behind the architecture in docs/adr/. AGENTS.md is written by next dev and points at the docs bundled with the installed Next version.

What exists today, and what comes next, is in PORTFOLIO_BRIEF.md under "Estado" and "Lo que sigue". That is the one place kept current; this file describes how the project works, not how far along it is.

Requirements

  • Node 24.21.0, the active LTS. nvm use 24.21.0 if you keep several.
  • npm. The lockfile is package-lock.json.

Running it

npm install     # first time, and whenever dependencies change
npm run dev     # http://localhost:3000
Command What it does
npm run dev Local server on :3000, with hot reload
npm run build Static export into out/
npm run preview Serves out/ the way a host will
npm run check next typegen then tsc --noEmit
npm run lint ESLint, including the module boundary rules
npm run format Prettier
npm test Vitest

Before calling anything done: npm run check && npm run lint && npm test.

check runs next typegen first on purpose. PageProps and LayoutProps are generated by Next, so tsc alone fails on a clean checkout.

Environment

Variable Where What for
GITHUB_TOKEN .env.local, and Vercel for both Production and Preview The contribution graph on the hub. A fine-grained token with no permissions is enough: it only reads the public contribution calendar. Private work counts only if "Private contributions" is on in the GitHub profile.

npm run build fails without GITHUB_TOKEN, on purpose: a production build missing it would ship a hub with the section quietly gone. npm run dev runs without it and just leaves the section out. Vercel's preview builds are production builds too, so a token set only for Production fails every pull request preview.

The graph is fetched at build time, so it is as old as the last deploy. .github/workflows/daily-rebuild.yml redeploys once a day by calling a Vercel deploy hook, stored as the repository secret VERCEL_DEPLOY_HOOK. GitHub pauses scheduled workflows after 60 days without commits; re-enable it from the Actions tab if that happens.

There is no canonical or hreflang tag yet, and no SITE_URL. Pages carry a title and a description and nothing else; the seo module that adds the rest is still to be written, and it needs the domain to be decided first.

Layout

src/
├─ app/                    Routing and composition only.
│  └─ [lang]/              The locale is the first segment, so this layout
│     ├─ layout.tsx        is the root layout and owns <html lang>.
│     ├─ page.tsx          The hub.
│     ├─ projects/[slug]/  One page per case study, per locale.
│     └─ services/         What you can hire him for.
├─ modules/                One per domain, each with a public API in index.ts.
│  ├─ activity/            domain · data · ui — the GitHub contribution graph
│  ├─ experience/          domain · data · ui — the work timeline
│  ├─ hub/                 The neutral sections: hero, what I do, about, contact
│  ├─ i18n/                Locales, dictionaries, localized paths
│  ├─ projects/            domain · data · ui — case studies and the work grid
│  ├─ seo/                 Open Graph and JSON-LD
│  ├─ services/            The offerings list behind /[lang]/services
│  └─ theming/             Theme registry, the worlds, the light/dark toggle
├─ content/projects/       The case studies as MDX, one folder per locale
├─ shared/                 ui primitives and config. Never imports a module.
├─ mdx-components.tsx      How a case study body maps to elements
└─ app/globals.css         Tailwind entry and the semantic token contract

Dependencies point one way — app → modules → shared — and a module is reached through its index.ts. ESLint enforces it, so a deep import fails npm run lint rather than review.

Server and client components

Everything is a Server Component unless a file opens with "use client". On a static export that means it runs at build time and ships no JavaScript, which is why the content is in the HTML.

Two components cross into the browser, and both earn it:

  • SchemeToggle needs a click handler and localStorage.
  • LanguageSwitcher needs usePathname(). A Server Component does not know the path, and when this took it as a prop, a page that forgot to pass it linked to the locale home instead of its own translation, with no error and no failing build.

"use client" is contagious downward: everything the file imports becomes client too. Keep it as low in the tree as possible, and when a client component has to wrap static content, pass that content as children so it keeps rendering on the server.

Watch the bundle size next build prints. If it grows, someone added a "use client" that did not need to be there.

Adding a case study

A case study is the page of a project. There is no separate collection: a project's page is its case study.

  1. Write src/content/projects/en/<slug>.mdx and its Spanish twin in src/content/projects/es/<slug>.mdx. Both need the same slug; that is what pairs them for hreflang.
  2. Fill the frontmatter. The Zod schema is the contract, and the build fails on anything missing or unknown, including a theme that is not registered.
  3. Follow the body structure: problem, constraints, solution, stack, result, what you would improve.
  4. Leave draft: true until it is finished. Drafts render while developing and are absent from a production build, so nothing half-written ships.
  5. Flip draft in both locales together. The build fails if they disagree, because publishing one translation and not the other ships a page in one language while the switcher on it points at a 404 in the other.

Screenshots go in public/case-studies/<slug>/, as WebP. The one under the title is cover in the frontmatter, and the layout renders it:

cover:
  src: /case-studies/stm/catalog.webp
  width: 1280
  height: 775
  alt: 'The STM.co catalogue: …'

Any further shot goes in the body as a plain <img>:

<img src="/case-studies/stm/table.webp" width="1280" height="1151" loading="lazy" alt="…" />

Both are styled by .case-study img in globals.css, not by a utility class: a literal <img> written in MDX is a plain element and never passes through mdx-components.tsx, so a class there would style the cover and silently skip everything in the body. loading="lazy" is written per image because the cover is the opposite case — it is the LCP element and loads eagerly.

Either way the width and height are the file's real pixel size, and they are not decoration: without them the browser learns the aspect ratio only once the file arrives, and the page shifts under whoever is already reading it.

The alt is written in the language of the file — it is prose, not metadata — but the src is not: a cover present in one locale and missing in the other fails the build, the same way a disagreeing draft does.

Never invent a number. An empty result section beats an estimated one, and period is optional precisely so a missing date does not become a guess.

client and category share one slot — the eyebrow on the hub card — and a project carries one or the other, never both. client is for professional work there is permission to name, and its absence is what makes a project a side project; there is no separate type field to keep in sync with it. category says what a side project is about instead, the way Atlas says Crypto Market. Setting both is not rejected by the schema today, and client wins.

One constraint that only shows up at the edge: output: 'export' requires every dynamic route to generate at least one page, so the build fails outright if every case study is a draft. With one published, drafting the rest works normally.

Announcing an upcoming project

The hub grid also carries the projects that do not exist yet, so the same grid shows what shipped and what is next.

  1. Add the slug to UPCOMING_PROJECTS in src/modules/projects/domain/upcoming.ts, in build order.
  2. Add upcoming.<slug>.title and upcoming.<slug>.summary to both dictionaries. The slug is a literal type, so a missing key fails the type-check rather than rendering an empty card.
  3. Remove the slug when its case study lands. A published project and an announcement of the same project in one grid is a bug you only notice from the outside.

An entry carries nothing but a slug on purpose: a project that has not been built has no role, no stack and no theme to show, and a draft case study cannot announce it because drafts are absent from a production build.

Keep the list short. Each card is a visible promise, and a wall of them reads as a backlog rather than as work.

Creating a theme

Two worlds exist: the neutral base the hub uses, and markets for financial and market-data work. A world gets its token file when its project does.

A theme is a world: its own palette, type and radii, applied by changing one frontmatter field.

  1. Add src/modules/theming/themes/<name>.css with a [data-theme='<name>'] block that redefines the semantic tokens.
  2. Import it from src/app/globals.css, next to the other worlds. Tailwind needs a single CSS entry, which is why the import points into the module. Keep it unlayered: Tailwind emits its own tokens inside @layer theme, and unlayered rules win over layered ones, which is what lets a world override them from a wrapper element rather than from :root.
  3. Register the name in THEME_NAMES in src/modules/theming/domain/theme-registry.ts. That array is the single source of valid names and the content schema reads it, so an unregistered theme fails the build instead of rendering unstyled.
  4. Check contrast in both schemes before committing. Every token pair that carries text must reach AA.

One rule that is easy to get wrong: text sitting on a fill of --color-accent takes --color-accent-fg, never --color-fg. In dark both fg and the lime accent are light, and the pair reads at 1.3:1.

Components read semantic tokens and never a raw color, font or radius, so adding a world touches no component.

One token is out of a world's reach on purpose: --radius-image, the 8px corner every screenshot is cut with. A world restyles its own buttons and chips through --radius-base; the screenshots stay identical across the site.

About

Website Professional Developer Portfolio

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages