feat: add /hire, a page that states both offers - #23
Merged
Conversation
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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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
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.
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:
#OPENTOWORK+ "구직 중 · CTO 및 소프트웨어 엔지니어 역할" → full-timeMeasured over Jul 9 – Aug 5 (GA4): 88 sessions, 15 engaged, and an outbound-event distribution (
code_click2,contact_click1,demo_click1,play_store_click1) 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
/hireand/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.projects.jsonviagetHireStats(), never typed into prose, so the page cannot outlive the data.shippedcounts a public destination rather than thelaunched/activestatuses — a bigger, more flattering number that a "show me" would not survive.ProjectCard, which already withholds a private repo's URL, so the Private rule is enforced rather than re-implemented.SkillGridextracted from/aboutbefore the second caller existed./aboutrenders byte-identical chips, verified by diffing name and order either side of the refactor.Bug fixed along the way
classifyOutboundLinkdropped every unrecognised host intodemo_click, so the LinkedIn link the footer puts on every page has always been counted as a demo. It never showed becausedemo_clicksaw 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. Nowcontact_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:
Work with mestill wrapped from 768px to ~887px. Raised the breakpoint tolgso it no longer depends on how long the longest label happens to be.shippedcountswebsite || 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
h3sits level withProjectCard'sh3(no level skipped, same idiom as/about), the KoreanmetaTitleem dash (precedent ingraveyard.description), and themd→lgnav change being site-wide (browser-verified at seven widths in both locales).Known follow-up, deliberately out of scope: the
llms.txtroute does not list/hireyet.Verification
vitest146/146 ·tsc --noEmitclean ·eslintclean ·next buildsucceeds with/[locale]/hirein the route table.🤖 Generated with Claude Code
https://claude.ai/code/session_01LaydoxAZ4eaqqPNKrmWLbt