Skip to content

feat: add /hire, a page that states both offers - #23

Merged
writingdeveloper merged 12 commits into
mainfrom
feat/hire-page
Aug 7, 2026
Merged

feat: add /hire, a page that states both offers#23
writingdeveloper merged 12 commits into
mainfrom
feat/hire-page

Conversation

@writingdeveloper

Copy link
Copy Markdown
Owner

Why

The site has never said what working together would look like. The footer publishes an email address, which is a contact detail, not an offer — and the three channels disagreed about what is even on the table:

channel what it advertised
LinkedIn #OPENTOWORK + "구직 중 · CTO 및 소프트웨어 엔지니어 역할" → full-time
GitHub README "Open to select freelance" → contract
the site nothing

Measured over Jul 9 – Aug 5 (GA4): 88 sessions, 15 engaged, and an outbound-event distribution (code_click 2, contact_click 1, demo_click 1, play_store_click 1) that exactly matches the instrumentation check clicked by hand on Aug 5. The honest read is that no real visitor opened a demo, a repo, or the mail client all month.

The audience is not missing — it is elsewhere. LinkedIn carries 1,159 followers to this site's 66 monthly users, and referral from it was at most one session in 28 days. This page exists to be the destination LinkedIn does not currently have, and the place the three channels can finally agree on.

What

  • /hire and /en/hire — both offers stated side by side and separately. A merged "open to opportunities" weakens both: a hiring manager and a client read for different sentences.
  • Counts derive from projects.json via getHireStats(), never typed into prose, so the page cannot outlive the data. shipped counts a public destination rather than the launched/active statuses — a bigger, more flattering number that a "show me" would not survive.
  • Case studies render through ProjectCard, which already withholds a private repo's URL, so the Private rule is enforced rather than re-implemented.
  • SkillGrid extracted from /about before the second caller existed. /about renders byte-identical chips, verified by diffing name and order either side of the refactor.

Bug fixed along the way

classifyOutboundLink dropped every unrecognised host into demo_click, so the LinkedIn link the footer puts on every page has always been counted as a demo. It never showed because demo_click saw one event in 28 days — but LinkedIn is a primary call to action here, and it would have corrupted the very metric this page is judged by. Now contact_click, reusing an existing key event rather than adding a name GA4 could not star for another day.

Review notes

Final whole-branch review: READY TO MERGE, 0 Critical, 0 Important. Three real defects were caught and fixed during the loop, all of them flaws in the plan rather than the implementation:

  1. The test fixture had 2 website-truthy and 2 playStore-truthy rows, so counting the wrong field still passed. Re-shaped to 3/2 and confirmed with three mutations.
  2. The nav remedy was half a fix — English Work with me still wrapped from 768px to ~887px. Raised the breakpoint to lg so it no longer depends on how long the longest label happens to be.
  3. The Korean meta description said "공개 데모가 있는 제품" while shipped counts website || playStore. True only by accident today; false the first time something ships to Play Store without a website.

Shipping as-is by ruling: case-study h3 sits level with ProjectCard's h3 (no level skipped, same idiom as /about), the Korean metaTitle em dash (precedent in graveyard.description), and the mdlg nav change being site-wide (browser-verified at seven widths in both locales).

Known follow-up, deliberately out of scope: the llms.txt route does not list /hire yet.

Verification

vitest 146/146 · tsc --noEmit clean · eslint clean · next build succeeds with /[locale]/hire in the route table.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LaydoxAZ4eaqqPNKrmWLbt

writingdeveloper and others added 11 commits August 6, 2026 17:53
Measured, not assumed. GA4 over Jul 9 - Aug 5 shows 88 sessions and 15
engaged, with an outbound-event distribution (code_click 2, contact_click
1, demo_click 1, play_store_click 1) that matches the instrumentation
check clicked by hand on Aug 5 - so the honest read is that no real
visitor opened a demo, a repo, or the mail client all month.

The audience exists, just elsewhere: LinkedIn carries 1,159 followers to
the portfolio's 66 monthly users, and referral from it was at most one
session in 28 days. Meanwhile the three channels advertise three
different offers - LinkedIn says full-time, the GitHub README says select
freelance, the site says nothing at all.

So this page is not a conversion surface for site traffic. It is the
destination LinkedIn does not currently have, and the place the three
channels can finally agree on. States both offers side by side and
concretely, because a merged "open to opportunities" weakens both.

Numbers come from projects.json rather than prose so they cannot drift,
and the spec records one pre-existing bug to fix along the way: LinkedIn
links classify as demo_click today, which would pollute the demo metric
on a page where LinkedIn is a primary call to action.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LaydoxAZ4eaqqPNKrmWLbt
Four tasks, each with its own test cycle and its own commit: fix the
LinkedIn misclassification, derive the counts, build the page, wire it
into the nav and sitemap.

The analytics fix leads because it is a pre-existing bug, not new work.
classifyOutboundLink drops every unrecognised host into demo_click, so the
LinkedIn link the footer puts on every page has always been counted as a
demo. Shipping a hire page where LinkedIn is a primary call to action
without fixing that would corrupt the metric while we watch it.

Self-review caught three typing problems that would have stopped an
implementer: getHireStats now takes a Pick so a fixture needs two fields
rather than a forged Project, HIRE_CASE_STUDIES is documented as the
readonly tuple `as const` actually produces, and ReactNode is imported
rather than reached for through a React namespace the file never imports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LaydoxAZ4eaqqPNKrmWLbt
Pre-flight scan of the plan caught a conflict with its own review rubric:
the hire page's code copied the about page's category-grouped skill chips
verbatim, about twenty lines of grouping and markup. A reviewer would flag
that, and rightly - a category added later would land on one page and
quietly not the other.

Adds Task 3 to extract SkillGrid before the second caller exists, and
narrows the "do not modify /about" constraint to its actual intent: don't
change what /about says. Pulling a presentational component out of it is
fine, and the task verifies the rendered chips are identical either side
of the refactor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LaydoxAZ4eaqqPNKrmWLbt
classifyOutboundLink special-cased Play Store and code hosts and let
everything else fall through to demo_click, so the LinkedIn link the
footer puts on every page has been landing in the demo metric all along.
It never showed because demo_click saw one event in 28 days — but it
becomes a primary call to action on the hire page, where it would quietly
corrupt the number that tells us whether demos get opened.

Reuses contact_click rather than adding an event name: GA4 can only star
events it has already processed and that table lags about a day, so a new
name would sit outside key events until tomorrow. Clicking through to a
profile is a contact action, and link_domain keeps it separable from mail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LaydoxAZ4eaqqPNKrmWLbt
The hire page claims what has shipped, so the claim has to come from
projects.json rather than prose — the same reason the about page reads its
project count from the data instead of spelling it out.

shipped counts a public destination, not the launched and active statuses.
Those together are a bigger number, but a hire page invites 'show me' and
only a row with a website or a store listing survives that.

The three case studies are pinned by slug and covered by a test, so a
renamed project fails CI instead of leaving a hole in the page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LaydoxAZ4eaqqPNKrmWLbt
The old fixture had 2 website-truthy rows and 2 playStore-truthy rows, so a
mutation that filtered playStore by the website field instead still summed to
2 and passed. A fourth row breaks that symmetry: shipped=4, playStore=2,
total=6, and now counting the wrong field is visible in the numbers.

Verified by mutation testing getHireStats against this fixture: swapping the
shipped OR to an AND, swapping the playStore filter to website, and zeroing
total each broke exactly the assertion meant to catch it (plus the relational
real-ledger check for the total mutation, as expected fallout).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LaydoxAZ4eaqqPNKrmWLbt
The hire page needs the same category-grouped chips the about page has,
and copying twenty lines of grouping means a category added later lands on
one page and quietly not the other. Pull it out before the second caller
exists rather than after.

Pure refactor: /about renders the same chips in the same order, verified
by diffing the rendered chip markup before and after. No tests added
because no behaviour changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LaydoxAZ4eaqqPNKrmWLbt
The site has never said what working together would look like. The footer
publishes an address, which is a contact detail, not an offer — and the
three channels disagreed: LinkedIn advertised full-time, the GitHub README
advertised select freelance, the site advertised nothing.

States both offers side by side and separately, because a hiring manager
and a client are reading for different sentences and a merged 'open to
opportunities' would serve neither. Counts come from getHireStats so the
page cannot outlive the data, and the case studies render through
ProjectCard, which already withholds a private repo's URL.

Both calls to action were already instrumented: mailto and linkedin.com
now classify as contact_click, so the page reports whether it works.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LaydoxAZ4eaqqPNKrmWLbt
A page nobody can reach is a page that does not exist. The nav array
drives both the desktop and mobile menus, so one entry covers both.

Sitemap priority matches /projects at 0.7: both are pages we actively want
ranked, below the blog but above the archival pages. No images — only
/projects carries screenshots.

At md (768px), seven links overflow the row and the Korean labels wrap
mid-character (verified in a real browser, not just estimated). Dropping
/graveyard from the desktop row alone was enough for ko; the mobile nav
still lists all seven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LaydoxAZ4eaqqPNKrmWLbt
…filter

Fix round 1 on the /hire nav overflow. Dropping /graveyard from the desktop
row only fixed Korean; English's "Work with me" still wrapped between 768px
and ~888px (verified in a real browser). The site already ships a working,
focus-trapped mobile menu, so letting it cover 768-1024px costs nothing,
while a wrapping desktop row is a visible defect. Moving the breakpoint
fixes the whole class of problem rather than the one label that happens to
be longest today — a future nav entry or a longer translation cannot
reintroduce it. Shortening "Work with me" was rejected: that label is the
page's offer, not decoration.

With room for all seven links at lg, the desktop row goes back to rendering
every entry in navLinks — the graveyard filter and its comment are gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LaydoxAZ4eaqqPNKrmWLbt
…mment

- ko.json hire.metaDescription said "공개 데모가 있는 제품" (demo-only) but
  the stat is demo-or-store; match the on-page label's wording so the copy
  stays true once a store-only project ships.
- constants.ts's CONTACT_EMAIL comment claimed the value was centralised
  across the hire page, ledger footer, and site footer, but the latter two
  still hardcode their own copies; correct the comment to describe reality
  instead of aspirational work that was never done.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LaydoxAZ4eaqqPNKrmWLbt
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
portfolio Ready Ready Preview Aug 7, 2026 6:52am

Request Review

The hire branch was cut before gray-matter came out, so it still carried
the vulnerable js-yaml through the frontmatter parser. Merging main brings
in the replacement and lets CI go green here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LaydoxAZ4eaqqPNKrmWLbt
@writingdeveloper
writingdeveloper merged commit ed3a3d6 into main Aug 7, 2026
3 checks passed
@writingdeveloper
writingdeveloper deleted the feat/hire-page branch August 7, 2026 06:56
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