Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file removed �
Empty file.
55 changes: 55 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: CI

on:
pull_request:
push:
branches: [main]

# A superseded run tells you nothing useful; don't pay for it.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

# react-simple-maps declares a React <19 peer, so a plain `npm ci` fails.
# deploy.yml installs the same way.
- name: Install
run: npm install --legacy-peer-deps

- name: Typecheck
run: npx tsc --noEmit

- name: Unit tests
run: npm test -- --run

# Deliberately no Substack fetch: CI must not depend on the network or on
# dommangonon.substack.com being up. deploy.yml runs
# scripts/fetch-substack.js as its own step before building.
- name: Build
run: npm run build

- name: Install Playwright browser
run: npx playwright install --with-deps chromium

# playwright.config.ts starts its own dev server; reuseExistingServer is
# off when CI is set, so this can't silently test a stale server.
- name: E2E tests
run: npx playwright test

- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
14 changes: 12 additions & 2 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ name: Deploy to GitHub Pages
on:
push:
branches: [main]
# Picks up newly published Substack posts without a human push. The feed is
# fetched during the build (below) rather than committed by a cron, because
# pushes from github-actions[bot] with the default GITHUB_TOKEN do not
# trigger workflows — a commit-then-rebuild loop silently never rebuilds.
# Note: GitHub disables scheduled workflows after 60 days of repo inactivity.
schedule:
- cron: '0 11 * * *'
workflow_dispatch:

permissions:
Expand Down Expand Up @@ -30,10 +37,13 @@ jobs:
- name: Install dependencies
run: npm install --legacy-peer-deps

# Rewrites lib/content/writing.ts in the workspace only — never committed.
# Failures are warnings: the committed posts are used instead.
- name: Fetch Substack posts
run: node scripts/fetch-substack.js

- name: Build Next.js
run: npm run build
env:
CAREER_DIR: ${{ github.workspace }}/content/career

- name: Upload artifact
uses: actions/upload-pages-artifact@v3
Expand Down
92 changes: 52 additions & 40 deletions .github/workflows/update-dashboard-data.yml
Original file line number Diff line number Diff line change
@@ -1,40 +1,52 @@
on:
schedule:
- cron: '0 */6 * * *'
workflow_dispatch:

permissions:
contents: write

jobs:
update-data:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '20'

- name: Fetch Analytics Data
env:
GOATCOUNTER_API_KEY: ${{ secrets.GOATCOUNTER_API_KEY }}
GOATCOUNTER_SITE: ${{ secrets.GOATCOUNTER_SITE }}
run: node scripts/fetch-analytics.js

- name: Fetch Performance Data
env:
SITE_URL: ${{ vars.SITE_URL }}
run: node scripts/fetch-performance.js

- name: Fetch Uptime Data
env:
UPTIMEROBOT_API_KEY: ${{ secrets.UPTIMEROBOT_API_KEY }}
run: node scripts/fetch-uptime.js

- name: Commit updated data
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add public/data/
git diff --staged --quiet || git commit -m "chore: update dashboard data" && git push
name: Update dashboard data

on:
schedule:
- cron: '0 */6 * * *'
workflow_dispatch:

permissions:
contents: write

jobs:
update-data:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '20'

- name: Fetch Analytics Data
env:
GOATCOUNTER_API_KEY: ${{ secrets.GOATCOUNTER_API_KEY }}
GOATCOUNTER_SITE: ${{ secrets.GOATCOUNTER_SITE }}
run: node scripts/fetch-analytics.js

- name: Fetch Performance Data
env:
SITE_URL: ${{ vars.SITE_URL }}
run: node scripts/fetch-performance.js

- name: Fetch Uptime Data
env:
UPTIMEROBOT_API_KEY: ${{ secrets.UPTIMEROBOT_API_KEY }}
run: node scripts/fetch-uptime.js

# Note: this commit uses the default GITHUB_TOKEN, so the push does NOT
# trigger deploy.yml. The dashboard reads /data/*.json at runtime, so the
# fresh data is picked up by deploy.yml's own daily schedule.
- name: Commit updated data
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add public/data/
# `a || b && c` is left-associative: the old one-liner pushed even
# when there was nothing to commit.
if git diff --staged --quiet; then
echo "No dashboard data changes."
else
git commit -m "chore: update dashboard data"
git push
fi
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,14 @@ next-env.d.ts
# Windows NTFS zone identifiers
*:Zone.Identifier

