diff --git a/.github/workflows/build-check.yml b/.github/workflows/build-check.yml new file mode 100644 index 0000000..6fba6d6 --- /dev/null +++ b/.github/workflows/build-check.yml @@ -0,0 +1,40 @@ +name: Verify build is up to date + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + build-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "24" + cache: "npm" + + - run: npm ci + + - name: Lint generated HTML + run: npm run lint:html + + - name: Build the site + run: npm run build + + - name: Fail if the committed output is stale + run: | + if [ -n "$(git status --porcelain)" ]; then + echo "::error::Committed output is out of date with the source. Run 'npm run build' locally and commit the result." + echo "Changed files:" + git status --porcelain + echo "Diff:" + git diff + exit 1 + fi + echo "Committed output matches a fresh build." diff --git a/.gitignore b/.gitignore index a4b2385..658c39d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ _local_preview.html node_modules/ +.claude/ diff --git a/.htmlvalidate.json b/.htmlvalidate.json new file mode 100644 index 0000000..19c1335 --- /dev/null +++ b/.htmlvalidate.json @@ -0,0 +1,9 @@ +{ + "extends": ["html-validate:recommended"], + "root": true, + "rules": { + "void-style": "off", + "doctype-style": ["error", { "style": "lowercase" }], + "long-title": "off" + } +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..f2fa146 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,16 @@ +node_modules +package-lock.json + +# Generated favicon assets (created by an external tool, not hand-maintained). +images + +# Generated theme output is formatted by build.js, not by lint:fix. +# (Leaving it here is harmless since the output is already Prettier-clean, +# but build.js is the source of truth for it.) + +# Hand-authored post prose. build.js renders + formats the HTML output; +# leave the markdown sources alone. +_posts + +# Local, gitignored personal layer (SOUL.md, skills, drafts). +.claude diff --git a/CLAUDE.md b/CLAUDE.md index ebd2529..7f63550 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,9 +22,11 @@ landing pages and regenerates the homepage post list. `title` and `date` are required. `description` is optional but recommended. `tags` is optional and defaults to `[]`. + 3. Run `npm install` once, then `npm run build` whenever you change a post. 4. Review the diff (it will include both the source `.md` and the generated - HTML) and commit everything. + HTML) and commit everything. CI fails the PR if the committed HTML isn't a + fresh build. The published URL will be `https://workman.tech//YYYY-MM-DD-slug.html`. @@ -37,24 +39,45 @@ The published URL will be - `_theme_template.html` — per-theme landing page template. - `build.js` — single-file Node script. Walks `_posts/`, renders posts and theme landing pages, regenerates the post-list region inside `index.html` - between `` and `` markers. + between `` and `` markers. Every file it + writes is run through Prettier first, so the committed HTML is deterministically + formatted. - `/` — generated output directories (committed; GH Pages needs them). Each generated HTML file starts with a `` marker so `npm run clean` can safely identify and remove them. - `index.html` — hand-edited, but the region between the two marker comments is rewritten on every build. -Dependencies: `markdown-it` and `gray-matter`. That's it. +Dependencies: `markdown-it` and `gray-matter` at runtime, plus dev tools +`prettier` (which `build.js` runs over every rendered file so the generated HTML +is consistently formatted) and `html-validate` (HTML linting, config in +`.htmlvalidate.json`). ## Commands - `npm run dev` — local preview at (wraps `python3 -m http.server 3000`; needs Python 3 on `PATH`). -- `npm run build` — render everything. +- `npm run build` — render everything (each file formatted with Prettier). +- `npm run lint:html` — run `html-validate` over `index.html` and the generated + theme HTML. +- `npm run lint:fix` — format source files with Prettier (`prettier --write .`; + scope controlled by `.prettierignore`). html-validate has no autofix, so it is + not part of this. - `npm run clean` — remove generated theme directories (only directories whose HTML files all start with the generated marker are touched) and reset the homepage post-list region to the empty-state placeholder. +## Continuous integration + +`.github/workflows/build-check.yml` runs on every pull request and on pushes to +`main`. It runs `npm ci && npm run build` and fails if the working tree changed +afterward (`git status` non-empty). That guards the one invariant that matters +here: the committed HTML must be a fresh render of the source. It catches stale +generated HTML, generated files that were never committed, and `build.js` errors +(bad frontmatter or filenames make the build exit non-zero). If it fails, run +`npm run build` locally and commit the result. The workflow then runs +`npm run lint:html` (html-validate) over the output. + ## Follow-up ideas - Code-block syntax highlighting (markdown-it emits @@ -64,6 +87,4 @@ Dependencies: `markdown-it` and `gray-matter`. That's it. - RSS feed. - `sitemap.xml`. - Per-post Open Graph images. -- GitHub Action to build on push and fail PRs when the committed HTML is - out of date with the source. - Formal tests for `build.js` (currently validated manually). diff --git a/README.md b/README.md index 193e465..2a7e665 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,14 @@ Source for Ryan Workman's personal blog at [workman.tech](https://workman.tech). ## Tech stack -- Node.js build script (`markdown-it` + `gray-matter`) +- Node.js build script (`markdown-it` + `gray-matter`), with `prettier` formatting the generated HTML and `html-validate` linting it - Vanilla HTML and CSS - GitHub Pages for hosting -- No frameworks, no bundler, no CI build step +- No frameworks or bundler. A GitHub Actions workflow validates each pull request (build freshness + HTML lint) ## Local setup -1. Install Node.js 18 or newer. +1. Install Node.js 22.17 or newer (required by the `html-validate` lint tooling; CI runs Node 24). 2. `npm install`. 3. To preview the site, run `npm run dev` and visit . This is a thin wrapper around `python3 -m http.server 3000` — it serves the repo root over HTTP so absolute paths like `/css/style.css` and `/images/...` resolve correctly. Requires Python 3 on your `PATH`. @@ -30,6 +30,7 @@ Source for Ryan Workman's personal blog at [workman.tech](https://workman.tech). ``` `title` and `date` are required. `description` and `tags` are optional. + 3. Run `npm run build`. 4. Review the diff — both the source `.md` and the generated HTML. 5. Commit everything: the markdown source and the generated HTML inside `/`. @@ -37,12 +38,14 @@ Source for Ryan Workman's personal blog at [workman.tech](https://workman.tech). The published URL pattern is `https://workman.tech//YYYY-MM-DD-slug.html`. -> **Note:** The build runs locally. There is no GitHub Action that builds on push — if you commit a new post's source `.md` without running `npm run build`, the homepage and the theme landing page will not update on the live site. Always run `npm run build` and commit the generated HTML alongside the source. +> **Note:** The build runs locally; CI does not build the site for you. Instead, a GitHub Actions workflow re-runs the build on every pull request and fails if the committed HTML doesn't match a fresh build (it also lints the HTML). So if you commit a post's source `.md` without running `npm run build`, CI will catch it. Always run `npm run build` and commit the generated HTML alongside the source. ## Commands - `npm run dev` — start a local static server on port 3000 for previewing the site. -- `npm run build` — render all posts and regenerate the homepage post list. +- `npm run build` — render all posts and regenerate the homepage post list. Every generated file is formatted with Prettier. +- `npm run lint:html` — validate `index.html` and the generated theme HTML with `html-validate`. +- `npm run lint:fix` — auto-format source files with Prettier. (html-validate has no autofix, so HTML problems must be fixed by hand.) - `npm run clean` — remove generated theme directories and reset the homepage post-list region to its empty state. Only touches files that start with the `` marker, so hand-edited content is safe. ## Deployment @@ -51,4 +54,4 @@ GitHub Pages serves the repo root. Any push to `main` triggers a redeploy and is ## More -See `CLAUDE.md` for the architecture details and the list of deferred follow-ups (RSS, sitemap, per-tag pages, syntax highlighting, OG images, build-on-push action). +See `CLAUDE.md` for the architecture details and the list of deferred follow-ups (RSS, sitemap, per-tag pages, syntax highlighting, OG images). diff --git a/_posts/side-projects/2026-05-24-pokemon-card-scanner-in-one-night.md b/_posts/side-projects/2026-05-24-pokemon-card-scanner-in-one-night.md new file mode 100644 index 0000000..ed7c827 --- /dev/null +++ b/_posts/side-projects/2026-05-24-pokemon-card-scanner-in-one-night.md @@ -0,0 +1,151 @@ +--- +title: i built a pokémon card scanner in one night with claude code +date: 2026-05-24 +description: A garage sale gave me a reason to finally figure out what my Pokémon cards are worth, so I built a photo-to-catalog scanner in one night with Claude vision, Next.js, and an agent-led XP workflow. +tags: [ai, claude-code, agentic-development, nextjs, side-projects] +--- + +I was selling off a chunk of my Pokémon card collection at a neighborhood +garage sale. A few buyers messaged ahead to say they were coming specifically +for the cards, which is when it hit me that I had no idea what most of them +were actually worth. + +So I needed something quick and functional. Point my phone at a page of cards, +snap a photo, and get a current price on the rarer ones. No standing there +flipping through TCGPlayer one card at a time while a line of buyers waits on +me. + +They say necessity is the mother of invention... thus my [Pokémon TCG Catalog](https://pokemontcg.workman.tech) was born. + +## a side project, run like a real project + +My team at work has leaned in hard on agent-driven engineering recently, and +I've been driving a good part of that shift. This project was a chance to take +the same practices I use professionally and point them at a problem of my own. + +The architecture was obvious before I wrote a line. A JS framework for the app, +a small SQL database, Claude vision to read the cards, and a free Pokémon API +for the canonical data and prices. It was the perfect tightly-scoped app to be +built using a few agents without babysitting every keystroke. + +So I spent about 30 minutes with Claude Opus 4.7 hashing out the architecture, +a handful of user stories and how I envisioned all of the pieces coming +together. +Out of that came six phases of work, each cut into four or five +single-responsibility tasks. + +This is just how I run projects at work now. I lean on Kent Beck's Extreme +Programming fundamentals and bend them to fit agent-led execution. The planning +is just-in-time. A top-level `ROADMAP.md` lays the phases out as high-level +road-signs, just enough to confirm the order makes sense, and I only flesh a +phase out in full right before I execute it. A phase is roughly the chunk of +work you'd scope in a single backlog grooming, no more. You wouldn't point and +prioritize months of stories in one sitting, and that restraint still matters +even though Claude could happily plan hundreds of tasks up front. Just because it +can doesn't mean it should. A spec that detailed would be wrong by phase three +anyway. + +Each phase gets its own directory, and at the top sits an `AGENT_INIT.md`. +That's the primer an agent reads first: the goal of the phase, the constraints, +the files to read and in what order, the decisions already made, and what's +explicitly out of scope. Under it are numbered task files, each one basically a +ticket scoped tight enough to land as a single PR. Small, focused, independently +verifiable. The `AGENT_INIT.md` is the source of truth for the phase. It's where +the agents go to figure out intent, and where pivots get written down when +reality disagrees with the plan. Reality usually disagrees with the plan. The +nice part is I can move in small steps and change direction halfway through +without losing the bigger thread. + +Alright, plan in hand and the first phase sketched, I handed the tasks to +Claude Code and started working through them. + +## what got built + +Here's the shape of what came out the other side: + +- **Next.js 16 (App Router) on React 19, in TypeScript.** Server-first by + default. The database, the auth, and the external API calls all live on the + server, and the client is just a rendering layer. +- **Neon (serverless Postgres) via Drizzle ORM.** One small schema. Cards, the + copies I own, and their condition, foil status, and price. +- **Claude Sonnet 4.6 for the actual identification.** This is the heart of it. + One photo of up to nine cards goes out in a single API call, and Claude hands + back each card's name, set, number, foil status, a rough condition read, and + a normalized bounding box. The app uses those boxes to crop one tight image + per card right on the device, so every row in my inventory ends up with its + own clean photo. +- **pokemontcg.io for canonical data and prices.** It's free, it indexes every + set back to Base Set, and it bundles daily TCGPlayer and Cardmarket prices + right into the card response. No second integration just for pricing, which + was a nice surprise. +- **Vercel for hosting and Blob storage**, with HeroUI v3 on Tailwind v4 for a + clean, mobile-first UI that stays out of the way of the card art. + +A couple choices I was glad I made. + +**Forced structured output instead of parsing JSON.** Rather than ask Claude +for JSON and hope, I gave it a single tool, `report_identified_cards`, with a +strict JSON Schema, and forced the model to call it. Anthropic validates the +shape server-side before any of it reaches my code. No `JSON.parse`, no +stripping markdown fences, no schema drift to defend against. The client just +reads the validated payload. Boring, in the best possible way. + +**Caching over cron.** The identification system prompt never changes, so it +goes out with prompt caching, and after the first call the bulk of it is about +90% cheaper. That keeps each multi-card photo down to roughly a couple cents. +And instead of a scheduled job to keep prices fresh, the app caches each card +for 24 hours and lazily refreshes it on view. That matches how often the +upstream prices actually move, and it was one less moving part to build at +11pm. + +## the result + +I worked through all six phases and had the thing live on Vercel in about four +hours. Then I did the one thing it was built for. I scanned in my highest-value +cards, watched them resolve to the right printings with current prices, and +walked into the garage sale with a catalog on my phone instead of a search box +and a prayer. + +It worked exactly as designed + +## the honest version + +Let me be clear about what this is and what it isn't. It's a personal tool that +solves a personal problem, and it has the rough edges to prove it. If I wanted +to run this for anyone other than me, I'd have real work to do on performance +and load balancing, and I'd have to swap the single shared-password admin for +an auth setup that's actually resilient and scalable. And the scanning, the +whole centerpiece of the thing, only nails the identification cleanly about +half the time. So I built a manual search fallback for the cards Claude whiffs +on. + +None of that changes the takeaway though. The cards were just the excuse. That +30 minutes of planning with Opus up front, figuring out what to build and in +what order, is the entire reason four hours of agent-driven building actually +landed a working app. I had a real problem the night before a garage sale, and +I went to bed with it solved. Hard to be mad at that. + +## the part i'm actually excited about + +Here's the thing I keep coming back to. A few years ago, solving a problem this +specific meant one of two things. Either I find a SaaS product that handles 80% +of it, pay every month, and bend my actual problem to fit the 20% it was never +designed for. Or I block out a whole weekend to wire up a framework, a database, +auth, and hosting by hand before I can even start on the fun part. Both of those +are a lot of friction for "I want to price my Pokémon cards at a garage sale." + +This time I described what I wanted, planned it out, and had a working tool by +bedtime. A couple cents in API calls and a free Vercel plan. That's genuinely +wild, and I don't think we should pretend it isn't. + +I came into tech as a career changer, and the best part of this whole ride has +been watching the barriers keep falling. The stuff that used to gatekeep +building software, the setup, the boilerplate, the sheer pile of things you had +to know before you could make anything at all, keeps getting lower. Tools like +Claude Code just knocked another big chunk of it down. That's not a threat. That +is the dream. More people building more specific things for themselves is +exactly the point. + +So if you've been sitting on some dumb little idea, some problem only you have, +consider this your sign. The floor has never been lower than it is right now. Go +build the thing. I can't wait to see what you make diff --git a/_template.html b/_template.html index 7bcbf87..d4c0d66 100644 --- a/_template.html +++ b/_template.html @@ -1,53 +1,70 @@ - + - + workman.tech — {{title}} - - - - - - - - - - - - - + + + + + + + + + + + + + -
+

workman.tech 👋🏻 hello world

-
+

- <- Back + <- Back

- {{title}}
+ {{title}}

-

- {{theme_and_tags_line}} -

+

{{theme_and_tags_line}}

-
-{{content}} -
+
{{content}}
-