← Back to agents

AGENTS.md from gupta-akshay/portfolio-v2

1 starsLast commit Sep 1, 2026

AGENTS.md

This file provides guidance to AI Agents when working with code in this repository.

Commands

```bash pnpm dev # Start dev server (Next.js + Turbopack) pnpm build # Production build pnpm lint # Run ESLint (with 8GB heap limit) pnpm peaks:generate # Regenerate music waveform peaks from S3 (needs ffmpeg + AWS creds) pnpm images:to-webp # Convert images in public/ to WebP pnpm db:generate # Generate Drizzle migrations from schema changes pnpm db:migrate # Apply migrations to Neon/PostgreSQL pnpm db:studio # Open Drizzle Studio UI ANALYZE=true pnpm build # Build with bundle analyzer ```

`pnpm test` runs the handful of `*.test.ts` files on Node's built-in test runner (`node --test`) — there is no test framework, and only non-obvious logic has a check (currently the contact form's HTML escaping). Package manager is **pnpm**; Node **v24** (`.nvmrc`).

Architecture

This is a **Next.js 16 App Router** portfolio site (V5) with React 19, TypeScript, and Sass modules.

Directory structure

``` src/app/ # App Router — pages, layouts, API routes, contexts, components src/app/data/ # Generated data committed to the repo (track-peaks.json) src/lib/mdx/ # Blog post loader (getBlogBySlug, getAllBlogs, getBlogHeadings) + zod metadata schema content/blog/ # MDX blog posts (each exports a `metadata` object) db/ # Drizzle ORM schema (anonymousUsers, blogReactions) + db client migrations/ # SQL migration files (generated by drizzle-kit) scripts/ # Offline tooling (peaks generation, image conversion) public/ # Static assets ```

Key data flows

**Blog pipeline**: MDX files in `content/blog/` are imported dynamically via `src/lib/mdx/index.ts`. The `metadata` export in each `.mdx` file is the source of truth (title, slug, date, categories, etc.) and is validated against `BlogMetadataSchema` (zod) in `src/lib/mdx/schema.ts` — invalid metadata logs and drops the post (returns `null`). Posts with `draft: true` are hidden in production only. Heading IDs are generated by `rehype-slug`; `getBlogHeadings()` re-derives matching IDs for the table of contents. MDX remark/rehype plugins (gfm, slug, prism) are configured in `next.config.mjs`, not per-file. Mermaid fences are left alone at build time and hydrated on the client by `MermaidRenderer`. The blog index paginates client-side in `src/app/blog/BlogList.tsx` (ten per page) — every post is already in the payload, so "load more" is a pure reveal with no extra request.

**Reactions**: Anonymous emoji reactions are persisted in Neon/PostgreSQL via Drizzle ORM. The `db/schema.ts` defines two tables: `anonymous_users` (browser-fingerprinted) and `blog_reactions`. The API routes live at `src/app/api/reactions/route.ts`.

**Music delivery (signing)**: `src/app/utils/aws.ts` lists tracks from S3 and parses metadata from the file key naming convention `[year][originalArtist][name][type][artist].mp3`. URLs are signed via CloudFront if `CLOUDFRONT_*` vars are set, else fall back to S3 pre-signed URLs.

**Music player**: `src/app/components/AudioPlayer/` composes `TrackList`, `WaveformSeeker`, `PlayerBar`, `QueuePanel` and `Toast` over three hooks — `useAudioPlayback`, `useQueueManager`, `useKeyboardShortcuts`. The bar, queue drawer and toast are rendered through `createPortal` to `<body>`: they are viewport-level overlays and a positioned ancestor would otherwise trap them beneath the fixed nav.

**Waveform peaks**: peaks are **precomputed offline** by `scripts/generate-track-peaks.mjs` (`pnpm peaks:generate`) into `src/app/data/track-peaks.json` — 400 byte-quantized buckets plus the exact duration per track. Decoding in the browser would double CloudFront egress (the `<audio>` element streams its own copy) and the serverless runtime has no decoder. Durations ship with the track listing; peaks are returned per-track by `/api/music/url`. Tracks missing from the JSON fall back to a flat placeholder strip. Re-run the script when tracks change.

**Contact form**: `src/app/api/sendMail/route.ts` sends emails via Resend using HTML templates in `src/app/utils/apiUtils/`.

**Theme**: `ThemeContext` stores dark/light preference in `localStorage` and toggles `theme-light` on `<body>`; an inline script in `layout.tsx` applies it before hydration (hence `suppressHydrationWarning` on `<html>`/`<body>`). Sass variables in `src/app/styles/variables.scss` drive all theming; components check `.theme-light &` or `:global(body.theme-light)` for light-mode overrides.

**Navigation**: `src/app/components/Layout/SiteNav.tsx` is a fixed top bar (there is no sidebar). Below 820px the links collapse into a dropdown with a backdrop. Scroll locking pins `body` to `position: fixed` at its current offset — `overflow: hidden` on the root is not reliable in Chromium and is ignored by iOS Safari.