# Career source docs and processing
# Career content — kept out of this public repo entirely.
# The site no longer reads it; source of record is ~/personal/career.
content/career/
public/assets/*.pdf
content/career/source-docs/
content/career/_processing/
content/career/package.json

# Playwright
test-results/
playwright-report/
169 changes: 95 additions & 74 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

Personal portfolio site built with Next.js 16, React 19, TypeScript, and Tailwind CSS 4. Static export (no server runtime).
Dom Mangonon's personal site: a **single-page** portfolio built with Next.js 16, React 19,
TypeScript, and Tailwind CSS 4. Static export (no server runtime), deployed to GitHub Pages
at https://dommango.github.io.

The page leads with the **project portfolio**, then writing, with career compressed to context.

## Commands

Expand All @@ -13,96 +17,113 @@ npm run dev # Start dev server (localhost:3000)
npm run build # Build static site (output: out/)
npm test # Run Vitest tests (watch mode)
npm test -- --run # Run tests once
npm test -- Button # Run tests matching "Button"
npm run lint # ESLint check
npm run lint -- --fix # Auto-fix lint issues
tsc --noEmit # Type check without emitting
npx tsc --noEmit # Type check
npx playwright test # E2E
```

## Build Process

The `prebuild` script runs automatically before `npm run build`:
1. **sync-career-content.js** - Copies career markdown from `$CAREER_DIR` (default: `/home/dom/personal/career`) to `content/career/`
2. **generate-resume-pdf.js** - Uses Puppeteer to generate PDF from HTML resume (gracefully skips if Chrome unavailable)

Static export config in `next.config.ts`: `output: 'export'` with `images.unoptimized: true`.
Note: `npm install` needs `--legacy-peer-deps` (react-simple-maps declares a React <19 peer).
CI uses it too.

## Architecture

The whole site is **one page**. `app/page.tsx` is a server component that loads data at build
time and hands it to `components/landing/BrutalistLanding.tsx`, a client shell holding theme +
scroll-spy state and composing every section.

```
app/ # Next.js App Router pages
├── layout.tsx # Root layout with Header, Footer, ChatBot
├── page.tsx # Home - ProfileHero, ProfileSummary, CareerThemes
├── career/ # Career timeline page
├── education/ # Education page
├── skills/ # Skills grid page
├── travel/ # Travel map page
├── blog/ # Blog listing/posts
├── contact/ # Contact form (uses EmailJS)
└── api/contact/ # Contact API route

components/
├── layout/ # Header, Footer
├── profile/ # ProfileHero, ProfileSummary, CareerThemes
├── chat/ # ChatBot widget
├── timeline/ # Career timeline
├── skills/ # Skills grid
├── education/ # Education section
├── travel/ # Travel map (react-simple-maps)
├── contact/ # Contact form
└── ui/ # Reusable primitives (Card, Badge, Section, Button)

lib/
├── content/ # Content loaders parsing markdown with gray-matter
│ ├── profile.ts # getProfile() - parses profile.md frontmatter
│ ├── roles.ts # getRoles() - career positions
│ ├── education.ts # getEducation()
│ ├── skills.ts # getSkills()
│ ├── blog.ts # getBlogPosts()
│ └── travel.ts # getCountries()
├── constants.ts # Shared constants
├── utils.ts # Utility functions
└── services/ # External service integrations

content/ # Markdown content (synced from CAREER_DIR)
├── career/ # Career entries with frontmatter
├── blog/ # Blog posts
└── travel/ # Travel data (JSON)
app/
├── layout.tsx # Root layout, fonts, metadata/JSON-LD, ChatBot
├── page.tsx # Loads projects/writing/travel data -> BrutalistLanding
├── globals.css # All styles (see Design System below)
└── dashboard-m7x9k2/ # Private-ish analytics dashboard (obscure URL, not linked)

components/landing/ # The page, in DOM order:
├── Nav.tsx # Sticky nav + theme cycler
├── Availability.tsx # Status strip under nav
├── Hero.tsx # Headline, portrait, bio
├── Projects.tsx # THE MAIN SECTION — project cards
├── Writing.tsx # Substack posts; renders only when posts exist
├── Resume.tsx # Compressed career timeline
├── Travel.tsx # Continent bars + globe
├── Contact.tsx # EmailJS-wired form
├── Footer.tsx
└── BinaryRule.tsx # Decorative divider (seeded PRNG — see below)

components/travel/TravelMap.tsx # Heavy globe, dynamically imported by landing/Travel
components/chat/ChatBot.tsx # Assistant widget
components/ui/ # Card, Skeleton, ProgressBar — dashboard only

lib/content/ # Build-time data (no fs, no runtime fetch)
├── projects.ts # Hand-authored PROJECTS array
├── writing.ts # Substack posts
└── travel.ts # Transforms content/travel/*.json
lib/services/ # emailjs.ts, chat.ts
```

## Content System
## Adding or changing a section

Career content lives in markdown files with gray-matter frontmatter:
- Source: `$CAREER_DIR` environment variable (default: `/home/dom/personal/career`)
- Build destination: `content/career/`
- Profile data parsed from `profile.md` frontmatter + content sections
Four places must stay in sync or the scroll-spy breaks silently:

Content loaders in `lib/content/` read files synchronously at build time for static generation.
1. `components/landing/<Section>.tsx` — the component
2. `SectionId` union in `Nav.tsx`
3. `SECTION_IDS` in `BrutalistLanding.tsx` — **must match DOM order**; the spy takes the last
element with `offsetTop <= scrollY`, so wrong order = wrong active link
4. The `link()` calls in `Nav.tsx`

## Environment Variables
Conditional sections (Writing) need the nav link gated by the same predicate as the section,
or the link scrolls to nothing.

```bash
# EmailJS for contact form
NEXT_PUBLIC_EMAILJS_SERVICE_ID=
NEXT_PUBLIC_EMAILJS_TEMPLATE_ID=
NEXT_PUBLIC_EMAILJS_PUBLIC_KEY=
## Design System

# Career content source (optional, defaults to /home/dom/personal/career)
CAREER_DIR=/path/to/career
```
All styles live in `app/globals.css` (~1130 lines). Two disjoint token systems:

- **`:root` (lines 1-121)** — legacy tokens + Tailwind bridge. Used by the dashboard/ChatBot only.
- **`.brutalist-root` (128-239)** — the landing. Everything is scoped here.

**Build new sections from semantic tokens only** — `var(--accent)`, `var(--fg)`, `var(--fg-muted)`,
`var(--fg-low)`, `var(--rule)`, `var(--s-N)` spacing, `var(--font-display|sans|mono)`. Do that and
all three themes (Gold / Oxblood / High Contrast) work for free, since the theme cycler only swaps
token values via `[data-accent]` / `[data-contrast]` attributes.

The landing is **dark-only by design** — the `prefers-color-scheme: light` block only touches
`:root`, which `.brutalist-root` shadows.

**Responsive: one breakpoint**, `@media (max-width: 900px)` at the bottom. Any new multi-column
grid must be added there manually; nothing is automatic.

Structure convention: `.section` > `<BinaryRule/>` > `.*-head`. The adjacent-sibling rule
`.section > .binary-rule + *` supplies the top margin, so keep that order.

`BinaryRule`'s `seed` prop drives a deterministic sine-hash PRNG — it exists for **hydration
safety** (server and client must render identical digits), not aesthetics. Give new sections an
arbitrary unused seed.

## Content

Hand-authored TypeScript in `lib/content/`, not markdown. Prefer typed TS modules over JSON:
`resolveJsonModule` is on, and importing an empty `[]` JSON file infers `never[]`, which fails
typecheck under `strict`.

Travel data is the exception — script-generated JSON in `content/travel/`, produced by
`scripts/process-travel-data.js` and `scripts/fetch-flights.js` (both manual).

**Career content is deliberately NOT in this repo** — it's gitignored. This repo is public; the
source of record is `~/personal/career`. Don't re-add it or reintroduce a sync step.

## Deployment

`.github/workflows/deploy.yml` — on push to `main` (+ manual), builds and pushes `out/` to Pages.

## Key Dependencies
**Gotcha:** pushes made by `github-actions[bot]` with the default `GITHUB_TOKEN` do **not**
trigger workflows. So a cron that commits data cannot make the site rebuild. Anything needing
fresh data at deploy time must fetch **during the build**, not commit-then-rebuild.

- **gray-matter** - Parse markdown frontmatter
- **react-simple-maps** / **d3-geo** / **topojson-client** - Travel map visualization
- **@emailjs/browser** - Contact form email delivery
- **date-fns** - Date formatting
- **clsx** - Conditional class composition
`update-dashboard-data.yml` crons analytics/uptime/performance JSON into `public/data/`.

## Testing

Vitest with jsdom environment. Setup in `vitest.setup.ts` includes mocks for:
- `window.matchMedia`
- `IntersectionObserver`
Vitest + jsdom for units (`__tests__/`), Playwright for E2E (`e2e/`). `vitest.setup.ts` mocks
`matchMedia` and `IntersectionObserver`.

Tests located in `__tests__/` directory. Use `@testing-library/react` for component tests.
Test nontrivial logic (parsers, transforms, conditional-render invariants). Skip ceremony tests.
Loading
Loading