# AGENTS.md

This file is the single source of truth for **any AI agent** (Cursor, Claude
Code, Copilot, Devin, etc.) working on this repository. Read it once at the
start of a session, then keep it open as a reference.

For human-facing context see [`README.md`](./README.md). For the deeper
"how is this organised and why" story see [`ARCHITECTURE.md`](./ARCHITECTURE.md).
For visual identity, design tokens, and UI style guidance see
[`DESIGN.md`](./DESIGN.md).

---

## 1. What this is

**`betomuniz.com`** is the personal website + portfolio of Beto Muniz. It
was originally framed as a **monorepo of side projects sharing
infrastructure**; as of 2026 every embedded side project has been
extracted into its own repo; the site is now a focused
portfolio shell — home, blog, side projects — that links out to the
spinoffs.

Public surface:

| Route                   | Purpose             | Source of data                                          |
| ----------------------- | ------------------- | ------------------------------------------------------- |
| `/`                     | Home + about        | Static                                                  |
| `/blog`, `/blog/[slug]` | Blog + videos index | Notion CMS → synced local markdown; YouTube Data API v3 |
| `/labs`                 | Side projects index | Static                                                  |

> **Consolidated routes (2026-10).** `/about` now lives on the home page,
> `/videos` is merged into `/blog`, and `/open-source` is gone — its notable
> repos are tiles on `/labs`. All three old paths 308-redirect in
> `vercel.json`.
>
> **Extracted side projects.** Three side projects used to live in this
> repo and have been lifted into their own repo in 2026 for
> independent release cadence:
>
> - `/fm` → **LoFM** ([repo](https://github.com/obetomuniz/lofm))
> - `/news` → **newsDev** ([repo](https://github.com/obetomuniz/newsdev))
> - `/jobs` → **Labuta**, formerly Jobs ([repo](https://github.com/obetomuniz/jobs))
>
> The old paths are not redirected yet (see below). The product
> command-bar actions and floating CTA have been removed from the portfolio.
> Tiles retain their existing previews and descriptions. A card has a link only
> when its `url` is a confirmed public destination; the extracted repositories
> are private. Add redirects and project links when real product domains are
> established.

---

`/jobs` redirects to <https://labuta.tech/> and `/fm` to
<https://lofm.betomuniz.com/>. `/news` is not redirected: its
placeholder `example.com` redirect was removed in 2026-10, so it 404s until
that product has a confirmed public domain. Add a 308 redirect in
`vercel.json` when one exists.

## 2. Stack & runtime

- **Astro 7** in **hybrid mode** (`output: "static"` + `adapter: vercel()` from `@astrojs/vercel`) on **Vercel**. Every page is statically generated by default; individual routes opt into server-on-demand by exporting `prerender = false`. Today only `src/pages/api/send-email.ts` does this — it backs the `send_contact_email` WebMCP tool and proxies to Resend. The rest of the site is pure SSG.
- **React 19** islands for interactive bits (`client:load`, `client:only="react"`)
- **Tailwind CSS v4** via `@tailwindcss/vite` (no `@astrojs/tailwind`)
- **TypeScript** strict, ESM (`"type": "module"`)
- **pnpm** for install + scripts (`pnpm-lock.yaml` is the source of truth). Version is pinned via the `packageManager` field in `package.json` and auto-provisioned by Corepack on machines that have it enabled (`corepack enable`).
- **Node** `^20.19.1 || >=22.12.0` (declared in `engines`; `.nvmrc` pins `24`; pnpm provisions `24.21.0` for scripts through `devEngines.runtime`). Vercel reads `.nvmrc` for the deploy runtime; when bumping Node update `.nvmrc`, `engines`, and `devEngines.runtime` together.
- **Vercel installs from scratch** (`installCommand` in `vercel.json` removes `node_modules` first). Vercel's restored dependency cache once left a package link missing after new dependencies were added, which broke the build (`Cannot find package 'tslib'`); a clean install avoids that at the cost of a few seconds.
- **`pnpm-workspace.yaml`** holds the `allowBuilds` allowlist (currently `canvas`, `esbuild`, `sharp`). pnpm 10+ blocks postinstall scripts by default, so any new native-binding dep needs to be added there or its install will silently skip.
- **WebMCP** — the site exposes three agent-callable tools via the native `navigator.modelContext` browser API (W3C, Chrome 146+ behind a flag). Registered through `@web-ai-sdk/webmcp` from `src/utils/webmcp.ts`. See § 7 (Common tasks → Add a new WebMCP tool).

---

## 3. Folder map

```
.
├── src/
│   ├── pages/            # Astro routes (one file = one URL)
│   │   ├── blog/[slug].astro   # SSG-paginated from Content Collections
│   │   ├── og/[...path].png.ts # Social preview image per page (build time)
│   │   └── api/                # Static + server-on-demand JSON endpoints
│   │       ├── posts.json.ts        # prerender=true (build-time blog manifest)
│   │       └── send-email.ts        # prerender=false (Resend proxy, the only
│   │                                # server function — backs the WebMCP
│   │                                # send_contact_email tool)
│   ├── layouts/          # base.astro — the single page shell
│   ├── assets/art/       # Site artwork (optimised by astro:assets)
│   ├── components/
│   │   └── shared/       # Cross-cutting components (Menu, Cut,
│   │                     # PageHeader, CMD, WebMCP, ...)
│   ├── services/         # Data fetchers — blog, youtube
│   │   ├── blog.ts       # Blog (Content Collections — reads synced local .md)
│   │   └── youtube.ts    # Videos (native fetch, no SDK)
│   ├── config/           # Site-wide constants split by domain
│   │   ├── routes.ts     # SITE_ROUTES (portfolio navigation and kbar entries)
│   │   ├── art.ts        # ART (which artwork belongs to which page)
│   │   ├── projects.ts   # SIDE_PROJECTS
│   │   ├── feeds.ts      # GH / YT allowlists
│   │   └── index.ts      # Barrel re-export (powers `@constants`)
│   ├── types/            # Shared domain types (GitHubProject, YouTubeVideo, BlogPost, ...)
│   ├── utils/            # Tiny helpers (dates, mermaid rendering) and the
│   │                     # delegated client scripts (menu, blog filter, previews)
│   ├── styles/global.css # Tailwind v4 entry + @theme + @utility + @custom-variant
│   └── env.d.ts          # `astro:env` is the source of typed env now
├── public/               # Static assets (icons, vectors, gifs, videos)
├── astro.config.mjs      # Integrations + `astro:env` schema + Tailwind Vite plugin
├── tsconfig.json         # Path aliases — see table below
├── eslint.config.js      # ESLint v9 flat config
├── biome.json            # Secondary auditor (scoped to .ts/.tsx — see § 5)
├── knip.json             # Dead-code & unused-deps detection
├── .prettierrc.json      # Prettier + plugins
├── DESIGN.md             # Visual identity and design-token guidance for agents
├── vercel.json           # Redirects (legacy URLs)
└── .env.local            # Secrets (DO NOT EDIT — see § 8)
```

Path aliases declared in `tsconfig.json`:

| Alias           | Maps to                                    |
| --------------- | ------------------------------------------ |
| `@constants`    | `src/config/index.ts` (back-compat barrel) |
| `@config/*`     | `src/config/*` (preferred for new code)    |
| `@assets/*`     | `src/assets/*`                             |
| `@services/*`   | `src/services/*`                           |
| `@utils/*`      | `src/utils/*`                              |
| `@components/*` | `src/components/*`                         |
| `@layouts/*`    | `src/layouts/*`                            |
| `@pages/*`      | `src/pages/*`                              |
| `@styles/*`     | `src/styles/*`                             |
| `@types`        | `src/types/index.ts`                       |

Always use the alias. Do **not** write `../../../components/...`.

Env vars are declared in `astro.config.mjs` under `env.schema` and consumed
via `import { … } from "astro:env/server"`. Don't reach for `import.meta.env`.

---

## 4. Commands

All scripts are defined in `package.json` and run via `pnpm run <script>` (or the shorthand `pnpm <script>` for non-reserved names).

| Command                      | What it does                                                                                        | When to run                       |
| ---------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------- |
| `pnpm install`               | Install deps and generate / verify `pnpm-lock.yaml`                                                 | After clone / dep change          |
| `pnpm run sync:blog`         | Sync published posts from Notion → `src/content/blog/`                                              | Auto (runs before dev only)       |
| `pnpm dev`                   | Run `sync:blog` (via `predev`), then Astro dev server on `localhost:4321`                           | Local development                 |
| `pnpm run setup:worktree`    | Link source-checkout credentials, preserving existing `.env.local`, and install locked dependencies | New Git worktrees                 |
| `pnpm run teardown:worktree` | Stop background Astro CLI servers and unlink worktree credentials, preserving the source file       | Worktree archive / manual removal |
| `pnpm run dev:worktree`      | Start a foreground Astro server using Paseo's assigned host, port, and proxy hostname               | Paseo `dev` service               |
| `pnpm build`                 | Copy docs into `public/` (via `prebuild`), then production SSG into `dist/`                         | Verify before commit              |
| `pnpm preview`               | Serve `./dist/` locally                                                                             | Sanity-check production output    |
| `pnpm run typecheck`         | `astro check` (TS + Astro template type errors)                                                     | CI gate                           |
| `pnpm run lint`              | ESLint v9 — primary linter                                                                          | CI gate                           |
| `pnpm run lint:fix`          | ESLint with autofix                                                                                 | Quick fixups                      |
| `pnpm run lint:biome`        | Biome — **secondary auditor**, scoped to `.ts`/`.tsx` only                                          | Occasional audit (see § 5)        |
| `pnpm run lint:design`       | DESIGN.md spec lint — token references, structure, and contrast                                     | CI gate                           |
| `pnpm run format`            | Prettier write across repo (also sorts imports + Tailwind classes)                                  | Before commit                     |
| `pnpm format:check`          | Prettier verify-only                                                                                | CI gate                           |
| `pnpm run knip`              | Find dead files / unused exports / unused deps                                                      | Periodic audit                    |
| `pnpm run gate`              | Runs all seven gates sequentially, stops at first failure                                           | Before any commit/PR              |

**Run before any commit:** `pnpm run gate`.

The root `paseo.json` configures automatic worktree setup, teardown, and a managed `dev`
service with a separate port per workspace. Setup may create an ignored
`.env.local` symlink to the source checkout when no environment file exists;
it must never inspect credentials, overwrite an existing file, or modify the
source file. Keep dependencies and generated output local to each worktree.
On archive, teardown stops background Astro CLI servers and removes only the
credential symlink. Paseo stops managed services and removes the worktree;
teardown must refuse to run in the primary checkout.
The service renders committed blog content; use its `sync:blog` script to
refresh from Notion explicitly. See README's Git worktrees section for manual
Git setup and Vercel credential refresh.

---

## 5. Linting & formatting setup (read this carefully)

There are **three** tools running with deliberately distinct responsibilities.
Don't try to consolidate them — each is here for a specific reason.

| Tool                                                                                                        | Scope                        | Why                                                                                                                                                                                                                                                                              |
| ----------------------------------------------------------------------------------------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **ESLint** (`eslint-plugin-astro`, `typescript-eslint`, `react-hooks`)                                      | All files including `.astro` | Astro-template-aware rules (e.g. `astro/missing-client-only-directive-value`) that no other tool catches                                                                                                                                                                         |
| **Prettier** (+ `prettier-plugin-astro`, `prettier-plugin-organize-imports`, `prettier-plugin-tailwindcss`) | All files                    | Formatting + import sorting + Tailwind class sorting                                                                                                                                                                                                                             |
| **Biome** (scoped to `.ts`/`.tsx`/`.js` only — **NOT** `.astro`)                                            | TS/TSX only                  | Secondary audit catching a11y, hook-deps, and React-key issues that ESLint setup misses. Biome doesn't yet understand Astro template syntax — running it on `.astro` yields **catastrophic false positives** ("unused imports" → would delete every component import on autofix) |

**Therefore:**

- ❌ Never run `pnpm run lint:biome --write` on `.astro` files (the config already excludes them, but be careful with explicit paths).
- ❌ Never replace ESLint with Biome wholesale. Biome's Astro template support is incomplete as of 2026.
- ✅ Use Biome's findings for hook-deps, a11y, and React-key cleanup.

---

## 6. Conventions (the rules to follow)

### TypeScript

- **No `any`.** All `any` was swept; reintroducing it should be intentional and commented.
- **Path aliases** over relative imports.
- **Type-only imports** with `import type { ... }`.
- **Domain types** live in `src/types/index.ts` — extend that file rather than inlining shapes.

### React (inside `.tsx` islands)

- **`client:only="react"`** — Astro 7 requires the framework name as the value (just `client:only` is a lint error).
- **Stable keys** — never `key={index}`; use a stable property (`job.url`, `tag.value`, etc.).
- **`type="button"`** on every `<button>` to prevent implicit form submission.
- **No `autoFocus`** — bad for screen readers.
- **No accessing refs during render** — refs are not reactive; use state for UI-affecting flags.

### Tailwind v4

- The whole config lives in `src/styles/global.css` (`@theme`, `@custom-variant`, `@utility`).
- `DESIGN.md` is the human/agent-facing description of the visual system. Keep it aligned when changing brand colors, typography, spacing rhythm, shape language, or reusable component styling.
- **Tailwind v4 unifies sizing under `--spacing-*`.** Naming a custom `--spacing-sm` shadows `max-w-sm` (the legacy v3 `maxWidth` scale is gone). The only project-specific spacing token kept is `--spacing-page: 1024px`.
- **Component classes go in `@layer components`** in `global.css`. Unlayered rules beat every Tailwind utility, which silently breaks `md:hidden` and friends on those elements.
- **Mutually-exclusive conditional classes** — when a state changes utilities (e.g. text size while typing), use `cn(isSearching ? "text-[85px]" : "text-[170px]")`, **not** `cn({ "text-[170px]": true, "text-[85px]": searching })`. Tailwind v4 changed the source-order rules for arbitrary-value utilities; the v3 "later class wins" pattern silently breaks.

### Astro

- **View Transitions** are enabled (`<ClientRouter />` in the base layout). For islands that need to keep state across nav, mark them `transition:persist="<unique-id>"` (we do this for `<Menu>` and `<WebMCP>`). Persisted markup is not re-rendered on navigation — anything page-dependent inside it must be updated from `astro:page-load` or driven by `<html>` attributes.
- **Client scripts** live in `src/utils/*.ts` and are imported from a bare `<script>` (`import "@utils/blog-filter"`). Module scripts run once per tab under the client router, so attach listeners to `document` (delegation) or re-run on `astro:page-load` — never bind to elements at module top level.
- **Motion** must stay on the compositor: animate `transform` and `opacity` only, no `requestAnimationFrame` loops, no timers, and honour `prefers-reduced-motion` (see DESIGN.md → Motion).
- **`getStaticPaths`** in dynamic routes returns typed params from the typed state functions — don't reintroduce `(item: any) =>`.

### Astro `.astro` files

- ESLint and Prettier handle these. Biome does not (see § 5).
- Imports in the frontmatter that are only used in the **template** are still real imports — Biome will flag them as "unused", which is wrong. Ignore those false positives or rely on the scoped Biome config.

---

## 7. Common tasks (cookbook)

### Add a new side project tile

1. Add an entry to `SIDE_PROJECTS` in `src/config/projects.ts`.
2. Drop the cover/video asset into `public/assets/videos/<id>` (mp4 + webm) or set `cover` to a URL.
3. If the project gets its own page, add a `src/pages/<route>.astro` and a route entry in `src/config/routes.ts` — the menu and `kbar` read from it.

### Add or replace artwork

1. Follow the rules in DESIGN.md → Artwork (periwinkle duotone, subject on the right, 16:9).
2. Save it as WebP (~2400px wide) in `src/assets/art/` and map it in `src/config/art.ts`.
3. Show it through a heading with `<Cut lines={[…]} art={["…"]} />` (or `<PageHeader art="…" />`). Artwork is never rendered as a plain image.

### Add a new env variable

1. Set the value in `.env.local` (gitignored) and `.env.local.example` (committed).
2. Declare it under `env.schema` in `astro.config.mjs` using `envField.string({ context: "server", access: "secret" })`.
3. Add the variable to **Vercel project settings** for production.
4. Import it via `import { NAME } from "astro:env/server"` — strictly typed, no fallback to `import.meta.env`.

### Add a new server-on-demand endpoint

The site is hybrid: static-by-default with per-route opt-in via `prerender = false`. Use this only when SSG truly cannot work (mutating, secret-bearing, or per-request endpoints — e.g. `send-email`).

1. Create `src/pages/api/<name>.ts` exporting a method handler (`POST`, `GET`, …) typed as `APIRoute`.
2. Add `export const prerender = false;` at the top.
3. The `@astrojs/vercel` adapter packages it as a Vercel Serverless Function automatically. `pnpm dev` serves it natively too — no `vercel dev` needed.
4. For request-time data: the handler receives `{ request, clientAddress, … }` from Astro. `clientAddress` is populated by the Vercel adapter.
5. Read secrets via `import { … } from "astro:env/server"` (must be declared as `secret` in the schema first — see "Add a new env variable").

### Add a new WebMCP tool

Tools are registered in `src/utils/webmcp.ts` through `registerTools` from `@web-ai-sdk/webmcp` on top of the native W3C API. It is a plain module imported by the base layout, not a React island.

1. Decide the safety profile:
   - Read-only (lookups, lists) → `readOnly: true`.
   - Mutating (sends, writes, payments) → `destructive: true`. Require explicit human confirmation before invocation; the WebMCP host handles this annotation.
2. Add a tool to the `readonly Tool[]` array with `name`, `description`, `inputSchema`, safety flags, and `execute`. Keep `description` agent-friendly (a short instruction telling an LLM when to call it). Use JSON Schema for `inputSchema` with per-property `description` strings.
3. If the tool fetches build-time data (e.g. blog posts), back it with a `prerender = true` Astro endpoint under `src/pages/api/<thing>.json.ts`. If it mutates, back it with a `prerender = false` endpoint (see recipe above) so `from`/secrets stay server-side.
4. The array is passed to `registerTools(tools)` once per tab.
5. Test in Chrome 146+ with the WebMCP testing flag enabled:
   ```js
   await navigator.modelContextTesting.listTools();
   await navigator.modelContextTesting.executeTool("<name>", JSON.stringify({...}));
   ```

### Update a Tailwind theme token

Edit `@theme { ... }` in `src/styles/global.css`. Do **not** create a `tailwind.config.cjs` — Tailwind v4 doesn't use it.

### Track a future improvement / refactor idea

Open a GitHub issue with the [`roadmap`](https://github.com/obetomuniz/betomuniz.com/issues?q=is%3Aissue+is%3Aopen+label%3Aroadmap) label (use `gh issue create --label "roadmap,enhancement"`). Do **not** add a bullet to `ARCHITECTURE.md`'s "Intended future evolution" section — issues are the single source of truth so nothing drifts out of sync. When the work lands, close the issue rather than editing the doc.

---

## 8. Don't-touch zones

| Path / file                                                          | Why                                                                                       |
| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `.env.local`                                                         | Secrets. Never read, modify, or print. Variable _names_ are documented in `src/env.d.ts`. |
| `pnpm-lock.yaml`                                                     | Lockfile — only update via `pnpm install` / `pnpm add`. Don't hand-edit.                  |
| `.astro/`                                                            | Auto-generated by `astro sync`. Never commit.                                             |
| `dist/`                                                              | Build output. Never commit.                                                               |
| `node_modules/`                                                      | Obvious.                                                                                  |
| `public/icons/`, `public/assets/vectors/beto-muniz-illustration.svg` | Brand assets — don't recreate or modify without permission.                               |
| `vercel.json` redirects                                              | Legacy URL preservation — only add; remove only when the owner asks.                      |

---

## 9. Quality gates

A change is "ready" when **all seven** of these pass:

```bash
pnpm run gate          # chains all seven, stops at first failure
```

Or run each individually if you want to drill in:

```bash
pnpm run lint           # 0 errors
pnpm run lint:biome     # 0 errors
pnpm run lint:design    # 0 errors (DESIGN.md token references and structure)
pnpm run typecheck      # 0 errors (catches stuff ESLint misses)
pnpm run knip           # 0 issues
pnpm run format:check   # all clean
pnpm run build          # ✓ Complete
```

Visual smoke test (recommended for any UI-touching change):

1. `pnpm dev`
2. Walk every route at desktop & mobile widths
3. Compare against production at https://betomuniz.com

---

## 10. Glossary

- **Side project** — One of the products surfaced on `/labs`. As of 2026 every side project has its own repo (`LoFM`, `newsDev`, `Labuta`); see § 1 for which old paths redirect. See `SIDE_PROJECTS` in `src/config/projects.ts` for the tiles.
- **Art** — One of the periwinkle duotone illustrations in `src/assets/art/`, shown only through the letters of a `<Cut>` heading.
- **CMD / kbar** — The `Cmd+K` / `Ctrl+K` command-bar overlay. **Currently not mounted** (2026-10): the menu shows two-key shortcuts instead, handled in `src/utils/site-controls.ts`. The code is kept for later. Implemented with `kbar` (in beta — pinned). Searches pages, contact links, and every blog post.