Cross-cutting infra

  • **Env validation**: `src/env.ts` exports a `serverEnv` zod-validated proxy (lazy — validated on first access). Import it instead of reading `process.env` on the server; reading `serverEnv.*` on the client throws. `NEXT_PUBLIC_*` vars are read as `process.env.NEXT_PUBLIC_…` literals at their use site, because that is the only form Next inlines into the client bundle. `SKIP_ENV_VALIDATION=true` bypasses (for Docker build stages). `src/lib/site-url.ts` `getSiteUrl()` is the canonical origin for metadata/feeds/sharing.
  • **Path aliases** (`tsconfig.json`): `@/*` → `src/*`, `@/content/*` → `content/*`.
  • **`exactOptionalPropertyTypes` is on.** Passing an explicitly `undefined` value to an optional prop is an error — either omit the key with a conditional spread (`...(x !== undefined && { x })`) or widen the prop to `string | undefined`.
  • **Error monitoring**: Sentry via `@sentry/nextjs`. Config in `sentry.{server,edge}.config.ts`, `src/instrumentation*.ts`, and `withSentryConfig` in `next.config.mjs` (tunnelRoute `/monitoring`). Use `src/app/utils/logger.ts` for logging.
  • **Security headers / CSP**: defined in `next.config.mjs` `headers()`. Adding a new external script/style/image/connect origin requires updating the matching CSP directive there or the browser blocks it. The separate, stricter `images.contentSecurityPolicy` guards SVGs passing through the image optimizer (`dangerouslyAllowSVG: true`) — do not loosen it.
  • **Rate limiting**: `src/app/utils/ratelimit.ts` — per-instance in-memory only (not coordinated across serverless instances); guards casual abuse, not DDoS. Used by API routes.
  • **SEO/feeds**: `src/app/sitemap.ts`, `robots.ts`, `manifest.ts`, `feed.xml/route.ts`, and per-route `opengraph-image.tsx` generate metadata/OG images dynamically. Every route renders the same card through `src/lib/og.tsx` — routes pass copy, never layout. Satori cannot read the `next/font` woff2s, so the card loads its own static TTFs from `src/lib/fonts/` with `readFileSync`; `fetch(new URL(…, import.meta.url))` (the pattern in the Next docs) is **not implemented by Turbopack** and fails the build. `outputFileTracingIncludes` in `next.config.mjs` pins that directory into the serverless bundles (nft traces it anyway; the glob guards against a path refactor silently dropping it, which would only surface as a production ENOENT).

Icons

All icons render through `src/app/components/Icon/Icon.tsx`, a zero-dependency component wrapping inline SVG. There is no icon font and no icon runtime — there are no `@fortawesome/*` or `devicon` packages; **do not add them back**.

Two typed records back it:

  • `icons.ts` — UI glyphs, keyed by `IconName`. Single-path, monochrome, inheriting `currentColor`.
  • `techIcons.ts` — technology logos, keyed by `TechIconName`. Multi-shape, each path keeping its brand `fill`.

`IconData` accepts either `path: string` or `paths: { d, fill? }[]`. Usage is identical for both: `<Icon name="play" />`, `<Icon name="typescript" />`. Props: `name` (required), `className`, `title`, `aria-hidden`, `spin`, `size` (`'2x'`|`'3x'`), `height`, `width`, `fontSize`.

To add a UI glyph, add an entry to `icons.ts` and extend `IconName`. To add a technology logo, take the source SVG, normalise any `<circle>`/`<g fill>` into paths carrying their own fill, add it to `techIcons.ts`, and extend `TechIconName`. Brand logos are deliberately **not** tinted by the theme.

Styling conventions

  • Global styles: `src/app/styles/globals.scss` (imports all partials)
  • Sass variables/tokens: `src/app/styles/variables.scss`
  • Per-component: CSS Modules (`.module.scss`) co-located in component directories
  • No CSS-in-JS — pure Sass throughout

The design language is **neo-brutalist**: square corners, thick ink borders, hard unblurred offset shadows, stepped/instant transitions. When adding UI:

  • **Offset shadows must use `var(--shadow-ink)`** (set on `body` and flipped by `.theme-light`). It resolves to white on the dark canvas and black on the light one — a hardcoded black shadow is invisible on a near-black page.
  • **Amber is not usable as text on light backgrounds** (~1.6:1). Use `$px-theme-ink` for accent-coloured *text* inside `body.theme-light`; keep `$px-theme` for fills, borders and dark-mode text.
  • **Corners are square.** A global reset sets `border-radius: 0`; do not reintroduce radii, including on badges, avatars and circular controls.
  • Base element styles that components may need to override are written with `:where()` so they carry zero specificity — follow that pattern rather than reaching for `!important`.

Two build-level gotchas that have already caused silent breakage:

  • **`sassOptions.charset = false`** in `next.config.mjs` is required. Dart Sass otherwise prepends a UTF-8 BOM whenever a stylesheet's output contains a non-ASCII character; after bundling that BOM lands mid-chunk and the browser parses it as part of the next selector, silently dropping that rule in production as well as dev.
  • **Write only the unprefixed `backdrop-filter`.** Declaring `-webkit-backdrop-filter` by hand makes Lightning CSS drop the standard property, so the effect never applies anywhere. The build adds prefixes from browserslist.

React Compiler

`reactCompiler: true` is enabled in `next.config.mjs`. The compiler handles memoization automatically; avoid adding manual `useMemo`/`useCallback` unless there is a measurable reason. Note the lint rule against calling `setState` synchronously inside an effect body — prefer event handlers.

Adding a blog post

Create `content/blog/<slug>.mdx` with a top-level `metadata` export:

```mdx export const metadata = { title: 'Post Title', slug: 'post-slug', publishedAt: 'YYYY-MM-DD', categories: ['tag1'], coverImage: '/images/blog/cover.webp', coverImageAlt: 'Alt text', author: { name: 'Akshay Gupta', avatar: '/images/blog-author.webp' }, excerpt: 'Short description.', }; ```

Environment variables

See `.env.example` (copy to `.env.local`) and README.md for the full list; `src/env.ts` is the authoritative schema. Required for local dev: `DATABASE_URL`, `RESEND_API_KEY`, AWS vars, and `NEXT_PUBLIC_SITE_URL`. CloudFront vars are optional (S3 fallback).

Extra docs in repo root: `TROUBLESHOOTING.md`.