Skip to content

feat: lead with the project portfolio, not the résumé - #1

Merged
dommango merged 11 commits into
mainfrom
feat/portfolio-first
Jul 15, 2026
Merged

feat: lead with the project portfolio, not the résumé#1
dommango merged 11 commits into
mainfrom
feat/portfolio-first

Conversation

@dommango

Copy link
Copy Markdown
Owner

Summary

Flips the site from résumé-first to portfolio-first. The two things the site was meant to show — the projects and the writing — were the two things missing: no project appeared anywhere, and Substack was a footer icon. Meanwhile the centrepiece was a Resume section with a Request-CV form.

Now: Hero → Projects → Writing (conditional) → Career → Travel → Contact. The brutalist design system, theme cycler, and travel map are untouched.

Content

  • New Projects section leads the page: SousIQ, Bracketeer, Claude Code Placemat, PRIAL Pipeline, modular-mind. Linked cards are anchors (previously hover-only, i.e. keyboard-inaccessible); PRIAL has no public URL so it renders as a plain <article>.
  • New Writing section, fed from the Substack RSS feed at build time. It stays invisible until real posts exist — the section and its nav link are gated on the same predicate, so they cannot diverge.
  • Hero drops the six "career themes" and nine-stop career arc. Career compressed to a 5-row timeline; Request-CV form, its reCAPTCHA, and sendResumeEmail removed. (Contact's reCAPTCHA stays.)

Repo hygiene

  • Removes the content/career/ tree and the publicly-downloadable resume PDF from this repo, plus both prebuild steps and puppeteer. See "Note on history" below.
  • Deletes ~20 dead files left over from the pre-redesign multi-page site.
  • Rewrites CLAUDE.md, which documented career/, skills/, education/ and blog/ routes that no longer exist.

Bugs fixed along the way (most pre-existing)

  • RSS automation design flaw, caught before implementing: pushes by github-actions[bot] with the default GITHUB_TOKEN do not trigger workflows, so the planned cron-commits-JSON→rebuild would never have rebuilt. This is already live for the dashboard, whose data has been frozen at the last manual push. Fixed by fetching during the build and scheduling deploy.yml.
  • A non-feed 200 could silently empty the Writing section. An interstitial or HTML login page returns 200 and parsed to [], overwriting committed posts on an unattended cron. The parser now distinguishes "feed with no posts" ([]) from "not a feed" (null).
  • fetch-substack.js could corrupt writing.ts: indexOf(...) returning -1 fed slice(-1), which returns the last character rather than throwing. Now bounded by an explicit end marker with an ordering guard.
  • fast-xml-parser was a devDependency the build requires — survived only because deploy.yml runs plain npm install.
  • reCAPTCHA overflowed viewports under 360px; mobile nav did not exist (display: none under 900px); npm test was broken on main (vitest had no include and swept up Playwright specs; @testing-library/dom was missing); update-dashboard-data.yml pushed even with nothing to commit (a || b && c is left-associative); sitemap listed five routes that 404; ChatBot context and JSON-LD were stale.

Note on history

The commit that removes those files takes them out of the working tree only — git history still retains them, and this repo is public. Scrubbing history requires git filter-repo plus a force-push and is deliberately not part of this PR: rewriting a published repo's history is a separate, destructive decision. Raised with the owner out-of-band.

Test plan

  • npx tsc --noEmit — clean
  • npm test -- --run — 13/13 (parser: placeholder filter, single-item feeds, bad dates, malformed XML, non-feed bodies, HTML subtitle stripping)
  • npm run build — static export succeeds
  • npx playwright test — 20/20, incl. a no-horizontal-overflow matrix at 320/360/390/430/768/1024/1440
  • npm run lint — 28 errors → 2, both pre-existing (TravelMap.tsx, vitest.setup.ts)
  • Substack outage path exercised against the real script: warns, exits 0, leaves committed posts intact
  • Interstitial (200 + HTML body) verified to preserve committed posts; a genuinely empty feed still clears them
  • Rewrite verified idempotent across runs; a missing marker leaves the file untouched
  • Hostile feed titles (backticks, ${}, quotes, newlines, array-closing injection) serialize to inert escaped strings
  • After deploy: confirm the removed static asset returns 404

Known gaps (not addressed here)

  • No workflow runs the test suite — deploy.yml is install + build only.
  • npm audit --omit=dev: 7 pre-existing vulns (6 high, incl. a next advisory), unrelated to this branch.
  • GitHub disables scheduled workflows after 60 days of repo inactivity.

dommango-sys added 11 commits July 14, 2026 23:24
The brutalist single-page redesign left ~20 unreachable files behind.
Traced the import graph from app/page.tsx, app/layout.tsx, and the
dashboard; removed everything unreachable.

- components/{contact,profile,timeline,skills,education,layout}/
- components/ErrorBoundary.tsx, ui/{Badge,Section,Button}.tsx
- components/travel/{CountryCard,TravelStats}.tsx (TravelMap is live
  via dynamic import; the TravelStats *interface* in lib/content/travel.ts
  is also live and stays)
- hooks/ (all three unused)
- lib/content/{profile,roles,skills,education}.ts, lib/utils.ts,
  lib/constants.ts
- app/api/contact/ — dead under output:'export'; forms use EmailJS directly
- __tests__/{components/Button,api/contact} — covered deleted code; the
  contact test was a stub asserting toBeDefined()
- 0-byte junk file in repo root
- gray-matter dep (only the deleted loaders used it)

Rendered output verified identical: same visible text, same file set.
Lint problems drop 36 -> 18 (remainder pre-existing in scripts/).
This repo is public and held 53 committed career files, including
internal Citi project write-ups (consent order, MRA reporting, data
architecture) and work AI-collaboration logs. The resume PDF was also
publicly downloadable at /assets/Dominic_Mangonon_Resume.pdf despite
the site claiming "Resume isn't public".

The portfolio-first site doesn't read any of it, so it all goes:

- content/career/ (53 files) and public/assets/*.pdf, both now gitignored
- scripts/{sync-career-content,generate-resume-pdf}.js
- the entire prebuild chain (its only job was feeding the above)
- CAREER_DIR env from deploy.yml
- puppeteer dep (only the PDF script used it)
- gray-matter dep — the previous commit's message claimed this was
  removed, but the uninstall had silently failed on the react-simple-maps
  peer conflict; it needed --legacy-peer-deps. Actually gone now.

Rewrote CLAUDE.md, which still documented the pre-redesign multi-page
architecture (career/, skills/, education/, blog/ routes that don't
exist). Replaced create-next-app boilerplate README.

Source of record for career content stays at ~/personal/career.
Note: git history still retains the deleted files.

Verified: build succeeds with no prebuild, hero image present in out/,
no PDF in build output.
Flips the site's hierarchy. It read as a resume in brutalist clothing:
headline was a job pitch, nav was Intro/Resume/Contact/Travel, and the
hero asserted six "career themes" ("Ambiguity-native", "AI-native
operator") that the projects should have been demonstrating instead.

New order: Hero -> Projects -> Writing -> Career -> Travel -> Contact.

Projects (the new main section):
- lib/content/projects.ts — typed, hand-authored: SousIQ, Bracketeer,
  Claude Code Placemat, modular-mind, PRIAL Pipeline
- Rewrote Projects.tsx, which existed but was never imported and held
  Citi/Morgan Stanley case studies. Its CSS was already written and
  unused, so this is mostly wiring.
- Cards linking out are anchors now; added the focus-visible state they
  never had (hover-only = keyboard-inaccessible). PRIAL has no public
  repo or URL, so it renders as a plain article.

Hero: new headline/kicker, one bio paragraph. Cut the six themes and the
nine-stop career arc (both duplicated the timeline below).

Career: 5 rows, no GPAs. Dropped the Request-CV form + its reCAPTCHA and
sendResumeEmail — it gated a PDF that was publicly downloadable anyway.
Contact's reCAPTCHA is untouched, so react-google-recaptcha stays.

Also:
- Mobile had NO nav at all (.nav-links was display:none under 900px).
  Now a horizontally scrollable row — Projects is the main section, so
  stranding mobile there was no longer survivable.
- Dropped "Section / 0N" eyebrow numbering: already inconsistent, and
  Writing renders conditionally so static numbers can't hold.
- Removed ~71 lines of CSS orphaned by the deleted themes/awards markup.

Verified: build green, tsc clean, 5 cards render, Writing absent at 0 posts.
Two separate overflow bugs, both caught by measuring the real page rather
than trusting the layout to be fine:

1. .hero-title's clamp floor of 48px stops scaling below ~667px viewport,
   where the vw term no longer binds. "Unapologetically" is a much longer
   word than the "Harnessing AI" it replaced and needed 415px at that size,
   so it pushed a 390px viewport to 431px. Floor is now 32px; anything
   above 667px renders identically.

2. .contact-tracked ("L E T ' S  T A L K") is 370px at 14px with 0.42em
   tracking, and a grid item defaults to min-width:auto, so the column
   refused to shrink below it and dragged the page to 386px at 360px wide.
   It now scales with the viewport below ~540px and is unchanged above.
   Added min-width:0 to the responsive grid children as a general guard.

Locks both in with an e2e matrix asserting scrollWidth <= clientWidth from
320px to 1440px. Also fixes e2e/travel.spec.ts, whose beforeEach looked for
"My Travel Map" — pre-brutalist copy that exists nowhere, so all 6 travel
tests had been failing on the hook before this branch.

Added a landing spec covering the redesign's real invariants: projects
precede the resume, one card per project, linked cards are anchors with
target/rel, PRIAL (no public repo, no URL) is not a link, cards are
keyboard-focusable, and Writing plus its nav link appear together or not
at all.

Gitignored test-results/ — it blocked a branch checkout.

20/20 e2e green.
The section renders only when there are real posts, so it ships dormant:
the feed currently holds nothing but Substack's seeded "Coming soon"
placeholder, which the parser filters out. It will appear on its own the
first time Dom publishes.

Build-time fetch, not a committing cron. A cron that commits JSON and
expects deploy.yml to rebuild cannot work: pushes from github-actions[bot]
using the default GITHUB_TOKEN do not trigger workflows. So deploy.yml gets
a daily schedule and runs scripts/fetch-substack.js during the build,
rewriting lib/content/writing.ts in the workspace only.

A feed outage is a warning, not a build failure — verified by pointing the
script at a dead host: exits 0 and leaves the committed posts in place.

Parser is a pure function in scripts/lib/, separate from the fetch so it
tests without network. 9 unit tests: real feed, placeholder-only -> [],
placeholder alongside real posts, ordering, single-item feeds (the parser
returns an object rather than an array), missing/unparseable pubDate, and
malformed XML -> [] rather than a throw.

Two pre-existing problems this surfaced:
- vitest had no `include`, so its default glob swept up e2e/*.spec.ts and
  tried to run Playwright specs under vitest. `npm test` was broken on main.
- @testing-library/dom was missing, which vitest.setup.ts needs transitively.

Also fixes a latent overflow that would only have appeared the day the
first post shipped: .projects-title's 48px clamp floor made
"Context//Collapse." — one unbreakable token — spill out of its box on
phones. The right-edge scan missed it because the text overflowed while the
box did not. Floor lowered to 32px with break-word as backstop; verified by
temporarily injecting a post and running the overflow matrix.
Loose ends left by the pre-redesign site, all of them pre-existing:

- public/sitemap.xml advertised /career, /skills, /education, /travel and
  /contact to search engines. All five have 404'd since the site became a
  single page. Reduced to /.
- lib/services/chat.ts fed the assistant a career-first description that
  knew nothing about any project, and told visitors to "check the relevant
  page on the website" — there are no other pages. Rewritten around the
  five projects, with a rule to point at sections instead. Also dropped its
  stale claim that content is managed with gray-matter.
- ChatBot greeted with "Ask me about his career, skills, travel adventures."
- app/layout.tsx metadata and JSON-LD described a "technology leader
  specializing in enterprise transformation" with jobTitle/worksFor Citi.
  Reframed to the builder story; added GitHub to sameAs.
- update-dashboard-data.yml had no `name:` and was indented two spaces at
  the root (valid, but it showed up unnamed in the Actions UI). Its commit
  step read `git diff --quiet || git commit && git push`, which parses as
  `(diff || commit) && push` and so pushed even with nothing to commit —
  now an explicit if/else. Documented that its bot push can't trigger
  deploy.yml, which is why deploy.yml carries its own schedule.
- eslint: scoped no-require-imports off for scripts/**, which are CommonJS
  run directly by node. Lint goes 28 errors -> 2 (both pre-existing, in
  TravelMap.tsx and vitest.setup.ts, neither touched here).

Verified: tsc clean, build green, 9/9 unit, 20/20 e2e, both workflow files
parse as valid YAML with the expected triggers.
Committed by an earlier 'git add -A' before the gitignore entry landed.
Playwright output is regenerated on every run and shouldn't be in the repo.
Playwright's reuseExistingServer trusts whatever already answers on the
port. Another project's dev server was occupying 3000 during verification,
so the suite was silently driving the wrong site. Thread PLAYWRIGHT_PORT
through baseURL and the webServer command so a private port is available.

Re-running on a clean port surfaced a real pre-existing bug the hijacked
run had masked: at 320px the page scrolled sideways to 353px. Google's
reCAPTCHA is a fixed 304px iframe with no resize API, so it cannot fit a
320px viewport once form padding is counted. Scale it below 360px.

The captcha overflow is not introduced by this branch — .form-captcha is
unchanged from main — but the new overflow matrix asserts 320px, so it
fails here first.
The tail was located with source.indexOf('export const hasPosts'), which
had two failure modes, both verified:

1. When the string is absent (someone renames or removes hasPosts),
   indexOf returns -1 and slice(-1) does not throw — it returns the last
   character. The script then overwrote writing.ts with a truncated file.
   The existing guard only covered a missing start marker.

2. On a re-run over already-generated content, a post title containing
   "export const hasPosts" matched ahead of the real declaration, slicing
   the file mid-array.

Bound the generated region with an explicit end marker and bail unless
both markers are present and ordered. Anchoring the marker on newlines
closes (2) for good: serialize() puts every field through JSON.stringify,
which escapes real newlines, so no emitted string literal can contain one.

Verified: rewrite is idempotent across runs, a title carrying the old
sentinel now survives intact, a missing marker leaves the file untouched,
and a fetch outage still exits 0 without writing.
Review findings on the Substack fetch.

parseSubstackFeed collapsed two different outcomes into []: "this feed has
no posts" and "I could not understand this response". Only the first should
overwrite the committed POSTS. A Cloudflare interstitial, maintenance page,
or redirect to an HTML login all return 200 with an HTML body, which parsed
happily to [] and wiped the Writing section — on a daily cron build with
nobody watching. Return null for a non-feed body and keep committed posts.
Gating on rss.channel is what separates the two: an HTML page parses under
`html`, so it can never look like an empty feed. Verified against the real
script — an interstitial now preserves a committed post, while a genuine
empty feed still clears it.

Also strip HTML from subtitles: Substack puts markup in <description>, and
unstripped it renders as visible tag soup ("<p>Hello <em>world</em></p>").
React escapes it, so this was never an XSS vector. The strip only matches
tag-shaped text so prose like "a < b" survives.

fast-xml-parser moves to dependencies — it is required at build time, and
only survived in devDependencies because deploy.yml runs plain `npm install`.
`npm ci --omit=dev` would have failed the build at require time.

main() gains a .catch(): an unwritable writing.ts should still fail the
build, but as a [substack] message rather than a raw unhandled rejection.

Caveat: a truncated feed body ('<rss><channel><item>broken') still parses to
a channel and so reads as an empty feed rather than a non-feed. Left as is.
Nothing ran the tests. deploy.yml is install + build only and fires on
push-to-main, schedule, and dispatch — it has no pull_request trigger, so
no workflow reported on a PR at all. The parser tests cover real ground
(placeholder filter, single-item feeds, bad dates, malformed XML, non-feed
bodies) and none of it was enforced.

Gates typecheck, unit, build, and e2e. Playwright uploads its report on
failure only.

Deliberately omits `npm run lint`: two errors predate this branch
(TravelMap.tsx react-compiler memoization, vitest.setup.ts no-explicit-any),
so gating on lint would fail every run on arrival. Worth a separate pass.

No Substack fetch here — CI must not depend on the network. deploy.yml keeps
running scripts/fetch-substack.js as its own step before its build.

Verified by running each command as written, and the suite under CI=true so
Playwright boots its own server rather than reusing one: 20/20, 13/13 unit,
tsc clean.
@dommango
dommango merged commit 7226bc1 into main Jul 15, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant