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
- Repository
b0nsun9/portfolio-for-brightspark, private, default branchmain, work branchdevelop. Addresshttps://brightspark.bonsung.me. - Colours, exactly: ink
#0f172a, hero#e8eef5, mist#f1f5f9, line#e2e8f0, paper#f8fafc, white#ffffff, slate#1e293b, night#0f172a, fog#e2e8f0, snow#f8fafc, teal#0f766e. Every text and background pair is at least 4.5 to 1. A shade moves only where the contrast test demands it. - Font stack
"Avenir Next", Avenir, <fallback>, sans-serif. The fallback is Nunito Sans throughnext/font/google. No font, image, logo or icon file of any other company in the repository. Buttons have a 5 px radius. - Hero, exactly:
Kia ora,/AI-led code. Engineer-led AI.(theh1) /Bonsung Koo/AI Led Software Engineer/ aHirebutton tomailto:developer@bonsung.me. Nothing moves. - Page title
Bonsung Koo, AI Led Software Engineer. The link's description isKia ora, AI-led code. Engineer-led AI. - Footer, exactly: his name, GitHub and
developer@bonsung.me. No phone number in the page text. - The favicon is a BK monogram drawn in code (
src/app/icon.tsx), in teal and white. - CV source:
tests/fixtures/Bonsung_Koo_CV_Senior_Software_Engineer.pdf, 65,783 bytes, SHA-2567285c6f59606d0dad22d6d53aeea810ce38596db4882df9eb5b9e55a99ed5053. The text of the Introduction, Key Strengths, Experience, Projects and Education sections is the CV's text, word for word. The PDF is the judge, not memory. The site serves no CV file. - Text written for the site, including this plan and the spec: no em dash, en dash or semicolon,
airiealways lowercase, no hours or days of work claimed, no dates or times on the/builtpages. - Every external link on the site is one of:
https://www.airie.world,https://apps.apple.com/nz/app/shopl-for-frontline-workers/id1284466280,https://apps.apple.com/us/app/detail-stories-in-image/id1468063860,https://jpeg.org/jpegsystems/index.html,https://usesentinel.tools,https://npmjs.com/package/sentinel-mcp,https://github.com/b0nsun9,https://brightspark.bonsung.me,mailto:developer@bonsung.me. - Section ids, in page order:
hero,introduction,key-strengths,experience,projects,education,showcase. - Showcase and card copy comes from this build only: its reviews, its decision log, its pull requests and its lessons. A step where nothing has happened says so in one plain line and is shown faded. Nothing is made up to fill a step.
- Before using a library's API (Next.js 16, Tailwind 4, Vitest, Playwright, unified, unpdf), read its current documentation through the Context7 MCP server.
- Nothing outward facing (creating the repository, a push to
main, Vercel, DNS) happens without Bonsung's go-ahead at that moment.
Review Focus
- A narrow phone. At 320 px wide nothing on the page or the
/builtpages scrolls sideways, including long code names and links. Owned by Task 4 (nothing overflows sideways at phone width) and Task 8 (at 1024 px the page does not scroll sideways). - No JavaScript. With scripts off, the CV, the showcase and every card still show. Owned by Task 8 (
without JavaScript). - A keyboard reader. The first tab stop is Skip to the CV, and the rail never stands between a reader and the CV. Owned by Task 8 (
a keyboard reader can skip the rail). - A page with no lessons yet.
/built/lessonsand the showcase still build and read plainly whendocs/solutions/is empty. Owned by Task 5 (the list of lessons reads plainly when there are none). - Markdown that holds HTML. Raw HTML in the spec, the plan or a lesson is shown as text, never rendered. Owned by Task 5 (
raw HTML in the markdown is shown as text, never rendered).
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:
npm run lint && npm run typecheck && npm run build && npm testall exit 0, andnpm run test:e2efrom Task 3 on.- Tick the task's boxes in
docs/plan.md. - Commit with a
feat:,fix:,test:,docs:orchore:message ending inCo-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>. git push -u origin <branch>, thengh 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).gh pr checks <number> --watchuntil every check passes, then tell Bonsung the pull request is ready.- 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:
- Create:
AGENTS.md,CLAUDE.md,README.md,.gitignore,docs/spec.md,docs/plan.md - Create:
.claude/settings.json,.claude/hooks/guard.mjs,.githooks/pre-commit - Test:
tests/hooks.test.mjs
Interfaces:
-
Produces:
blockReason(command: string, branch: string): string | null, exported from.claude/hooks/guard.mjs. Run as a hook, the file reads{ "tool_input": { "command": "..." } }on stdin, gets the branch fromgit branch --show-current, and exits 2 with the reason on stderr when blocked, 0 otherwise. The reason for a merge command isAgents never merge pull requests. Bonsung merges. -
Step 1: Write the failing tests (
node:test, no packages needed)
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 */ })
- Step 2: Run them and see them fail
Run: node --test tests/hooks.test.mjs
Expected: FAIL, guard.mjs does not exist.
-
Step 3: Write
guard.mjs,.githooks/pre-commitand.claude/settings.json(aPreToolUsehook onBashrunning the guard). The guard is deliberately cautious: a command that only mentions one of these is blocked too. -
Step 4: Run them and see them pass
Run: node --test tests/hooks.test.mjs
Expected: 7 tests pass.
-
Step 5: Write the rules and the documents
-
AGENTS.md: what the repository is (the source ofhttps://brightspark.bonsung.me, a one-page job application site by Bonsung Koo, whose spec, plan, rules and lessons are published, so written for a public reader), then the rules. Where the work comes from: a change to what the page does starts as a change todocs/spec.md, work followsdocs/plan.md, read current library documentation through Context7,docs/solutions/holds lessons from problems already solved here. Branches and pull requests: never commit onmainordevelop, one branch and one pull request per task targetingdevelop, a pull request fromdeveloptomainpublishes the site, never merge (Bonsung merges), titles and bodies in plain English, commit prefixes and the co-author line. The checks before a pull request. The style rules. No third-party files: no font, image, logo or icon file of any other company, the favicon is drawn in code, the fallback font comes fromnext/font/google. What the hooks block, and why. Anything outward facing waits for Bonsung's go-ahead. -
CLAUDE.md: the single line@AGENTS.md. -
docs/spec.md: the published spec.docs/plan.md: this plan. -
README.md: what this is, the commands, and how a change reachesmainthroughdevelop. -
.gitignore:node_modules/,.next/,out/,test-results/,playwright-report/,.env*,*.tsbuildinfo,next-env.d.ts. -
Step 6: Make the first commit, before the hooks are switched on
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>"
- Step 7: With Bonsung's go-ahead, create the private repository, keep merge commits only, keep branches after a merge, and start
develop
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.
- Step 8: Continue in a Claude Code session opened at the repository root, so
.claude/settings.jsonloads the guard for every task that follows.
Task 2: Scaffold, checks and CI
Branch: chore/scaffold
Files:
- Create:
package.json,package-lock.json,tsconfig.json,next.config.ts,eslint.config.mjs,vitest.config.ts,playwright.config.ts,vercel.json - Create:
src/app/layout.tsx,src/app/page.tsx,src/app/globals.css(a placeholder page, replaced in Task 3) - Create:
src/record/history.ts,src/record/github.ts,src/record/mainchain.ts,scripts/check-history.ts - Create:
.github/workflows/ci.yml - Test:
tests/helpers.ts,tests/site.test.ts,tests/history.test.ts,tests/repo.test.ts
Interfaces:
-
Produces in
tests/helpers.ts:loadPage(path = "index.html"): Document(jsdom overout/),squash(text: string): string(NFKC, straight quotes, no whitespace). -
Produces in
src/record/history.ts:type MainCommit = { sha: string; parents: string[]; subject: string; committerEmail: string }andhistoryViolations(commits: MainCommit[], pulls: Map<number, PullInfo>, merger: string): string[], wherecommitsis the branch's first-parent chain, oldest first. -
Produces in
src/record/github.ts:type PullInfo = { number: number; title: string; body: string; mergedBy: string | null; mergeSha: string | null },resolveToken(env), andgithubClient(token: string, repo = "b0nsun9/portfolio-for-brightspark")withpull(n: number): Promise<PullInfo>. -
Produces in
src/record/mainchain.ts:readMainChain(from: string, cwd?: string): MainCommit[]. -
Produces:
npm run check-history -- <ref>, exit 1 with each violation on its own line. -
Step 1: Write the failing tests
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.
- Step 2: Run them and see them fail
Run: npm run build && npx vitest run
Expected: FAIL, the record modules and the workflow do not exist yet.
-
Step 3: Implement.
next.config.tssetsoutput: "export"andimages.unoptimized.package.jsonscripts:dev,build,start,lint,typecheck,test(vitest run && node --test tests/hooks.test.mjs),prepare(git config core.hooksPath .githooks || true),check-history,test:e2e. CI has five jobs,build(uploadsout),testandlintandtypecheckafter it, andhistory, which checks out the full history and runscheck-historyfor the pushed branch on pushes and for bothorigin/mainandorigin/develop.vercel.jsongives the share card its content type. Runnpm installsoprepareswitches the git hooks on. -
Step 4: Run them and see them pass
Run: npm run lint && npm run typecheck && npm run build && npm test
Expected: all exit 0.
- Step 5: Finish per the working rules.
Task 3: The look and the hero
Branch: feat/hero
Files:
- Create:
src/content/theme.ts,src/content/hero.ts,src/components/Section.tsx,src/components/sections/Hero.tsx,src/app/icon.tsx - Modify:
src/app/globals.css(the tokens as Tailwind theme colours, the font stack, the 5 px radius),src/app/layout.tsx(Nunito Sans fallback, title, description,metadataBasehttps://brightspark.bonsung.me),src/app/page.tsx - Test:
tests/theme.test.ts,tests/page.test.ts,tests/repo.test.ts,e2e/hero.spec.ts
Interfaces:
-
Produces in
src/content/theme.ts:THEME(the eleven tokens),QUIET_OPACITY = 0.7,fade(fg: string, bg: string, alpha: number): string,TEXT_PAIRSas[fg, bg, use][],contrast(a: string, b: string): number. -
Produces in
src/content/hero.ts:HERO_GREETING = "Kia ora,",HERO_HEADLINE = "AI-led code. Engineer-led AI.",HERO_ROLE = "AI Led Software Engineer",HERO_DESCRIPTION(greeting and headline joined by a space). -
Produces:
Section({ id, title?, children })rendering<section data-section={id}>. -
Step 1: Write the failing tests
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
})
- Step 2: Run them and see them fail
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.
-
Step 3: Implement the tokens, the hero (greeting,
h1, the role line in teal, theHirebutton in teal with white bold text), andicon.tsxwithImageResponse: a 32 by 32 teal square with a 5 px radius and BK in white bold.TEXT_PAIRSlists every pair the page uses, with where it is used. -
Step 4: Run them and see them pass
Run: npm run lint && npm run typecheck && npm run build && npm test && npm run test:e2e
Expected: all exit 0.
- Step 5: Finish per the working rules.
Task 4: The CV on the page
Branch: feat/cv
Files:
- Create:
src/content/cv.ts,src/components/Inline.tsx,src/components/Footer.tsx,src/components/sections/{Introduction,KeyStrengths,Experience,Projects,Education}.tsx,tests/fixtures/Bonsung_Koo_CV_Senior_Software_Engineer.pdf - Modify:
src/app/page.tsx,tests/helpers.ts(addpdfText),tests/page.test.ts - Test:
tests/cv.test.ts,e2e/cv.spec.ts
Interfaces:
-
Produces in
src/content/cv.ts:CV = { introduction: string, keyStrengths: string[], jobs: { company: string, roles: string[], bullets: string[] }[], project: { title: string, subtitle: string, bullets: string[] }, education: { degree: string, place: string }[] }.**bold**and[text](url)are the only markup. -
Produces:
Inline({ text })rendering that markup. Every CV line carriesdata-cv-line. -
Produces in
tests/helpers.ts:pdfText(): Promise<string>, the fixture's text through unpdf. -
Step 1: Write the failing tests
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 */ })
- Step 2: Run them and see them fail
Run: npm run build && npx vitest run tests/cv.test.ts tests/page.test.ts
Expected: FAIL, the CV sections do not exist.
-
Step 3: Implement. Copy the fixture from the CV that goes with the application, check its size and SHA-256 against Global Constraints, and write
cv.tsline by line from the PDF's text, the links taken from the PDF's link areas. -
Step 4: Run them and see them pass
Run: npm run lint && npm run typecheck && npm run build && npm test && npm run test:e2e
Expected: all exit 0.
- Step 5: Finish per the working rules.
Task 5: The working documents as pages
Branch: feat/built
Files:
- Create:
src/record/docs.ts,src/app/built/layout.tsx,src/app/built/{rules,spec,plan}/page.tsx,src/app/built/lessons/page.tsx,src/app/built/lessons/[name]/page.tsx - Modify:
src/app/globals.css(the.docstyles) - Test:
tests/docs.test.ts,tests/built.test.ts,tests/page.test.ts
Interfaces:
-
Produces in
src/record/docs.ts:type DocBlock = { line: number; source: string },parseDoc(markdown: string): DocBlock[],renderDoc(markdown: string): string(each block getsid="L<line>", raw HTML shown as text),readDoc(name: "spec" | "plan"): string,readRules(): string,type Lesson = { name: string; title: string; body: string },readLessons(): Lesson[](empty whendocs/solutions/has no lesson). -
Produces: the routes
/built/rules,/built/spec,/built/plan,/built/lessons,/built/lessons/<name>, each with a header linking to the page, then the rules, the spec, the plan and the lessons. -
Step 1: Write the failing tests
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/ */ })
- Step 2: Run them and see them fail
Run: npm run build && npx vitest run tests/docs.test.ts tests/built.test.ts
Expected: FAIL, the pages do not exist.
-
Step 3: Implement with unified (
remark-parse,remark-gfm,remark-rehype,rehype-stringify), the lessons read fromdocs/solutions/**/*.mdwithout their frontmatter. -
Step 4: Run them and see them pass
Run: npm run lint && npm run typecheck && npm run build && npm test && npm run test:e2e
Expected: all exit 0.
- Step 5: Finish per the working rules.
Task 6: Share card, page details and analytics
Branch: feat/details
Files:
- Create:
src/app/opengraph-image.tsx,e2e/analytics.spec.ts - Modify:
src/app/page.tsx(Open Graph details for the home page only),src/app/layout.tsx(<Analytics />from@vercel/analytics/next),package.json - Test:
tests/page.test.ts
Interfaces:
-
Consumes:
HERO_GREETING,HERO_HEADLINE,HERO_ROLE,HERO_DESCRIPTION,THEME. -
Produces:
/opengraph-image, 1200 by 630, the hero as it reads on the page. -
Step 1: Write the failing tests
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 */ })
- Step 2: Run them and see them fail
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.
-
Step 3: Implement the share card with
ImageResponsein the hero's colours and headline weight, and the analytics component in the root layout. -
Step 4: Run them and see them pass
Run: npm run lint && npm run typecheck && npm run build && npm test && npm run test:e2e
Expected: all exit 0.
- Step 5: Finish per the working rules.
Task 7: The showcase
Branch: feat/showcase
Files:
- Modify first:
docs/spec.md(the Evidence for steps 3 and 4, written from this build's reviews and decision log so far, in the first commit) - Create:
src/content/showcase.ts,src/components/sections/Showcase.tsx - Modify:
src/app/page.tsx - Test:
tests/showcase.test.ts,tests/sources.test.ts,e2e/cv.spec.ts
Interfaces:
-
Produces in
src/content/showcase.ts:type ShowcaseStep = { title: string; says: string; label: string; evidence: string; quiet?: boolean; links?: { text: string; href: string }[] },SHOWCASE_LEAD,SHOWCASE_RULES: ShowcaseStep,SHOWCASE_STEPS: ShowcaseStep[](seven),LESSON_PATH: string | nullandLESSON_TITLE: string | null(the lesson step 7 cites,nulluntil one exists). -
The rules render as
#step-0, each step asli[data-step]with idstep-<n>, its evidence in afigureunder its label. A quiet step carriesdata-quietand is shown faded. -
Step 1: Write the evidence for steps 3 and 4 into the spec. One real finding from a review of Tasks 1 to 6, and one real judgement call from the decision log of Tasks 1 to 6, each retold in one sentence. If there is none for a step, its Evidence says so in one plain line and the step is quiet. Steps 6 and 7 stay quiet with Not yet run. and Nothing written down yet. until Task 10. Commit
docs: the showcase's evidence from this build. -
Step 2: Write the failing tests
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 */ })
- Step 3: Run them and see them fail
Run: npm run build && npx vitest run tests/showcase.test.ts tests/sources.test.ts
Expected: FAIL, the showcase does not exist.
-
Step 4: Implement the showcase as a dark slate section after Education, with the copy from the spec's table and the quiet path for steps without evidence.
-
Step 5: Run them and see them pass
Run: npm run lint && npm run typecheck && npm run build && npm test && npm run test:e2e
Expected: all exit 0.
- Step 6: Finish per the working rules.
Task 8: The rail and the section cards
Branch: feat/rail
Files:
- Modify first:
docs/spec.md(under The rail, one table per card, written from the build record of Tasks 3 to 7, in the first commit) - Create:
src/content/cards.ts,src/components/Band.tsx,src/components/Rail.tsx,src/components/SectionCard.tsx - Modify:
src/app/page.tsx, the section components (each in itsBand),src/app/globals.css - Test:
tests/cards.test.ts,tests/sources.test.ts,e2e/rail.spec.ts
Interfaces:
-
Consumes:
parseDoc,readDoc,readLessons,SHOWCASE_RULES,SHOWCASE_STEPS. -
Produces in
src/content/cards.ts:type CardStep = { n: number; label: string; text: string; quiet: boolean },type SectionCard = { section: string; heading: string; specQuote: string; specLine: number; taskLine: number; testLines: Record<string, number>; lessonName?: string; steps: CardStep[] },SECTION_CARDS: SectionCard[], linked at build time: a missing spec quote, task, test or lesson fails the build. -
Produces:
Band({ id, className, children })with two cells from 1024 px up,RailCell({ id }),SectionCard({ card }). -
Step 1: Write the cards into the spec. For each of Hero, Introduction, Key Strengths & Achievements, Work Experience, Selected Projects and Education, seven lines: the spec line the section comes from, the plan task and the test named first, what its review found, its judgement call, its pull request and checks, and steps 6 and 7 quiet until Task 10. Every line is a real event from this build, or a plain Nothing for this section. marked quiet. Commit
docs: the section cards from this build. -
Step 2: Write the failing tests
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 }) => {})
- Step 3: Run them and see them fail
Run: npm run build && npx vitest run tests/cards.test.ts tests/sources.test.ts
Expected: FAIL, the rail does not exist.
-
Step 4: Implement the rail as a light mist column, 26rem wide from 1024 px up, with the rules and the seven step titles beside the hero and each card level with its section, and on narrower screens a closed How this section was built at the end of each section. A Skip to the CV link comes first.
-
Step 5: Run them and see them pass
Run: npm run lint && npm run typecheck && npm run build && npm test && npm run test:e2e
Expected: all exit 0.
- Step 6: Finish per the working rules.
Task 9: Review the whole site, and fix what it finds
Branch: fix/final-review
- Step 1: Run the final review. A fresh reviewer on the most capable model reads the whole of
developagainstdocs/spec.mdand this plan at once, and asks for each test whether it would fail if what it covers were broken, demonstrating it where it can. It reports findings by severity. - Step 2: Bonsung reads the findings and says which to fix.
- Step 3: For each finding to fix, write the failing test first, then the fix, and record each decision in the decision log.
- Step 4: Run every check.
Run: npm run lint && npm run typecheck && npm run build && npm test && npm run test:e2e
Expected: all exit 0.
- Step 5: Finish per the working rules. If the review found nothing to fix, the pull request carries only the ticked boxes.
Task 10: Write down what was learnt, and finish steps 6 and 7
Branch: docs/lessons
Files:
-
Modify first:
docs/spec.md(the Evidence for steps 6 and 7, and the cards' steps 6 and 7) -
Create:
docs/solutions/<category>/<name>.md, one per problem solved in this build that is worth knowing next time -
Modify:
src/content/showcase.ts,src/content/cards.ts -
Step 1: Write the lessons with Compound Engineering's compound step, one file per solved problem, with searchable frontmatter and a
title. In the past tense, no line numbers, no dates. If this build solved nothing worth a lesson, write none. -
Step 2: Write steps 6 and 7 into the spec. Step 6 from Task 9's review, in one sentence. Step 7 the title of one lesson, with Read the lessons. For each card, step 6 and step 7 from the same, or quiet with Nothing for this section. If there is no lesson, step 7 stays quiet with a plain line and Read the lessons stays absent. Commit
docs: steps 6 and 7 from the final review and the lessons. -
Step 3: Update
showcase.tsandcards.tsto the spec, and run the showcase, cards and sources tests.
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.
- Step 4: Run every check.
Run: npm run lint && npm run typecheck && npm run build && npm test && npm run test:e2e
Expected: all exit 0.
- Step 5: Finish per the working rules.
Task 11: Publish and verify
Branch: none. A pull request from develop to main.
- Step 1: Open the pull request from
developtomainwith a plain summary of the site, and watch its checks. - Step 2: Bonsung merges it. Then confirm
developis still on the remote and thehistorycheck passes onmain. - Step 3: With Bonsung's go-ahead, he imports the repository in Vercel, keeps
mainas the production branch, and turns on Web Analytics. - Step 4: He attaches
brightspark.bonsung.mein Vercel after a green production build, and adds the DNS recordbrightspark, aCNAMEtocname.vercel-dns.com, DNS only. - Step 5: Verify before the link is shared, as in the spec:
- CI is green on
main. https://brightspark.bonsung.meopens in a real browser with a valid certificate.- The showcase reads well on a desktop and a phone width, and its links open the rules, the spec, the plan and the list of lessons.
- Nothing in the hero moves.
- The share card shows when the link is pasted into a chat.
- On a desktop each card sits beside its section and shows all seven steps. On a phone the rail is gone and each section's note opens.
- CI is green on