brightspark.bonsung.me Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build and publish brightspark.bonsung.me, a one-page digital CV in a calm slate and teal look that closes with a showcase of how it was built with coding agents, a rail of cards recording how each section was built, and the site's own spec, plan, rules and lessons as published pages.

Architecture: A statically exported Next.js site. The CV's text, the showcase and the cards are typed content modules, checked by tests against their sources: the CV's PDF, the published spec and plan, the rules, the lessons and the CI workflow. The spec, the plan, AGENTS.md and the lessons are rendered from Markdown at build time, each block anchored by its line number. Vitest checks the built HTML, Playwright checks behaviour in a browser, and a history check proves main and develop hold only Bonsung's merges.

Tech Stack: Node 22, Next.js 16 (App Router, output: "export"), React 19, Tailwind CSS 4, TypeScript, Vitest with jsdom, Playwright (Chromium), unified and remark for Markdown, unpdf for the CV's text, tsx for scripts, GitHub Actions, Vercel with Web Analytics, Cloudflare DNS.

Spec: docs/spec.md. Read it before starting.

Global Constraints

Review Focus

Working rules (every task from Task 2)

Start: git switch develop && git pull, then git switch -c <the task's branch>. Every task's pull request targets develop.

Finish:

  1. npm run lint && npm run typecheck && npm run build && npm test all exit 0, and npm run test:e2e from Task 3 on.
  2. Tick the task's boxes in docs/plan.md.
  3. Commit with a feat:, fix:, test:, docs: or chore: message ending in Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.
  4. git push -u origin <branch>, then gh pr create --base develop --title "<title>" --body "<body>". Plain English, what changed and why, no dates or durations, the page's style rules. The body ends with 🤖 Generated with [Claude Code](https://claude.com/claude-code).
  5. gh pr checks <number> --watch until every check passes, then tell Bonsung the pull request is ready.
  6. Bonsung merges in GitHub with Create a merge commit. Agents never merge. After the merge, delete the task's branch.

File Map

File Responsibility
AGENTS.md, CLAUDE.md Rules for coding agents. CLAUDE.md imports AGENTS.md
docs/spec.md, docs/plan.md, docs/solutions/ The published spec, this plan, and the lessons
.claude/settings.json, .claude/hooks/guard.mjs The coding-agent hook
.githooks/pre-commit Refuses commits on main and develop
.github/workflows/ci.yml The five checks: build, test, lint, typecheck, history
src/record/history.ts, src/record/github.ts, src/record/mainchain.ts, scripts/check-history.ts The history check
src/record/docs.ts Reading and rendering the published documents
src/content/theme.ts Colour tokens and the contrast formula
src/content/hero.ts, src/content/cv.ts, src/content/showcase.ts, src/content/cards.ts The page's copy
src/components/sections/*.tsx One file per page section
src/components/{Section,Inline,Footer,Band,Rail,SectionCard}.tsx Shared pieces
src/app/{layout,page,icon,opengraph-image}.tsx, src/app/globals.css The page, its details and its look
src/app/built/** The published documents
tests/*.test.ts, tests/hooks.test.mjs, e2e/*.spec.ts Checks

Task 1: Start the repository

Branch: none. This is the first commit on main, made before the hooks are switched on.

Files:

Interfaces:

test("the agent guard stops skipping the git hooks", () => {
  for (const c of ["git commit --no-verify -m x", "git commit -n -m x", "git -c core.hooksPath=/dev/null commit -m x", "git config core.hooksPath x"]) assert.ok(blocked(c), c)
})
test("the agent guard stops pushes to main", () => {
  for (const c of ["git push origin main", "git push origin HEAD:main", "git push -f origin feat/x:main", "git push origin +main", "git push origin refs/heads/main"]) assert.ok(blocked(c), c)
  assert.ok(blocked("git push", "main"))
})
test("the agent guard stops merging pull requests", () => {
  for (const c of ["gh pr merge 3", "gh api -X PUT repos/b0nsun9/portfolio-for-brightspark/pulls/3/merge"]) assert.equal(blockReason(c, "feat/x"), "Agents never merge pull requests. Bonsung merges.")
  assert.ok(blocked("git merge feat/x", "main"))
})
test("the agent guard treats develop as it treats main", () => {
  for (const c of ["git push origin develop", "git push origin HEAD:develop", "git push origin +develop"]) assert.ok(blocked(c), c)
  assert.ok(blocked("git merge feat/x", "develop"))
})
test("the agent guard lets ordinary branch work through", () => {
  for (const c of ["git push -u origin feat/hero", "gh pr create --base develop --fill", "git merge develop", "npm test"]) assert.equal(blockReason(c, "feat/hero"), null, c)
})
test("the pre-commit hook refuses commits on main", () => { /* a scratch repo with core.hooksPath set, a commit on main fails, a commit on a branch passes */ })
test("the pre-commit hook refuses commits on develop", () => { /* the same, on develop */ })

