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.
- Node 24.21.0, the active LTS.
nvm use 24.21.0if you keep several. - npm. The lockfile is
package-lock.json.
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.
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.
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.
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:
SchemeToggleneeds a click handler andlocalStorage.LanguageSwitcherneedsusePathname(). 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.
A case study is the page of a project. There is no separate collection: a project's page is its case study.
- Write
src/content/projects/en/<slug>.mdxand its Spanish twin insrc/content/projects/es/<slug>.mdx. Both need the same slug; that is what pairs them for hreflang. - Fill the frontmatter. The Zod schema is the contract, and the build fails on
anything missing or unknown, including a
themethat is not registered. - Follow the body structure: problem, constraints, solution, stack, result, what you would improve.
- Leave
draft: trueuntil it is finished. Drafts render while developing and are absent from a production build, so nothing half-written ships. - Flip
draftin 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.
The hub grid also carries the projects that do not exist yet, so the same grid shows what shipped and what is next.
- Add the slug to
UPCOMING_PROJECTSinsrc/modules/projects/domain/upcoming.ts, in build order. - Add
upcoming.<slug>.titleandupcoming.<slug>.summaryto both dictionaries. The slug is a literal type, so a missing key fails the type-check rather than rendering an empty card. - 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.
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.
- Add
src/modules/theming/themes/<name>.csswith a[data-theme='<name>']block that redefines the semantic tokens. - 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. - Register the name in
THEME_NAMESinsrc/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. - 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.
