feat: lead with the project portfolio, not the résumé - #1
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
<article>.sendResumeEmailremoved. (Contact's reCAPTCHA stays.)Repo hygiene
content/career/tree and the publicly-downloadable resume PDF from this repo, plus both prebuild steps andpuppeteer. See "Note on history" below.CLAUDE.md, which documented career/, skills/, education/ and blog/ routes that no longer exist.Bugs fixed along the way (most pre-existing)
github-actions[bot]with the defaultGITHUB_TOKENdo 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 schedulingdeploy.yml.[], overwriting committed posts on an unattended cron. The parser now distinguishes "feed with no posts" ([]) from "not a feed" (null).fetch-substack.jscould corruptwriting.ts:indexOf(...)returning-1fedslice(-1), which returns the last character rather than throwing. Now bounded by an explicit end marker with an ordering guard.fast-xml-parserwas a devDependency the build requires — survived only becausedeploy.ymlruns plainnpm install.display: noneunder 900px);npm testwas broken on main (vitest had noincludeand swept up Playwright specs;@testing-library/domwas missing);update-dashboard-data.ymlpushed even with nothing to commit (a || b && cis 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-repoplus 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— cleannpm 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 succeedsnpx playwright test— 20/20, incl. a no-horizontal-overflow matrix at 320/360/390/430/768/1024/1440npm run lint— 28 errors → 2, both pre-existing (TravelMap.tsx,vitest.setup.ts)${}, quotes, newlines, array-closing injection) serialize to inert escaped stringsKnown gaps (not addressed here)
deploy.ymlis install + build only.npm audit --omit=dev: 7 pre-existing vulns (6 high, incl. anextadvisory), unrelated to this branch.