Run: node --test tests/hooks.test.mjs Expected: FAIL, guard.mjs does not exist.

Run: node --test tests/hooks.test.mjs Expected: 7 tests pass.

git init -b main
git add -A
git commit -m "chore: start with the spec, the plan and the rules" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
gh repo create b0nsun9/portfolio-for-brightspark --private --source . --push
gh api -X PATCH repos/b0nsun9/portfolio-for-brightspark \
  -F allow_merge_commit=true -F allow_squash_merge=false -F allow_rebase_merge=false \
  -F delete_branch_on_merge=false -f merge_commit_title=MERGE_MESSAGE -f merge_commit_message=PR_TITLE
git switch -c develop && git push -u origin develop
gh repo view b0nsun9/portfolio-for-brightspark --json visibility,mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,deleteBranchOnMerge

Expected: "visibility":"PRIVATE", merge commits allowed, squash and rebase not, "deleteBranchOnMerge":false, and develop on the remote at the first commit.


Task 2: Scaffold, checks and CI

Branch: chore/scaffold

Files:

Interfaces:

it("the site builds to a static page", () => { expect(existsSync("out/index.html")).toBe(true) })
it("the build reads nothing from git or GitHub", () => { /* no file under src/app imports src/record/github or mainchain */ })
it("main may hold the first commit and pull requests merged by Bonsung", () => { expect(historyViolations([root, merge(1)], pulls, "b0nsun9")).toEqual([]) })
it("a commit pushed straight to main fails the history check", () => { /* a non-merge commit after the root is a violation */ })
it("a pull request merged by anyone else fails the history check", () => { /* mergedBy other than b0nsun9 */ })
it("a merge made outside GitHub fails the history check", () => { /* a merge commit whose subject is not "Merge pull request #N from ..." */ })
it("a squash or rebase merge fails the history check", () => { /* a single-parent commit naming a pull request */ })
it("a merge whose pull request cannot be found fails the history check", () => { /* no entry in pulls */ })
it("a pull request whose merge commit is a different one fails the history check", () => { /* mergeSha differs */ })
it("no font, image, logo or icon file from another company is in the repository", () => { /* git ls-files has no font or image file */ })
it("CI runs on pushes to main and develop, and checks the history of both", () => {
  const ci = readFileSync(".github/workflows/ci.yml", "utf8")
  expect(ci).toMatch(/^  push:\n    branches: \[main, develop\]$/m)
  for (const ref of ["origin/main", "origin/develop"]) expect(ci).toContain(`npm run check-history -- ${ref}`)
})

Sample commits in the tests use developer@example.com, never a real address.

Run: npm run build && npx vitest run Expected: FAIL, the record modules and the workflow do not exist yet.

Run: npm run lint && npm run typecheck && npm run build && npm test Expected: all exit 0.


Task 3: The look and the hero

Branch: feat/hero

Files:

Interfaces:

