loaded.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 loaded.bonsung.me, a portfolio page in Loaded's colours that shows its own build: the spec line, the checks and the merged change behind every section, from a record produced by the repository itself.
Architecture: A statically exported Next.js site. A record script reads the repository's own history (git) and its pull requests and check results (GitHub's API) at build time and writes one JSON file, from which the /built pages and the build notes are rendered. The spec and this plan are published from docs/, each block anchored by its line number. Vitest checks the built HTML, Playwright checks behaviour in a browser, and a history check proves main holds 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/remark for Markdown, unpdf for the CV's text, tsx for scripts, GitHub Actions, Vercel, Cloudflare DNS.
Spec: docs/spec.md. Read it before starting.
Global Constraints
- Repository
b0nsun9/portfolio-for-loaded, private, default branchmain. Addresshttps://loaded.bonsung.me. - Colours, exactly: champagne
#faf5dc, off-black#020608, dark blue#122635, coffee#eddbb7, blue#3b73fa, light coffee#f9f8f5, white#ffffff. Every text and background pair is at least 4.5 to 1. - Font stack
"Avenir Next", Avenir, <fallback>, sans-serif. The fallback is Nunito Sans throughnext/font/google. No font, image, logo or icon file of Loaded's, or of any other company, in the repository. Buttons have a 5 px radius. - Hero, exactly:
Kia ora, I’m/Bonsung. Loaded./Helping the platform that helps hospitality grow./Senior Software Developer. The blue bar fills to 100% beforeLoaded.appears. Under reduced motion the bar is full andLoaded.is there from the start. - Footer line, exactly:
A job application by Bonsung Koo. Not made or endorsed by Loaded.Email shown:loaded@bonsung.me. No phone number in the page text. - On every
/builtpage, exactly:The repository is private. Access on request.No dates or times on the/builtpages. - CV file:
public/cv/Bonsung_Koo_CV_Senior_Software_Developer.pdf, 65,927 bytes, SHA-25618fbcab91a7f18f9edc94602ce2005c46778aeb41a0a82695b1a1d4bf4f3eaa3. - 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.
- Text written for the page: no em dash, en dash or semicolon,
airiealways lowercase, no hours or days claimed. - 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,mailto:loaded@bonsung.me. - Section ids, in page order:
hero,introduction,key-strengths,experience,projects,education,download-cv, thenstrip. - 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, a token, DNS) happens without Bonsung's go-ahead at that moment.
Working rules (every task)
Start: git switch main && git pull, then git switch -c <the task's branch>. If the previous task's pull request is still open, branch from that branch instead. Every pull request targets main.
Finish:
npm run lint && npm run typecheck && npm run build && npm testall exit 0, andnpm run test:e2efrom Task 4 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 main --title "<title>" --body "<body>". The title and body are published on/built/changes: 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.
File Map
| File | Responsibility |
|---|---|
AGENTS.md, CLAUDE.md |
Rules for coding agents. CLAUDE.md imports AGENTS.md |
docs/spec.md, docs/plan.md |
The published spec and this plan |
.claude/settings.json, .claude/hooks/guard.mjs |
The coding-agent hook |
.githooks/pre-commit |
Refuses commits on main |
.github/workflows/ci.yml |
The five checks: build, test, lint, typecheck, history |
src/content/theme.ts |
Colour tokens and the contrast formula |
src/content/cv.ts |
The CV's text, line by line |
src/components/sections/*.tsx |
One file per page section |
src/components/{Section,Inline,Footer,Strip,BuildSwitch,BuildNote}.tsx |
Shared pieces |
src/record/*.ts |
History, GitHub, documents, tests, notes: everything the build record is made from |
scripts/record.ts, scripts/check-history.ts |
Write the record, check main's history |
src/app/built/{spec,plan,checks,changes}/page.tsx |
The build record pages |
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. -
Step 1: Write the failing tests (
node:test, no packages needed)
import { test } from "node:test"
import assert from "node:assert/strict"
import { execFileSync } from "node:child_process"
import { mkdtempSync } from "node:fs"
import { tmpdir } from "node:os"
import { join, resolve } from "node:path"
import { blockReason } from "../.claude/hooks/guard.mjs"
const blocked = (command, branch = "feat/hero") => blockReason(command, branch) !== null
test("the agent guard stops skipping the git hooks", () => {
for (const c of [
"git commit --no-verify -m 'feat: x'",
"git commit -n -m 'feat: x'",
"git push --no-verify origin feat/hero",
"git -c core.hooksPath=/dev/null commit -m x",
"git config core.hooksPath .git/hooks",
]) 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/hero:main",
"git push origin +main",
"git push origin refs/heads/main",
]) assert.ok(blocked(c), c)
assert.ok(blocked("git push", "main"))
assert.ok(blocked("git push -u origin HEAD", "main"))
})
test("the agent guard stops merging pull requests", () => {
for (const c of [
"gh pr merge 3",
"gh pr merge 3 --merge --admin",
"gh api -X PUT repos/b0nsun9/portfolio-for-loaded/pulls/3/merge",
]) assert.ok(blocked(c), c)
assert.ok(blocked("git merge feat/hero", "main"))
})
test("the agent guard lets ordinary branch work through", () => {
for (const c of [
"git commit -m 'feat: add the hero'",
"git push -u origin feat/hero",
"git push origin feat/main-menu",
"git push -n origin feat/hero",
"gh pr create --base main --fill",
"gh pr checks 3 --watch",
"git merge main",
]) assert.equal(blockReason(c, "feat/hero"), null, c)
})
test("the pre-commit hook refuses commits on main", () => {
const dir = mkdtempSync(join(tmpdir(), "hook-"))
const git = (...args) => execFileSync("git", ["-C", dir, ...args], { stdio: "pipe" })
git("init", "-b", "main")
git("config", "core.hooksPath", resolve(".githooks"))
git("config", "user.email", "test@example.com")
git("config", "user.name", "Test")
assert.throws(() => git("commit", "--allow-empty", "-m", "straight onto main"))
git("switch", "-c", "feat/x")
assert.doesNotThrow(() => git("commit", "--allow-empty", "-m", "on a branch"))
})
- Step 2: Run them and see them fail
Run: node --test tests/hooks.test.mjs
Expected: FAIL, cannot find .claude/hooks/guard.mjs.
- Step 3: Write
guard.mjs,.githooks/pre-commitand.claude/settings.json
blockReason is conservative: a command that merely mentions --no-verify (in a commit message, say) is blocked too. A push targets main when a refspec's destination is main or refs/heads/main (with or without +), or when the current branch is main and the push names no other branch. The pre-commit hook is a POSIX sh script that exits 1 with a message when git symbolic-ref --short HEAD is main, and must be executable. .claude/settings.json registers the guard as a PreToolUse hook on Bash, run as node "$CLAUDE_PROJECT_DIR/.claude/hooks/guard.mjs".
- Step 4: Run them and see them pass
Run: node --test tests/hooks.test.mjs
Expected: 5 tests pass.
-
Step 5: Write the rules and the documents
-
AGENTS.md: what the repository is, then these rules. Changes to what the page does start as a change todocs/spec.md. Work followsdocs/plan.md. Never commit onmain, one branch and one pull request per task, never merge (Bonsung merges). The checks to run before a pull request (Working rules above). Agent commits end with the co-author line. Pull request titles and bodies are published, so they follow the page's style rules. The page's style rules and the no third-party files rule (Global Constraints). Read current library documentation through Context7 before using an API. What the hooks block and why. -
CLAUDE.md: the single line@AGENTS.md. -
docs/spec.md: the published spec.docs/plan.md: this plan. -
README.md: one paragraph on what this is and where the spec and plan are. -
.gitignore:node_modules/,.next/,out/,src/record/generated/,test-results/,playwright-report/,.env*. -
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 and set merges to merge commits only
gh repo create b0nsun9/portfolio-for-loaded --private --source . --push
gh api -X PATCH repos/b0nsun9/portfolio-for-loaded \
-F allow_merge_commit=true -F allow_squash_merge=false -F allow_rebase_merge=false \
-F delete_branch_on_merge=true -f merge_commit_title=MERGE_MESSAGE -f merge_commit_message=PR_TITLE
gh repo view b0nsun9/portfolio-for-loaded --json visibility,mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed
Expected: "visibility":"PRIVATE", merge commits allowed, squash and rebase not.
- 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: feat/scaffold
Files:
- Create:
package.json,next.config.ts,tsconfig.json,eslint.config.mjs,postcss.config.mjs,vitest.config.ts - Create:
src/app/layout.tsx,src/app/page.tsx,src/app/globals.css,.github/workflows/ci.yml - Test:
tests/helpers.ts,tests/site.test.ts
Interfaces:
-
Produces:
loadPage(path?: string): Documentintests/helpers.ts, parsingout/<path>(defaultindex.html) with jsdom.squash(text: string): string, which applies NFKC, turns curly quotes straight and removes all whitespace, so text compares across line breaks. -
Produces npm scripts:
dev,build,lint(eslint .),typecheck(tsc --noEmit),test(vitest run && node --test tests/hooks.test.mjs),prepare(git config core.hooksPath .githooks || true, so an install outside a git checkout still works). -
Step 1: Scaffold Next.js 16 in a temporary folder and copy it in
Run npx create-next-app@16 into an empty temporary folder with TypeScript, ESLint, Tailwind, the src/ folder, the App Router and the @/* alias, without git. Copy everything except .git, README.md and .gitignore into the repository. Delete public/*.svg and src/app/favicon.ico: they are Next.js and Vercel artwork. Set output: "export" and images: { unoptimized: true } in next.config.ts. Add vitest and jsdom as dev dependencies, with vitest.config.ts including only tests/**/*.test.ts and aliasing @ to src. Run npm install, which also switches the git hooks on.
- Step 2: Write the failing test
import { expect, it } from "vitest"
import { loadPage } from "./helpers"
it("the site builds to a static page", () => {
const doc = loadPage()
expect(doc.documentElement.lang).toBe("en-NZ")
expect(doc.title).toBe("Bonsung Koo, Senior Software Developer")
})
- Step 3: Run it and see it fail
Run: npm run build && npx vitest run tests/site.test.ts
Expected: FAIL, the scaffold's lang is en and its title differs.
-
Step 4: Set
lang="en-NZ"and the title insrc/app/layout.tsx, and reducepage.tsxto an empty<main> -
Step 5: Run it and see it pass
Run: npm run build && npm test
Expected: PASS, and the hook tests still pass.
- Step 6: Add the CI workflow
.github/workflows/ci.yml runs on pull_request and on push to main, with permissions: { contents: read, pull-requests: read, actions: read }. Jobs, named exactly: build (checkout with fetch-depth: 0, Node 22, npm ci, npm run build with GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}, upload out as the artifact site), test (needs build, checkout with fetch-depth: 0, npm ci, download site into out, npm test), lint and typecheck. Use the current major version of each action.
- Step 7: Finish (Working rules). Title: Scaffold the site and its checks.
Task 3: main holds merges only
Branch: feat/history-check
Files:
- Create:
src/record/history.ts,src/record/github.ts,scripts/check-history.ts - Modify:
package.json(addtsx, scriptcheck-history),.github/workflows/ci.yml(jobhistory) - Test:
tests/history.test.ts
Interfaces:
-
Produces in
src/record/history.ts:type MainCommit = { sha: string; parents: string[]; subject: string; committerEmail: string }historyViolations(commits: MainCommit[], pulls: Map<number, PullInfo>, merger: string): string[], wherecommitsismain'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: NodeJS.ProcessEnv): string | null, fromRECORD_GITHUB_TOKEN, thenGITHUB_TOKEN.githubClient(token: string, repo?: string)(defaultb0nsun9/portfolio-for-loaded) withpull(n: number): Promise<PullInfo>.
-
Step 1: Write the failing tests
import { expect, it } from "vitest"
import { historyViolations, type MainCommit } from "@/record/history"
import type { PullInfo } from "@/record/github"
const root: MainCommit = { sha: "a1", parents: [], subject: "chore: start with the spec, the plan and the rules", committerEmail: "developer@example.com" }
const merge = (sha: string, n: number, over: Partial<MainCommit> = {}): MainCommit =>
({ sha, parents: ["p", "q"], subject: `Merge pull request #${n} from b0nsun9/feat-${n}`, committerEmail: "noreply@github.com", ...over })
const pull = (n: number, mergeSha: string, mergedBy = "b0nsun9"): [number, PullInfo] =>
[n, { number: n, title: "", body: "", mergedBy, mergeSha }]
it("main may hold the first commit and pull requests merged by Bonsung", () => {
const pulls = new Map([pull(1, "m1"), pull(2, "m2")])
expect(historyViolations([root, merge("m1", 1), merge("m2", 2)], pulls, "b0nsun9")).toEqual([])
})
it("a commit pushed straight to main fails the history check", () => {
const direct = { sha: "x9", parents: ["a1"], subject: "feat: straight in", committerEmail: "developer@example.com" }
const found = historyViolations([root, direct], new Map(), "b0nsun9")
expect(found).toHaveLength(1)
expect(found[0]).toContain("x9")
})
it("a pull request merged by anyone else fails the history check", () => {
const found = historyViolations([root, merge("m1", 1)], new Map([pull(1, "m1", "someone-else")]), "b0nsun9")
expect(found.join()).toContain("#1")
})
it("a merge made outside GitHub fails the history check", () => {
const local = merge("m1", 1, { committerEmail: "developer@example.com" })
expect(historyViolations([root, local], new Map([pull(1, "m1")]), "b0nsun9")).toHaveLength(1)
})
it("a squash or rebase merge fails the history check", () => {
const squash = { sha: "s1", parents: ["a1"], subject: "Add the hero (#1)", committerEmail: "noreply@github.com" }
expect(historyViolations([root, squash], new Map([pull(1, "s1")]), "b0nsun9")).toHaveLength(1)
})
- Step 2: Run them and see them fail
Run: npx vitest run tests/history.test.ts
Expected: FAIL, @/record/history not found.
- Step 3: Implement
historyViolations,resolveToken,githubClient().pullandscripts/check-history.ts
Rules: the first commit has no parents. Every later commit has two parents, a subject matching ^Merge pull request #(\d+) from , committer noreply@github.com, and a pull request whose mergedBy is the merger and whose mergeSha is that commit. Each violation names the commit's short sha and, where there is one, the pull request number. The script reads git rev-list --first-parent --reverse of its argument (default origin/main) with the subject and committer email, fetches each named pull request, prints violations and exits 1 if there are any. It needs a token (resolveToken) and exits 1 without one.
- Step 4: Run them and see them pass
Run: npx vitest run tests/history.test.ts
Expected: 5 tests pass. Then GITHUB_TOKEN=$(gh auth token) npm run check-history exits 0.
-
Step 5: Add the
historyjob (checkout withfetch-depth: 0,npm ci,npm run check-history -- origin/mainwithGITHUB_TOKEN). -
Step 6: Finish. Title: Check that main holds only merged pull requests.
Task 4: The look and the hero
Branch: feat/hero
Files:
- Create:
src/content/theme.ts,src/components/sections/Hero.tsx,src/components/Footer.tsx,src/components/Section.tsx,src/app/icon.svg,playwright.config.ts - Modify:
src/app/globals.css,src/app/layout.tsx,src/app/page.tsx,.github/workflows/ci.yml,package.json(add@playwright/test,serve, scripttest:e2e) - 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 seven colours by name, plusblueInk, a darker blue for small text and button backgrounds),TEXT_PAIRS: [fg: string, bg: string, use: string][](every pair the page uses for text),contrast(a: string, b: string): number(WCAG 2 formula). -
Produces:
Section({ id, title, children })rendering<section id={id} data-section={id}>, with an optional heading. -
Produces in the hero: elements with
data-testidbar-track,bar-fillandloaded-word. The bar isaria-hidden.Loaded.is in the static HTML, shown by CSS. -
Step 1: Write the failing tests
// tests/theme.test.ts
it("the contrast formula matches known values", () => {
expect(contrast("#000000", "#ffffff")).toBeCloseTo(21, 1)
expect(contrast("#ffffff", "#3b73fa")).toBeCloseTo(4.2, 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", () => {
const css = readFileSync("src/app/globals.css", "utf8")
for (const value of Object.values(THEME)) expect(css).toContain(value)
})
// tests/page.test.ts
it("the hero reads Kia ora, I'm Bonsung. Loaded.", () => {
const hero = loadPage().querySelector("[data-section=hero]")!
expect(squash(hero.querySelector("h1")!.textContent!)).toBe(squash("Bonsung. Loaded."))
for (const line of ["Kia ora, I’m", "Helping the platform that helps hospitality grow.", "Senior Software Developer"])
expect(squash(hero.textContent!)).toContain(squash(line))
})
it("the footer says the page is a job application, not Loaded's", () => {
const footer = loadPage().querySelector("footer")!
expect(squash(footer.textContent!)).toContain(squash("A job application by Bonsung Koo. Not made or endorsed by Loaded."))
expect(footer.querySelector("a[href='mailto:loaded@bonsung.me']")).not.toBeNull()
expect(footer.querySelector("a[href='https://github.com/b0nsun9']")).not.toBeNull()
})
// tests/repo.test.ts
const OWN_MEDIA = ["src/app/icon.svg"]
it("no font, image, logo or icon file from another company is in the repository", () => {
const files = execFileSync("git", ["ls-files"], { encoding: "utf8" }).split("\n")
const media = files.filter((f) => /\.(woff2?|ttf|otf|eot|png|jpe?g|gif|webp|avif|svg|ico)$/i.test(f))
expect(media.sort()).toEqual([...OWN_MEDIA].sort())
})
// e2e/hero.spec.ts
test("with reduced motion the bar is full and Loaded. shows at once", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" })
await page.goto("/")
const word = page.getByTestId("loaded-word")
expect(await word.evaluate((el) => getComputedStyle(el).animationName)).toBe("none")
expect(await word.evaluate((el) => getComputedStyle(el).opacity)).toBe("1")
const fill = await page.getByTestId("bar-fill").boundingBox()
const track = await page.getByTestId("bar-track").boundingBox()
expect(fill!.width).toBeCloseTo(track!.width, 0)
})
test("the bar fills before Loaded. appears", async ({ page }) => {
await page.goto("/")
await expect(page.getByTestId("loaded-word")).toHaveCSS("opacity", "1", { timeout: 5000 })
const fill = await page.getByTestId("bar-fill").boundingBox()
const track = await page.getByTestId("bar-track").boundingBox()
expect(fill!.width).toBeCloseTo(track!.width, 0)
})
- Step 2: Run them and see them fail
Run: npm run build && npm test
Expected: FAIL, @/content/theme not found, no hero.
- Step 3: Implement the theme, the hero, the footer and the favicon
The colours go into Tailwind's @theme in globals.css as --color-champagne and so on. The hero sits on champagne in off-black: the greeting, the h1, then the bar (coffee track, blue fill), then the line and the role. CSS only, no script: the fill animates its width from 0 to 100%, then Loaded. animates its opacity from 0 to 1, delayed by the fill's duration, with animation-fill-mode: both. Under prefers-reduced-motion: reduce both have animation: none, the fill is 100% and the word is opaque. The footer is dark blue with coffee text: the name, the GitHub link, the email link and the footer line. icon.svg is drawn here: a short blue bar on champagne, nothing of Loaded's.
- Step 4: Add Playwright
playwright.config.ts: testDir: "e2e", Chromium at 1280 by 800, baseURL: "http://localhost:4173", webServer: { command: "npx serve out -l 4173", port: 4173, reuseExistingServer: !process.env.CI }. Script test:e2e is playwright test. In CI, the test job runs npx playwright install --with-deps chromium and then npm run test:e2e after npm test.
- Step 5: Run everything and see it pass
Run: npm run build && npm test && npm run test:e2e
Expected: PASS.
- Step 6: Finish. Title: Add Loaded's colours, the hero and the footer.
Task 5: The CV on the page
Branch: feat/cv
Files:
- Create:
src/content/cv.ts,src/components/Inline.tsx,src/components/sections/{Introduction,KeyStrengths,Experience,Projects,Education,DownloadCv}.tsx - Create:
public/cv/Bonsung_Koo_CV_Senior_Software_Developer.pdf(the submitted file, copied byte for byte) - Modify:
src/app/page.tsx,tests/helpers.ts(addpdfText),package.json(addunpdf) - Test:
tests/cv.test.ts,tests/page.test.ts
Interfaces:
-
Consumes:
Section,loadPage,squash. -
Produces:
CVinsrc/content/cv.ts, every line a string in which**bold**and[text](url)are the only markup.Inline({ text })renders that markup. Every element that shows a CV line carriesdata-cv-line. Bullets areli[data-cv-line]. -
Produces:
pdfText(): Promise<string>intests/helpers.ts, the PDF's text through unpdf. -
Step 1: Copy the PDF in and check its size and hash (65,927 bytes,
18fbcab9…). -
Step 2: Write the failing tests
// tests/cv.test.ts
const PDF = "public/cv/Bonsung_Koo_CV_Senior_Software_Developer.pdf"
const sha = (p: string) => createHash("sha256").update(readFileSync(p)).digest("hex")
const lines = (section: string, selector = "[data-cv-line]") =>
[...loadPage().querySelectorAll(`[data-section=${section}] ${selector}`)].map((e) => e.textContent ?? "")
async function expectCvLines(section: string, bullets: number | null) {
const pdf = squash(await pdfText())
const found = lines(section)
expect(found.length).toBeGreaterThan(0)
for (const line of found) expect(pdf, line).toContain(squash(line))
if (bullets !== null) expect(lines(section, "li[data-cv-line]")).toHaveLength(bullets)
}
it("the CV download is the submitted PDF", () => {
expect(sha(PDF)).toBe("18fbcab91a7f18f9edc94602ce2005c46778aeb41a0a82695b1a1d4bf4f3eaa3")
expect(sha("out/cv/Bonsung_Koo_CV_Senior_Software_Developer.pdf")).toBe(sha(PDF))
expect(loadPage().querySelector("[data-section=download-cv] a[href='/cv/Bonsung_Koo_CV_Senior_Software_Developer.pdf']")).not.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) })
// tests/page.test.ts
it("airie is always lowercase", () => {
expect(loadPage().body.textContent).not.toMatch(/Airie|AIRIE/)
})
it("every external link is on the allowed list", () => {
const ALLOWED = [
"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",
"mailto:loaded@bonsung.me",
]
for (const file of globSync("out/**/*.html")) for (const a of loadPage(file.slice(4)).querySelectorAll("a[href]")) {
const href = a.getAttribute("href")!
if (!href.startsWith("/") && !href.startsWith("#")) expect(ALLOWED, `${file}: ${href}`).toContain(href)
}
})
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", () => {
const doc = loadPage()
doc.querySelectorAll("[data-cv-line], script, style").forEach((e) => e.remove())
const text = doc.body.textContent!
expect(text).not.toMatch(/[—–;]/)
expect(text).not.toMatch(/\b\d+\s*(hours?|hrs?|days?|weeks?)\b/i)
})
- Step 3: Run them and see them fail
Run: npm run build && npx vitest run tests/cv.test.ts tests/page.test.ts
Expected: FAIL, no sections and no pdfText.
- Step 4: Type
cv.tsfrom the PDF, then build the sections
Read the text with pdftotext -layout and copy each line exactly, headings and separators included. Where the layout splits a heading line (the PDF puts Sentinel's links on its first line, for one), follow the PDF: the test compares against the PDF's text. The Introduction is the CV's summary paragraph. Key Strengths shows each strength's bold label and text as one line. Experience shows, per job, the company line, the role line and the bullets (7, 6 and 1). Projects shows Sentinel's lines and its 3 bullets, with its two links. Education shows each degree and its place line. Download CV links to the PDF. Section headings: Introduction, Key Strengths & Achievements, Work Experience, Selected Projects, Education, Download CV. Sections alternate light coffee and white.
- Step 5: Run them and see them pass
Run: npm run build && npm test && npm run test:e2e
Expected: PASS.
- Step 6: Finish. Title: Put the CV on the page.
Task 6: The spec and the plan as pages
Branch: feat/built-docs
Files:
- Create:
src/record/docs.ts,src/app/built/layout.tsx,src/app/built/spec/page.tsx,src/app/built/plan/page.tsx - Modify:
package.json(addunified,remark-parse,remark-gfm,remark-rehype,rehype-stringify) - Test:
tests/docs.test.ts,tests/built.test.ts
Interfaces:
-
Produces in
src/record/docs.ts:type DocBlock = { line: number; source: string }parseDoc(markdown: string): DocBlock[]: every top-level block, except that a list is replaced by its items.lineis the block's first line,sourceits raw Markdown.findBlock(blocks: DocBlock[], quote: string): DocBlock: the one block whose source, with whitespace collapsed, contains the quote. Throwsnot foundormore than one.renderDoc(markdown: string): string: HTML in which every block fromparseDochasid="L<line>". Raw HTML in the Markdown is not passed through.readDoc(name: "spec" | "plan"): string, readingdocs/<name>.md.
-
Produces:
src/app/built/layout.tsxwith links between the four record pages, a link home, and the lineThe repository is private. Access on request. -
Step 1: Write the failing tests
// tests/docs.test.ts
const md = "# Title\n\nFirst paragraph\nwraps here.\n\n- one item\n- two **bold** item\n\n| a | b |\n|---|---|\n| c | d |\n"
it("each block of a document keeps its first line number", () => {
expect(parseDoc(md).map((b) => b.line)).toEqual([1, 3, 6, 7, 9])
})
it("a spec quote is found across line breaks and markdown", () => {
expect(findBlock(parseDoc(md), "paragraph wraps here").line).toBe(3)
expect(findBlock(parseDoc(md), "two **bold** item").line).toBe(7)
})
it("a quote that is missing or appears twice is an error", () => {
expect(() => findBlock(parseDoc(md), "not there")).toThrow(/not found/)
expect(() => findBlock(parseDoc(md), "item")).toThrow(/more than one/)
})
it("a rendered document gives every block an anchor", () => {
const html = renderDoc(md)
for (const id of ["L1", "L3", "L6", "L7", "L9"]) expect(html).toContain(`id="${id}"`)
})
// tests/built.test.ts
it("the spec and the plan are published with every block addressable", () => {
for (const [page, name] of [["built/spec.html", "spec"], ["built/plan.html", "plan"]] as const) {
const doc = loadPage(page)
for (const b of parseDoc(readDoc(name))) expect(doc.getElementById(`L${b.line}`), `${page} L${b.line}`).not.toBeNull()
expect(squash(doc.body.textContent!)).toContain(squash("The repository is private. Access on request."))
}
})
- Step 2: Run them and see them fail
Run: npx vitest run tests/docs.test.ts
Expected: FAIL, @/record/docs not found.
-
Step 3: Implement
docs.tswith unified (remark-parse and remark-gfm for positions and tables, a small rehype step that setsidfrom each block'sposition.start.line, rehype-stringify), then the two pages and the record layout. -
Step 4: Run them and see them pass
Run: npm run build && npm test && npm run test:e2e
Expected: PASS.
- Step 5: Finish. Title: Publish the spec and the plan.
Task 7: The change record, the checks page and the strip
Branch: feat/built-record
Files:
- Create:
src/record/types.ts,src/record/git.ts,src/record/tests.ts,src/record/options.ts,src/record/read.ts,scripts/record.ts - Create:
src/app/built/changes/page.tsx,src/app/built/checks/page.tsx,src/components/Strip.tsx - Modify:
src/record/github.ts(addchecksFor),package.json(record,predev, andbuildbecomesnpm run record && next build),.github/workflows/ci.yml(thesiteartifact also carriessrc/record/generated, andtestdownloads it to the repository root),src/app/page.tsx - Test:
tests/git.test.ts,tests/record.test.ts,tests/built.test.ts,tests/page.test.ts
Interfaces:
- Produces in
src/record/types.ts:
export type CheckName = "build" | "test" | "lint" | "typecheck" | "history"
export type Conclusion = "success" | "failure" | "cancelled" | "skipped"
export type Change = {
id: string // "start" for the first commit, otherwise "pr-<number>"
number: number | null
title: string
body: string
mergedBy: string | null // GitHub login, null for the first commit
agentWritten: boolean // a commit in it carries a "Co-Authored-By: Claude" line
files: string[]
checks: Partial<Record<CheckName, Conclusion>> | null // null when unknown, and for the first commit
}
export type TestInfo = { name: string; file: string; slug: string }
export type BuildRecord = { changes: Change[]; tests: TestInfo[] } // changes newest first
-
Produces:
gitChanges(repoDir: string, ref?: string): Change[]ingit.ts, from the first-parent chain ofref(defaultHEAD): each merge's number from its subject, title from its body's first line, files fromgit diff --name-only M^1 M,agentWrittenfrom the commits inM^1..M^2, then the first commit.body,mergedByandcheckscome back empty, forscripts/record.tsto fill from GitHub.listTests(files: { path: string; source: string }[]): TestInfo[]andreadTestFiles(root: string)(everything undertests/ande2e/) intests.ts. A test is a literalit("…")ortest("…")call at the start of a line (after indentation), so a test's fixture text inside a string is not counted. The slug is the name in lowercase with each run of other characters turned into-.checksFor(headSha: string): Promise<Partial<Record<CheckName, Conclusion>>>on the GitHub client, from the latest Actions run for that commit and its jobs.recordOptions(env: NodeJS.ProcessEnv, shallow: boolean): { token: string | null }inoptions.ts.readRecord(): BuildRecordinread.ts, readingsrc/record/generated/record.jsonand saying to runnpm run recordwhen it is missing.
-
scripts/record.tstakes the token fromrecordOptions, or fromgh auth tokenon a developer's machine, fills each change frompull()andchecksFor()when there is a token, and writes the record. -
Step 1: Write the failing tests
tests/git.test.ts builds a throwaway repository in a temporary folder, with its own test user configured: a first commit adding docs/spec.md, then branch a adding src/a.ts in a commit ending Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>, merged into main with git merge --no-ff -m "Merge pull request #1 from b0nsun9/a" -m "Add a", then branch b adding src/b.ts with no co-author line, merged the same way as #2 with the body Add b.
it("the change record reads merged pull requests from main's history", () => {
const changes = gitChanges(repo)
expect(changes.map((c) => c.id)).toEqual(["pr-2", "pr-1", "start"])
expect(changes[0]).toMatchObject({ number: 2, title: "Add b", files: ["src/b.ts"], agentWritten: false })
expect(changes[1]).toMatchObject({ number: 1, title: "Add a", files: ["src/a.ts"], agentWritten: true })
expect(changes[2]).toMatchObject({ number: null, files: ["docs/spec.md"] })
})
it("commits on an unmerged branch are not in the change record", () => {
// on a new branch from main, commit src/c.ts, then:
const changes = gitChanges(repo)
expect(changes).toHaveLength(3)
expect(changes.flatMap((c) => c.files)).not.toContain("src/c.ts")
})
// tests/record.test.ts
it("the checks page finds every test by name", () => {
const found = listTests([{ path: "tests/x.test.ts", source: `it("one's test", () => {})\ntest('two', () => {})\ntest.describe("group", () => {})` }])
expect(found.map((t) => t.name)).toEqual(["one's test", "two"])
expect(found[0].slug).toBe("one-s-test")
})
it("test names are unique across the repository", () => {
const names = listTests(readTestFiles(".")).map((t) => t.name)
expect(new Set(names).size).toBe(names.length)
})
it("a production build without a GitHub token stops instead of publishing an incomplete record", () => {
expect(() => recordOptions({ VERCEL_ENV: "production" }, false)).toThrow(/token/)
})
it("a build from a shallow clone stops instead of publishing a short history", () => {
expect(() => recordOptions({ GITHUB_TOKEN: "t" }, true)).toThrow(/shallow/)
})
it("a local build without a token makes a record without check results", () => {
expect(recordOptions({}, false)).toEqual({ token: null })
})
it("every merged change passed every check", () => {
for (const c of readRecord().changes) if (c.id !== "start" && c.checks !== null) {
for (const name of ["build", "test", "lint", "typecheck"] as const) expect(c.checks[name], `${c.id} ${name}`).toBeDefined()
for (const [name, result] of Object.entries(c.checks)) expect(result, `${c.id} ${name}`).toBe("success")
}
})
history is not required on every change because the first pull request merged before that check existed. Its result is still held to success wherever it ran.
// tests/built.test.ts
it("the changes page lists every change, newest first, with who merged it", () => {
const record = readRecord()
const doc = loadPage("built/changes.html")
const items = [...doc.querySelectorAll("[data-change]")]
expect(items.map((e) => e.id)).toEqual(record.changes.map((c) => `change-${c.id}`))
for (const c of record.changes) if (c.mergedBy) expect(doc.getElementById(`change-${c.id}`)!.textContent).toContain(c.mergedBy)
})
it("the checks page names every check and every test", () => {
const doc = loadPage("built/checks.html")
for (const name of ["build", "test", "lint", "typecheck", "history"]) expect(doc.getElementById(`check-${name}`), name).not.toBeNull()
for (const t of readRecord().tests) expect(doc.getElementById(`test-${t.slug}`), t.name).not.toBeNull()
})
// tests/page.test.ts
it("the strip shows the five steps", () => {
const STEPS = [
["Spec", "The page was agreed with the agent in a written spec before any code.", "/built/spec"],
["Plan", "The spec became a step-by-step plan.", "/built/plan"],
["Agents build", "Coding agents wrote the code, a change at a time.", "/built/changes"],
["Checks", "Every change passed the tests, the build and lint, which give the same answer every time.", "/built/checks"],
["I approve", "Nothing reached main without my merge.", "/built/changes"],
]
const strip = loadPage().querySelector("[data-section=strip]")!
const items = [...strip.querySelectorAll("li")]
expect(items).toHaveLength(5)
STEPS.forEach(([title, text, href], i) => {
expect(squash(items[i].textContent!)).toContain(squash(title))
expect(squash(items[i].textContent!)).toContain(squash(text))
expect(items[i].querySelector(`a[href='${href}']`)).not.toBeNull()
})
expect(squash(strip.textContent!)).toContain(squash("Turn on “Show how this was built” to see it on every section."))
})
- Step 2: Run them and see them fail
Run: npx vitest run tests/git.test.ts tests/record.test.ts
Expected: FAIL, modules not found.
- Step 3: Implement the record and the two pages
/built/changes: the merged changes, newest first, each an element with data-change and id="change-<id>": its number and title, its body rendered as Markdown, Written by coding agents where agentWritten, its check results, and Merged by Bonsung (b0nsun9) (or the login, for anyone else). The first commit comes last, under its own heading: it added the spec, the plan and the rules, and went straight onto main to start the repository. /built/checks: one block per check with id="check-<name>" and what it guards in one plain sentence, the tests under test each with id="test-<slug>", and a table of each merged change's results. No dates or times anywhere. The strip goes at the end of the home page, before the footer, as <section data-section="strip"> titled How this page was built.
- Step 4: Run them and see them pass
Run: npm run build && npm test && npm run test:e2e
Expected: PASS. src/record/generated/record.json lists the merged changes with their check results.
- Step 5: Finish. Title: Publish the record of changes and checks.
Task 8: The build notes and the switch
Branch: feat/build-notes
Files:
- Create:
src/record/sections.ts,src/record/notes.ts,src/components/BuildSwitch.tsx,src/components/BuildNote.tsx - Modify:
src/components/Section.tsx(an optionalnote: Noteprop, shown beside the content and below it on narrow screens),src/app/globals.css,src/app/page.tsx - Test:
tests/notes.test.ts,tests/page.test.ts,e2e/switch.spec.ts
Interfaces:
- Consumes:
Change,BuildRecord,DocBlock,findBlock,parseDoc,readDoc,listTests,readTestFiles,readRecord. - Produces in
src/record/sections.ts:type SectionId = "hero" | "introduction" | "key-strengths" | "experience" | "projects" | "education" | "download-cv",type SectionSource = { id: SectionId; specQuote: string; tests: string[]; paths: string[] }andSECTIONS, exactly:
| id | specQuote | tests | paths |
|---|---|---|---|
hero |
Hero copy, a play on Loaded's name and line. |
the hero reads Kia ora, I'm Bonsung. Loaded., with reduced motion the bar is full and Loaded. shows at once, the bar fills before Loaded. appears |
src/components/sections/Hero.tsx, src/components/sections/Hero.css |
introduction |
the CV summary, in his own first-person words |
the introduction is the CV summary |
src/components/sections/Introduction.tsx, src/content/cv.ts |
key-strengths |
the CV's five strengths, which hold the claims about agent-driven work |
every key strength is the CV's text |
src/components/sections/KeyStrengths.tsx, src/content/cv.ts |
experience |
airie, Shopl and Company, PJ FACTORY, with the CV's job titles and dates |
every experience line is the CV's text, airie is always lowercase |
src/components/sections/Experience.tsx, src/content/cv.ts |
projects |
Sentinel, with its two public links |
every project line is the CV's text, every external link is on the allowed list |
src/components/sections/Projects.tsx, src/content/cv.ts |
education |
The text of sections 2 to 6 is the CV's text. |
every education line is the CV's text |
src/components/sections/Education.tsx, src/content/cv.ts |
download-cv |
the PDF he submitted to Loaded, served byte for byte |
the CV download is the submitted PDF |
src/components/sections/DownloadCv.tsx, public/cv/Bonsung_Koo_CV_Senior_Software_Developer.pdf |
-
Produces in
src/record/notes.ts:firstChangeTouching(changes: Change[], paths: string[]): Change | null: the oldest merged change (not the first commit) with a file inpaths, which is the change that first shipped the section.type Note = { section: SectionId; spec: { quote: string; href: string }; tests: { name: string; href: string }[]; shipped: { title: string; href: string; mergedBy: string; result: Conclusion | null } | null }buildNote(section: SectionSource, record: BuildRecord, spec: DocBlock[]): Note. Links are/built/spec#L<line>,/built/checks#test-<slug>and/built/changes#change-<id>.resultis the shipped change'stestresult. The quote is shown without Markdown.
-
Produces:
BuildSwitch, a client component:<button role="switch" aria-checked>labelled Show how this was built, fixed to the top right of the screen with its own background, renderedhiddenuntil the page's script runs. On, it setsdata-build="on"on<html>and?build=onin the address (withhistory.replaceState). A page opened with?build=onstarts on. CSS hides[data-note]unlesshtml[data-build="on"]. -
BuildNoteshows the three parts as From the spec, Checked by and Shipped in. Checked by names the tests and says Passed on #N, Failed on #N or No result recorded. Shipped in says #N <title>, merged by Bonsung, or Not merged yet whenshippedis null. -
Step 1: Write the failing tests
// tests/notes.test.ts
const change = (n: number, files: string[]): Change =>
({ id: `pr-${n}`, number: n, title: `Change ${n}`, body: "", mergedBy: "b0nsun9", agentWritten: true, files, checks: { test: "success" } })
it("a note names the merged change that first shipped its section", () => {
const changes = [change(3, ["src/a.ts", "src/b.ts"]), change(2, ["src/a.ts"]), change(1, ["src/b.ts"])]
expect(firstChangeTouching(changes, ["src/a.ts"])?.id).toBe("pr-2")
})
it("a section no merged change has touched yet says so", () => {
expect(firstChangeTouching([change(1, ["src/b.ts"])], ["src/a.ts"])).toBeNull()
const record: BuildRecord = { changes: [change(1, ["src/b.ts"])], tests: listTests(readTestFiles(".")) }
expect(buildNote(SECTIONS[0], record, parseDoc(readDoc("spec"))).shipped).toBeNull()
})
it("every section's spec quote and tests exist", () => {
const spec = parseDoc(readDoc("spec"))
const tests = new Set(listTests(readTestFiles(".")).map((t) => t.name))
expect(SECTIONS.map((s) => s.id)).toEqual(["hero", "introduction", "key-strengths", "experience", "projects", "education", "download-cv"])
for (const s of SECTIONS) {
expect(() => findBlock(spec, s.specQuote), s.id).not.toThrow()
for (const t of s.tests) expect(tests.has(t), t).toBe(true)
}
})
// tests/page.test.ts
it("every build note points at a spec line, a test and a change that exist", () => {
const notes = [...loadPage().querySelectorAll("[data-note]")]
expect(notes).toHaveLength(7)
for (const a of notes.flatMap((n) => [...n.querySelectorAll("a")])) {
const [path, id] = a.getAttribute("href")!.split("#")
expect(loadPage(`${path.slice(1)}.html`).getElementById(id), a.getAttribute("href")!).not.toBeNull()
}
})
// e2e/switch.spec.ts
const toggle = (page: Page) => page.getByRole("switch", { name: "Show how this was built" })
test("the switch shows and hides the build notes", async ({ page }) => {
await page.goto("/")
await expect(page.locator("[data-note]").first()).toBeHidden()
await toggle(page).click()
await expect(toggle(page)).toHaveAttribute("aria-checked", "true")
await expect(page.locator("[data-note]").first()).toBeVisible()
await expect(page).toHaveURL(/\?build=on$/)
await toggle(page).click()
await expect(page.locator("[data-note]").first()).toBeHidden()
await expect(page).not.toHaveURL(/build=on/)
})
test("a link with ?build=on opens with the notes showing", async ({ page }) => {
await page.goto("/?build=on")
await expect(toggle(page)).toHaveAttribute("aria-checked", "true")
await expect(page.locator("[data-note]").first()).toBeVisible()
})
test("on a phone the notes sit below their sections and nothing scrolls sideways", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 })
await page.goto("/?build=on")
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375)
const heading = await page.locator("[data-section=introduction] h2").boundingBox()
const note = await page.locator("[data-section=introduction] [data-note]").boundingBox()
expect(note!.y).toBeGreaterThan(heading!.y)
const h1 = await page.locator("h1").boundingBox()
const sw = await toggle(page).boundingBox()
expect(sw!.y + sw!.height <= h1!.y || sw!.x >= h1!.x + h1!.width).toBe(true)
})
- Step 2: Run them and see them fail
Run: npx vitest run tests/notes.test.ts
Expected: FAIL, @/record/sections not found.
- Step 3: Implement the sections table,
notes.ts, the switch and the notes
The home page builds the seven notes at build time from readRecord() and readDoc("spec") and passes each to its section. Notes are in the static HTML, so their links are checkable without a browser.
- Step 4: Run them and see them pass
Run: npm run build && npm test && npm run test:e2e
Expected: PASS.
- Step 5: Finish. Title: Show how each section was built.
Task 9: Share card and page details
Branch: feat/share-card
Files:
-
Create:
src/app/opengraph-image.tsx -
Modify:
src/app/layout.tsx(metadata),README.md -
Test:
tests/page.test.ts -
Step 1: Write the failing test
it("the page has its description and share card", () => {
const meta = (p: string) => loadPage().querySelector(`meta[property='${p}'], meta[name='${p}']`)?.getAttribute("content")
expect(meta("description")).toBe("Senior Software Developer. Helping the platform that helps hospitality grow.")
expect(meta("og:url")).toBe("https://loaded.bonsung.me")
expect(meta("og:image")).toMatch(/^https:\/\/loaded\.bonsung\.me\/opengraph-image/)
})
- Step 2: Run it and see it fail
Run: npm run build && npx vitest run tests/page.test.ts
Expected: FAIL, no description.
- Step 3: Add the metadata and the share card
metadataBase is https://loaded.bonsung.me. opengraph-image.tsx uses ImageResponse from next/og with dynamic = "force-static", 1200 by 630: the hero's words on champagne with the full blue bar, nothing of Loaded's. The README gets the commands (npm run dev, build, test, test:e2e, check-history), the two environment variables (RECORD_GITHUB_TOKEN, VERCEL_DEEP_CLONE) and how a change reaches main.
- Step 4: Run everything and see it pass
Run: npm run build && npm test && npm run test:e2e
Expected: PASS, and the repository test still lists only src/app/icon.svg.
- Step 5: Finish. Title: Add the share card and the page description.
Task 10: Publish and verify
Replaced by Task 14 after the revision below. The build no longer needs a token or a full clone.
Files: none in the repository.
Each step that sets something up outside the repository is Bonsung's or needs his go-ahead.
- Step 1 (Bonsung): Make a read-only token for the record. A fine-grained GitHub token for this repository only, with read access to Actions, Contents, Metadata and Pull requests.
- Step 2 (Bonsung): Import the repository in Vercel. Framework Next.js, production branch
main. Environment variables for Production and Preview:RECORD_GITHUB_TOKEN(the token) andVERCEL_DEEP_CLONE=true, so the build sees the whole history. - Step 3: Check the first deployment's build log. Expected: the record step lists every merged change with check results, and no
shallowerror. If the history is still shallow, stop and bring it back to Bonsung. - Step 4 (Bonsung): Add
loaded.bonsung.mein Vercel and aCNAMErecordloadedtocname.vercel-dns.comin Cloudflare, DNS only. - Step 5: Verify in a real browser (automated fetches can be shown a hosting checkpoint, so they do not count):
- CI is green on
main. https://loaded.bonsung.meopens with a valid certificate.- The switch works at desktop width and at 375 px, and each note's three links open the right spec block, test and change.
- With reduced motion emulated, the bar is full and
Loaded.shows at once. - The downloaded CV's SHA-256 is
18fbcab91a7f18f9edc94602ce2005c46778aeb41a0a82695b1a1d4bf4f3eaa3.
- CI is green on
Review Focus
- A shared link with
?build=onmust open with the switch on and the notes showing. Pinned by a link with ?build=on opens with the notes showing (Task 8). - A reader with reduced motion must see the full bar and
Loaded.at once, with nothing animating. Pinned by with reduced motion the bar is full and Loaded. shows at once (Task 4). - A phone at 375 px with the notes on must not scroll sideways, and the fixed switch must not cover the name. Pinned by on a phone the notes sit below their sections and nothing scrolls sideways (Task 8).
- A production build without the token or with a shallow clone must fail rather than publish a record missing changes or results. Pinned by the two
recordOptionstests (Task 7). - A section no merged change has touched yet (on a preview of an open pull request) must say Not merged yet, not name the wrong change. Pinned by a section no merged change has touched yet says so (Task 8).
Revision: the workflow showcase
The spec changed before the page went live. A list of every commit said little about how the work was done, so a short showcase of the workflow replaces the published build record, the site stops offering the CV as a PDF, and the footer loses its disclaimer line. Tasks 1 to 9 stay above as they were built. Task 10 is replaced by Task 14.
Goal: Replace the build record with the eight-step showcase the spec describes, and publish the site.
Architecture: The site stays a static Next.js export, but the build no longer reads git or GitHub. The showcase's copy lives in one content file. Tests check each piece of evidence against the file it comes from (AGENTS.md, the agent hook, the CI workflow, the plan, the end-to-end tests and the lesson in docs/solutions/). The history check stays in CI.
Revision constraints
These replace the matching lines of the Global Constraints above. Every other line there still holds.
- Section ids, in page order:
hero,introduction,key-strengths,experience,projects,education,showcase. - Footer:
Bonsung Koo, aGitHublink tohttps://github.com/b0nsun9and aloaded@bonsung.melink tomailto:loaded@bonsung.me, and nothing else. No phone number in the page text. - The site serves no CV file. The submitted PDF stays in the repository at
tests/fixtures/Bonsung_Koo_CV_Senior_Software_Developer.pdf, the same bytes, as the source the content tests read. Its hash is no longer pinned. /builthas two pages,/built/specand/built/plan. Each still saysThe repository is private. Access on request.and shows no date or time.npm run buildisnext build. The build needs no token and works from a shallow clone.- The showcase's copy is the spec's table, word for word (Task 13).
Task 11: Take out the build record, the CV download and the footer line
Branch: chore/showcase-groundwork.
Files:
- Modify:
docs/spec.md,docs/plan.md(the revised spec and this section, in the first commit) - Delete:
src/components/BuildSwitch.tsx,src/components/BuildNote.tsx,src/components/Strip.tsx,src/components/sections/DownloadCv.tsx,src/app/built/changes/page.tsx,src/app/built/checks/page.tsx,src/record/{checks,git,notes,options,read,sections,tests,types}.ts,scripts/record.ts,e2e/switch.spec.ts,tests/{notes,record,production,git}.test.ts - Move:
public/cv/Bonsung_Koo_CV_Senior_Software_Developer.pdftotests/fixtures/withgit mv - Modify:
src/app/page.tsx,src/components/Section.tsxand every file insrc/components/sections/(nonoteprop),src/components/Footer.tsx,src/app/built/layout.tsx(links to Spec and Plan only),src/record/docs.ts(droprenderBodyandfindBlock, which only the notes used),src/record/github.ts(dropchecksForand what only it uses,resolveTokenreadsGITHUB_TOKENthen falls back togh auth token),src/record/history.ts(dropdirectCommits),package.json(buildisnext build, norecordorpredev,unpdfmoves to devDependencies),.github/workflows/ci.yml(build and test jobs check out shallow, the build has no token, the artifact isoutonly and is downloaded toout, because a single uploaded folder loses its own name),.gitignore,vercel.json(drop the/cv/rule),README.md,AGENTS.md(pull request text is no longer published, so the bullet that says so keeps only its style rule) - Test:
tests/page.test.ts,tests/cv.test.ts,tests/built.test.ts,tests/docs.test.ts,tests/site.test.ts,tests/helpers.ts
Interfaces:
-
Produces:
Section({ id, title, children })with nonote.githubClient(token, repo?)withpullonly,resolveToken(env),historyViolations,readMainChainandscripts/check-history.tsunchanged in behaviour.parseDoc,readDocandrenderDocunchanged.pdfText()intests/helpers.tsreads the fixture. -
Step 1: Bring in the revised spec and this section of the plan (the private notes say how). Commit
docs: revise the spec and the plan for the workflow showcase. -
Step 2: Write the failing tests
In tests/page.test.ts, replace the footer test and the strip and build-note tests with:
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 loaded@bonsung.me"))
expect(footer.querySelector("a[href='mailto:loaded@bonsung.me']")).not.toBeNull()
expect(footer.querySelector("a[href='https://github.com/b0nsun9']")).not.toBeNull()
})
it("every link inside the site opens a page that exists", () => {
for (const file of globSync("out/**/*.html")) for (const a of loadPage(file.slice(4)).querySelectorAll("a[href^='/']")) {
const [path, id] = a.getAttribute("href")!.split("#")
const page = path === "/" ? "index.html" : `${path.slice(1)}.html`
expect(existsSync(`out/${page}`), `${file}: ${path}`).toBe(true)
if (id) expect(loadPage(page).getElementById(id), `${file}: ${a.getAttribute("href")}`).not.toBeNull()
}
})
it("the page sections are in order", () => {
const ids = [...loadPage().querySelectorAll("[data-section]")].map((e) => e.getAttribute("data-section"))
expect(ids).toEqual(["hero", "introduction", "key-strengths", "experience", "projects", "education"])
})
In tests/cv.test.ts, replace the download test with the one below, and make the headings test read section:not([data-section=showcase]) h2 and expect ["Introduction", "Key Strengths & Achievements", "Work Experience", "Selected Projects", "Education"].
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()
})
In tests/built.test.ts, delete the changes and checks tests and make the date test expect builtPages().sort() to equal ["built/plan.html", "built/spec.html"]. In tests/docs.test.ts, delete the change body test and the two spec quote tests. In tests/site.test.ts, add:
it("the build reads nothing from git or GitHub", () => {
expect(JSON.parse(readFileSync("package.json", "utf8")).scripts.build).toBe("next build")
for (const file of globSync("src/{app,components,content}/**/*.{ts,tsx}"))
expect(readFileSync(file, "utf8"), file).not.toMatch(/child_process|api\.github\.com/)
})
- Step 3: Run them and see them fail
Run: npm run build && npx vitest run
Expected: FAIL on the footer, link, section order, CV file, headings, /built pages and build script tests.
- Step 4: Delete, move and modify the files listed above
Delete the imports of the removed modules with them, then run npx tsc --noEmit until nothing refers to a deleted file.
- Step 5: Run everything
Run: npm run lint && npm run typecheck && npm run build && npm test && npm run test:e2e
Expected: all pass, and ls out shows no cv folder and out/built holds only plan.html and spec.html.
- Step 6: Commit
chore: take out the build record, the CV download and the footer line
Task 12: Write down the lesson from the hero's review
The session runs this task itself, not a fresh agent, because the compound step asks Bonsung before it edits AGENTS.md.
Branch: docs/hero-test-lesson.
Files:
- Create: one file under
docs/solutions/(its folder, name and title are chosen by the compound step) - Modify:
AGENTS.md(one line sayingdocs/solutions/exists and what it holds, from the compound step's discoverability check)
Interfaces:
-
Produces: the lesson's path and the
title:in its frontmatter, which Task 13 quotes. -
Step 1: Run Compound Engineering's compound step (
/ce-compound, interactive) with this context: Task 4's review found that the end-to-end test "the bar fills before Loaded. appears" checked only the end state, the bar full and the word visible, so it passed whatever order the two animations ran in. The fix compares the word's computed animation delay with the fill's delay plus its duration, with both animations present. Seee2e/hero.spec.ts. Approve theAGENTS.mdline when it asks. Expected: one new file underdocs/solutions/with atitle:in its frontmatter, and the line inAGENTS.md. If the compound step judges the problem not worth a lesson, run it on another problem solved while building this site, and say which in the pull request. -
Step 2: Check the title follows the style rules for text written for the page (no em dash, en dash or semicolon, no durations). It will be shown on the page.
-
Step 3: Run everything as in Task 11 Step 5. Expected: all pass.
-
Step 4: Commit
docs: write down the lesson from the hero's review. Put the lesson's title in the pull request body so Bonsung sees it before it reaches the page.
Task 13: The showcase
Branch: feat/showcase.
Files:
- Create:
src/content/showcase.ts,src/components/sections/Showcase.tsx,tests/showcase.test.ts - Modify:
src/app/page.tsx(the showcase after Education),tests/page.test.ts(the section order ends withshowcase)
Interfaces:
- Consumes:
parseDocandreadDocfromsrc/record/docs.ts.blockReason(command, branch)from.claude/hooks/guard.mjs(tests only). The lesson from Task 12. - Produces: in
src/content/showcase.ts,type ShowcaseStep = { title: string; says: string; label: string; evidence: string; link?: { text: string; href: string } }, andSHOWCASE_LEAD,SHOWCASE_STEPS(eight steps),SHOWCASE_CLOSE,LESSON_PATH,LESSON_TITLEandPLAN_HERO_LINE. Text between backticks renders as<code>.
The copy, exactly. LESSON_PATH is Task 12's file, and the last step's evidence is that file's title. The plan link opens the hero's task: PLAN_HERO_LINE is the line of the first block of parseDoc(readDoc("plan")) whose source starts with ### Task 4:.
export const SHOWCASE_LEAD = "How I work with coding agents, shown on the page you are reading."
export const SHOWCASE_CLOSE = "The repository is private. Access on request."
export const SHOWCASE_STEPS: ShowcaseStep[] = [
{ title: "Agree the spec first.", says: "I talk each change through with the agent until we agree a written spec.", label: "From AGENTS.md", evidence: "“A change to what the page does starts as a change to `docs/spec.md`.”", link: { text: "Read the spec", href: "/built/spec" } },
{ title: "Plan small tasks, with their tests.", says: "Each task names its tests before any code exists, and Context7 keeps the plan on current library documentation.", label: "From the plan", evidence: "“the bar fills before Loaded. appears”", link: { text: "Read the plan", href: `/built/plan#L${PLAN_HERO_LINE}` } },
{ title: "Keep the rules in the repository.", says: "Hooks stop the agents committing or pushing to `main`, and merging.", label: "The agent hook", evidence: "“Agents never merge pull requests. Bonsung merges.”" },
{ title: "One agent builds, another reviews.", says: "A fresh agent checks each task against the spec.", label: "From a review", evidence: "The loading bar’s test could not fail if the order were wrong. A check of the order was added." },
{ title: "Write down judgement calls.", says: "Each one records what was decided, why, and what it costs if wrong.", label: "From the decision log", evidence: "The starter’s Geist font was removed, because the rules allow no other company’s fonts." },
{ title: "Checks decide, and I merge.", says: "Every pull request passes checks that give the same answer every time, and only I merge into `main`.", label: "On every pull request", evidence: "Build, test, lint, typecheck, history" },
{ title: "Review the whole at the end.", says: "A more capable model reviews the finished site at once.", label: "From the final review", evidence: "It found a real email address in the published plan’s sample data. It now uses an example address." },
{ title: "Write down what was learnt.", says: "Compound Engineering writes the lessons from solved problems into `docs/solutions/`, where the agents look when they plan.", label: "From docs/solutions", evidence: LESSON_TITLE },
]
Markup: a dark blue band like the other sections, <section id="showcase" data-section="showcase"> with the h2 How this page was built, the lead, an <ol> of eight <li data-step>, each with an h3 title, the sentence, and a <figure> holding a <figcaption> (the label) and the evidence, then the link if there is one, then the closing line.
- Step 1: Write the failing tests in
tests/showcase.test.ts
const plain = (s: string) => s.replace(/`/g, "")
const read = (p: string) => readFileSync(p, "utf8")
const steps = () => [...loadPage().querySelectorAll("[data-section=showcase] li[data-step]")]
it("the showcase shows the eight steps as written", () => {
const section = loadPage().querySelector("[data-section=showcase]")!
expect(squash(section.textContent!)).toContain(squash(SHOWCASE_LEAD))
expect(squash(section.textContent!)).toContain(squash(SHOWCASE_CLOSE))
expect(steps()).toHaveLength(8)
SHOWCASE_STEPS.forEach((s, i) => {
for (const text of [s.title, s.says, s.label, s.evidence]) expect(squash(steps()[i].textContent!)).toContain(squash(plain(text)))
})
})
it("each step's evidence sits in its own figure, under its label", () => {
steps().forEach((li, i) => {
expect(li.querySelectorAll("figure")).toHaveLength(1)
expect(li.querySelector("figure figcaption")!.textContent).toBe(SHOWCASE_STEPS[i].label)
expect(squash(li.querySelector("figure")!.textContent!)).toContain(squash(plain(SHOWCASE_STEPS[i].evidence)))
})
})
it("the two links open the spec and the hero's task in the plan", () => {
const links = steps().flatMap((li) => [...li.querySelectorAll("a")]).map((a) => a.getAttribute("href"))
const line = parseDoc(readDoc("plan")).find((b) => b.source.startsWith("### Task 4:"))!.line
expect(links).toEqual(["/built/spec", `/built/plan#L${line}`])
})
it("every quotation in the showcase is in the file it comes from", () => {
const quote = (i: number) => SHOWCASE_STEPS[i].evidence.replace(/[“”]/g, "")
const plan = read("docs/plan.md")
const [from, to] = [plan.indexOf("\n### Task 4:"), plan.indexOf("\n### Task 5:")]
expect(from).not.toBe(-1)
expect(to).not.toBe(-1)
const task4 = plan.slice(from, to)
expect(read("AGENTS.md")).toContain(quote(0))
for (const text of [task4, read("e2e/hero.spec.ts")]) expect(text).toContain(quote(1))
expect(blockReason("gh pr merge 1", "feat/showcase")).toBe(quote(2))
})
it("what each story points to is in the repository", () => {
const hero = read("e2e/hero.spec.ts").split('test("the bar fills before Loaded. appears"')[1].split("\ntest(")[0]
expect(hero).toContain("expect(seconds(wordTiming.delay)).toBeGreaterThanOrEqual(seconds(fillTiming.delay) + seconds(fillTiming.duration))")
const tracked = execFileSync("git", ["ls-files", "src"], { encoding: "utf8" }).split("\n").filter(Boolean)
expect(tracked.filter((f) => /geist/i.test(f))).toEqual([])
for (const file of ["package.json", "package-lock.json", ...tracked.filter((f) => f !== "src/content/showcase.ts")]) {
if (!/\.(woff2?|ttf|otf|png|jpe?g|svg|ico|webp|avif)$/i.test(file)) expect(read(file), file).not.toMatch(/geist/i)
}
expect(read("AGENTS.md")).toContain("## No third-party files")
const ci = read(".github/workflows/ci.yml")
expect(ci.split("\njobs:\n")[0]).toMatch(/^on:\n(?:.*\n)*? pull_request:/m)
const jobs = [...ci.split("\njobs:\n")[1].matchAll(/^ ([\w-]+):$/gm)].map((m) => m[1])
expect(jobs).toEqual(SHOWCASE_STEPS[5].evidence.toLowerCase().split(", "))
const emails = read("docs/plan.md").match(/[\w.+-]+@[\w-]+(\.[\w-]+)+/g)!
expect(emails.some((e) => e.endsWith("@example.com"))).toBe(true)
for (const e of emails) expect(["loaded@bonsung.me", "noreply@anthropic.com", "noreply@github.com"].includes(e) || e.endsWith("@example.com"), e).toBe(true)
const title = /^title:\s*["']?(.+?)["']?\s*$/m.exec(read(LESSON_PATH))![1]
expect(SHOWCASE_STEPS[7].evidence).toBe(title)
})
src/content/showcase.ts reads LESSON_TITLE from the title: line of LESSON_PATH's frontmatter at build time, so the test above parses the file on its own.
- Step 2: Run them and see them fail
Run: npm run build && npx vitest run tests/showcase.test.ts
Expected: FAIL, src/content/showcase.ts does not exist.
-
Step 3: Write
src/content/showcase.tsandShowcase.tsx, and add the showcase after Education inpage.tsx -
Step 4: Run everything as in Task 11 Step 5. Expected: all pass, the phone width test included, now with the showcase on the page.
-
Step 5: Commit
feat: show how this page was built, step by step
Task 14: Publish and verify
Files: none in the repository. Each step that sets something up outside the repository is Bonsung's or needs his go-ahead.
- Step 1 (Bonsung): In Vercel, redeploy
main. The build needs no environment variables. Any record token or deep clone setting added earlier can be removed. - Step 2: Check the production build log. Expected:
next buildsucceeds with no record step and no token or shallow clone error. - Step 3 (Bonsung): Add
loaded.bonsung.mein Vercel and aCNAMErecordloadedtocname.vercel-dns.comin Cloudflare, DNS only. - Step 4: Verify in a real browser (automated fetches can be shown a hosting checkpoint, so they do not count):
- CI is green on
main. https://loaded.bonsung.meopens with a valid certificate.- The showcase reads well at desktop width and at 375 px, and its two links open the spec and the hero's task in the plan.
- With reduced motion emulated, the bar is full and
Loaded.shows at once. curl -sI https://loaded.bonsung.me/opengraph-imageshowscontent-type: image/png, and the share card shows when the link is pasted into a chat.
- CI is green on
Task 15: Loaded's favicon
The author chose Loaded's own favicon for the tab, unchanged (spec Decision 9). It is the one file of another company's that the repository holds, so the rule against third-party files names it, and step 5 of the showcase now says the rules allow no other company's fonts, which stays true.
Branch: feat/loaded-favicon.
Files:
-
Modify:
docs/spec.md,docs/plan.md(the spec change and this task, in the first commit) -
Delete:
src/app/icon.svg -
Create:
src/app/icon.png, Loaded's favicon as loadedhub.com links it (<link rel="shortcut icon">): a 32 by 32 PNG of 1,224 bytes, SHA-2564e90fbe6f1e52b1ca927484a49b56bfdb196ca1b4bb9bf29bbb81122753371c8 -
Modify:
AGENTS.md(the third-party rule namessrc/app/icon.pngas its one exception, and keeps its heading),src/content/showcase.ts(step 5's evidence),tests/repo.test.ts -
Step 1: Bring in the spec change and this task. Commit
docs: use Loaded's favicon, the one exception to the third-party rule. -
Step 2: Write the failing tests in
tests/repo.test.ts
const THIRD_PARTY = { "src/app/icon.png": "4e90fbe6f1e52b1ca927484a49b56bfdb196ca1b4bb9bf29bbb81122753371c8" }
it("no font, image, logo or icon file from another company is in the repository", () => {
const files = execFileSync("git", ["ls-files"], { encoding: "utf8" }).split("\n")
const media = files.filter((f) => /\.(woff2?|ttf|otf|eot|png|jpe?g|gif|webp|avif|svg|ico)$/i.test(f))
expect(media.sort()).toEqual(Object.keys(THIRD_PARTY).sort())
})
it("the favicon is Loaded's own, unchanged", () => {
for (const [file, sha] of Object.entries(THIRD_PARTY)) expect(createHash("sha256").update(readFileSync(file)).digest("hex"), file).toBe(sha)
expect(loadPage().querySelector("link[rel='icon']")?.getAttribute("href")).toMatch(/^\/icon\.png/)
})
- Step 3: Run them and see them fail. Run:
npm run build && npx vitest run tests/repo.test.ts. Expected: FAIL,src/app/icon.pngdoes not exist. - Step 4: Replace the icon, change step 5's evidence to "The starter’s Geist font was removed, because the rules allow no other company’s fonts.", and add the exception to
AGENTS.md. - Step 5: Run everything as in Task 11 Step 5. Expected: all pass.
- Step 6: Commit
feat: show Loaded's favicon in the tab
Task 16: No line about the repository
The author will not open the repository, so the site stops offering it (spec Decision 4). The showcase loses its closing line and the /built pages lose theirs. The earlier tasks above still mention that line, because they describe what was built then.
Branch: chore/no-repository-line.
Files:
-
Modify:
docs/spec.md,docs/plan.md(the spec change and this task, in the first commit) -
Modify:
src/content/showcase.ts(dropSHOWCASE_CLOSE),src/components/sections/Showcase.tsx,src/app/built/layout.tsx,AGENTS.md(the opening paragraph no longer says the repository may be opened),tests/showcase.test.ts,tests/built.test.ts,tests/page.test.ts -
Step 1: Bring in the spec change and this task. Commit
docs: say nothing about access to the repository. -
Step 2: Write the failing test in
tests/page.test.ts, and drop the two expectations of the old line fromtests/showcase.test.tsandtests/built.test.ts
it("no page offers access to the repository", () => {
for (const file of globSync("out/**/*.html")) {
const doc = loadPage(file.slice(4))
doc.querySelectorAll("script, style, article.doc").forEach((e) => e.remove())
expect(doc.body.textContent, file).not.toMatch(/repository is private|access on request/i)
}
})
The published spec and plan are left out of this check (article.doc), since the plan's earlier tasks quote the old line as history.
- Step 3: Run it and see it fail. Run:
npm run build && npx vitest run tests/page.test.ts. Expected: FAIL onindex.htmland the two/builtpages. - Step 4: Remove the line from the showcase and the
/builtlayout, and rewordAGENTS.md's opening paragraph to say the spec and the plan are published on the site, so write for a public reader. - Step 5: Run everything as in Task 11 Step 5. Expected: all pass.
- Step 6: Commit
chore: drop the line about access to the repository
Revision Review Focus
- A link inside the site to a removed page (the
/builtmenu, an old anchor) must not land on a missing page. Pinned by every link inside the site opens a page that exists (Task 11). - A Vercel build with no token and a shallow clone must succeed. Pinned by the build reads nothing from git or GitHub (Task 11), and the CI build job checks out shallow with no token.
- An edit to
AGENTS.md, the hook, CI or the plan that changes a quoted line must fail the tests, not leave the page quoting something false. Pinned by every quotation in the showcase is in the file it comes from and what each story points to is in the repository (Task 13). - A phone at 375 px must not scroll sideways once the showcase's longer lines and code are on the page. Pinned by nothing overflows sideways at phone width (Task 4), which now covers the showcase.
- A screen reader must hear the steps in order, each with its own evidence. Pinned by each step's evidence sits in its own figure, under its label (Task 13).