it("the contrast formula matches known values", () => { expect(contrast("#000000", "#ffffff")).toBeCloseTo(21, 1) })
it("every text colour pair meets WCAG AA", () => { for (const [fg, bg, use] of TEXT_PAIRS) expect(contrast(fg, bg), use).toBeGreaterThanOrEqual(4.5) })
it("the stylesheet uses the theme's colours", () => { /* each token in globals.css equals THEME */ })
it("the hero says AI-led code. Engineer-led AI.", () => {
  const hero = loadPage().querySelector("[data-section=hero]")!
  expect(squash(hero.querySelector("h1")!.textContent!)).toBe(squash("AI-led code. Engineer-led AI."))
  for (const line of ["Kia ora,", "AI Led Software Engineer"]) expect(squash(hero.textContent!)).toContain(squash(line))
})
it("the Hire button writes to developer@bonsung.me", () => {
  const button = [...loadPage().querySelectorAll("[data-section=hero] a")].find((a) => a.textContent === "Hire")!
  expect(button.getAttribute("href")).toBe("mailto:developer@bonsung.me")
})
it("the favicon is drawn in code, and no icon file is in the repository", () => {
  expect(existsSync("src/app/icon.tsx")).toBe(true)
  expect(loadPage().querySelector("link[rel='icon']")?.getAttribute("href")).toMatch(/^\/icon/)
})
test("the hero takes the site's look and nothing moves", async ({ page }) => {
  // the hero's background is hero, the role line and the Hire button are teal, and no element in the hero animates or transitions
})

Run: npm run build && npx vitest run tests/theme.test.ts tests/page.test.ts tests/repo.test.ts Expected: FAIL, the hero and the theme do not exist.

Run: npm run lint && npm run typecheck && npm run build && npm test && npm run test:e2e Expected: all exit 0.


Task 4: The CV on the page

Branch: feat/cv

Files:

Interfaces:

it("the site serves no CV file", () => {
  expect(existsSync("out/cv")).toBe(false)
  for (const file of globSync("out/**/*.html")) expect(loadPage(file.slice(4)).querySelector("a[href$='.pdf']"), file).toBeNull()
})
it("the introduction is the CV summary", async () => { await expectCvLines("introduction", null) })
it("every key strength is the CV's text", async () => { await expectCvLines("key-strengths", 5) })
it("every experience line is the CV's text", async () => { await expectCvLines("experience", 14) })
it("every project line is the CV's text", async () => { await expectCvLines("projects", 3) })
it("every education line is the CV's text", async () => { await expectCvLines("education", null) })
it("the section headings are the CV's own", () => {
  const headings = [...loadPage().querySelectorAll("section:not([data-section=showcase]) h2")].map((h) => h.textContent)
  expect(headings).toEqual(["Introduction", "Key Strengths & Achievements", "Work Experience", "Selected Projects", "Education"])
})

expectCvLines(section, bullets, headings) joins the section's [data-cv-line] texts in page order, run by run (a run of bullets, or a run of headings), and requires each run to appear as one contiguous piece of the squashed PDF text, so a line cut short or dropped fails. The PDF's text lists a page's bullets before the job headings they belong to, so experience is checked per job run and not as one piece. It also checks the number of bullets when one is given and the number of heading lines, and that the section's slice of the CV text, less its section headings and bullet glyphs, is exactly as long as the page's lines, so a cut at the start or end of a run fails. Work Experience, Selected Projects and Education are measured together because the PDF lists some job headings after Education.

it("the footer has his name, GitHub and email, and nothing else", () => {
  const footer = loadPage().querySelector("footer")!
  expect(squash(footer.textContent!)).toBe(squash("Bonsung Koo GitHub developer@bonsung.me"))
})
it("the page sections are in order", () => { /* hero, introduction, key-strengths, experience, projects, education */ })
it("airie is always lowercase", () => { /* no Airie or AIRIE in the prose of any page */ })
it("every external link is on the allowed list", () => { /* the list in Global Constraints */ })
it("the page shows no phone number", () => { expect(loadPage().body.textContent).not.toMatch(/\b0\d{1,2}[\s-]?\d{3}[\s-]?\d{3,4}\b/) })
it("text written for the page follows the style rules", () => { /* no em dash, en dash, semicolon or hours and days in prose outside [data-cv-line] and code */ })
test("nothing overflows sideways at phone width", async ({ page }) => { /* at 320 px, document width equals viewport width */ })

Run: npm run build && npx vitest run tests/cv.test.ts tests/page.test.ts Expected: FAIL, the CV sections do not exist.

Run: npm run lint && npm run typecheck && npm run build && npm test && npm run test:e2e Expected: all exit 0.


Task 5: The working documents as pages

Branch: feat/built

Files:

Interfaces:

it("each block of a document keeps its first line number", () => { /* parseDoc keeps the 1-based first line of each block */ })
it("a rendered document gives every block an anchor", () => { /* renderDoc gives each block id="L<line>" */ })
it("raw HTML in the markdown is shown as text, never rendered", () => { /* "<b>x</b>" renders as text */ })
it("the spec and the plan are published with every block addressable", () => { /* out/built/spec.html and plan.html carry an anchor per block */ })
it("every page under /built links back, then to the rules, the spec, the plan and the lessons", () => { /* header links in that order */ })
it("no page under /built shows a date or a time", () => { /* no YYYY-MM-DD, no month names with a year, no hh:mm */ })
it("the date check can see a date", () => { /* the same check fails on a sample with a date */ })
it("no lesson cites a line number", () => { /* no "line 12" or "L12" in any lesson */ })
it("the list of lessons reads plainly when there are none", () => { /* with no lesson, /built/lessons says so in one line and links nothing */ })
it("every link inside the site opens a page that exists", () => { /* every internal href and its anchor resolve in out/ */ })

Run: npm run build && npx vitest run tests/docs.test.ts tests/built.test.ts Expected: FAIL, the pages do not exist.

Run: npm run lint && npm run typecheck && npm run build && npm test && npm run test:e2e Expected: all exit 0.


Task 6: Share card, page details and analytics

Branch: feat/details

Files:

Interfaces:

it("the link's description is the hero's greeting and headline", () => {
  for (const p of ["description", "og:description"]) expect(meta(p), p).toBe("Kia ora, AI-led code. Engineer-led AI.")
})
it("the page has its address and share card", () => {
  expect(meta("og:url")).toBe("https://brightspark.bonsung.me")
  expect(meta("og:image")).toMatch(/^https:\/\/brightspark\.bonsung\.me\/opengraph-image/)
})
test("the page and a published document load Vercel Web Analytics", async ({ page }) => { /* the analytics script is requested on / and on /built/spec */ })

Run: npm run build && npx vitest run tests/page.test.ts Expected: FAIL on the description test and on the address and share card test.

Run: npm run lint && npm run typecheck && npm run build && npm test && npm run test:e2e Expected: all exit 0.


Task 7: The showcase

Branch: feat/showcase

Files:

Interfaces:

it("the showcase shows the rules and the seven steps as written", () => { /* the lead, #step-0 and seven li[data-step] hold each title, says, label and evidence */ })
it("the rules come first, apart from the seven steps", () => { /* #step-0 holds "Before every session" and sits outside the list */ })
it("each step's evidence sits in its own figure, under its label", () => { /* figure > figcaption is the label */ })
it("the showcase is the spec's table, word for word", () => { /* each row of the spec's showcase table equals SHOWCASE_RULES and SHOWCASE_STEPS */ })
it("the four links open the rules, the spec, the plan, and the list of lessons, in page order", () => { /* while no lesson exists, three links, and Read the lessons is absent */ })
it("every quotation in the showcase is in the file it comes from", () => { /* step 0 in the guard's output for a merge command, step 1 in AGENTS.md, step 2 a test name in docs/plan.md and in the tests */ })
it("this file takes every quotation from the page's own copy", () => { /* the quotations are read from SHOWCASE_STEPS, never retyped */ })
it("what each story points to is in the repository", () => { /* the CI job names for step 5, and each story's anchor in the last column of the spec's table */ })
it("quiet steps say so and are faded", () => { /* a quiet step carries data-quiet */ })
it("the rules and every lesson are published", () => { /* /built/rules exists, and a page for each lesson */ })
it("the showcase's plan link opens the plan from the top", () => { /* href is exactly /built/plan */ })
test("the showcase steps show their numbers", async ({ page }) => { /* 0 to 7 are visible */ })

Run: npm run build && npx vitest run tests/showcase.test.ts tests/sources.test.ts Expected: FAIL, the showcase does not exist.

Run: npm run lint && npm run typecheck && npm run build && npm test && npm run test:e2e Expected: all exit 0.


Task 8: The rail and the section cards

Branch: feat/rail

Files:

Interfaces:

it("every CV section has a card, in page order", () => { /* SECTION_CARDS sections equal the page's data-section ids except showcase */ })
it("the showcase has no card of its own", () => { /* no [data-card=showcase] */ })
it("each card's spec line is in the spec, and the card links to its first block", () => { /* /built/spec#L<line> */ })
it("every card shows the seven steps, quiet where nothing happened", () => { /* li[data-step-n] 1 to 7, data-quiet as written */ })
it("what a card names is real: its plan task, its tests and its lesson", () => { /* Task N is a heading in docs/plan.md, each named test is a test title, each lesson a lesson title */ })
it("each test a card names is in the plan task it names", () => { /* the test's it(" line sits between Task N's heading and the next */ })
it("every card is the spec's table, word for word", () => { /* each card equals its table under ## The rail */ })
it("each section's band holds its card before the section, and the hero's holds the rules and the seven steps", () => { /* [data-rail] cells */ })
it("on a phone each section ends with its card, closed, and the showcase has none", () => { /* details, not open */ })
it("no card in the rail is hidden, and none says This section", () => { /* no hidden attribute, no "This section" */ })
it("each card's step list keeps its list role", () => { /* ol or ul with li */ })
it("each card step links to its public source, and steps 3 to 6 to nothing", () => { /* step 1 to the spec, step 2 to the plan task and its tests, step 7 to its lesson */ })
test("on a phone there is no rail and a section's note opens", async ({ page }) => {})
test("a keyboard reader can skip the rail", async ({ page }) => {})
test("the footer lines up with the CV beside the rail", async ({ page }) => {})
test("each card sits level with its section's heading", async ({ page }) => {})
test("tabbing from the hero meets the introduction's card before the experience section", async ({ page }) => {})
test.describe("without JavaScript", () => { test("every card still shows beside its section", async ({ page }) => {}) })
test("quiet steps are faded and the others are not", async ({ page }) => {})
test("the rail's top holds the rules as 0 and the seven steps", async ({ page }) => {})
test("the rules' 0 sits level with the rules' title", async ({ page }) => {})
test("the rail is 26rem wide and its steps are the CV's body size", async ({ page }) => {})
test("at 1024 px the page does not scroll sideways", async ({ page }) => {})
test("on a phone the notes keep their size", async ({ page }) => {})

Run: npm run build && npx vitest run tests/cards.test.ts tests/sources.test.ts Expected: FAIL, the rail does not exist.

Run: npm run lint && npm run typecheck && npm run build && npm test && npm run test:e2e Expected: all exit 0.


Task 9: Review the whole site, and fix what it finds

Branch: fix/final-review

Run: npm run lint && npm run typecheck && npm run build && npm test && npm run test:e2e Expected: all exit 0.


Task 10: Write down what was learnt, and finish steps 6 and 7

Branch: docs/lessons

Files:

Run: npm run build && npx vitest run tests/showcase.test.ts tests/cards.test.ts tests/sources.test.ts tests/built.test.ts Expected: PASS.

Run: npm run lint && npm run typecheck && npm run build && npm test && npm run test:e2e Expected: all exit 0.


Task 11: Publish and verify

Branch: none. A pull request from develop to main.