Robot Packages Blog ~ Extracting a Design System out of the Blog
Part 1 got the posts out of WordPress. Part 2 got the build onto Cloudflare Pages. Both of those ended with a blog that lives in a git repo and a theme that lives in that same repo, tangled up with the posts, the mascot, and every decision I made while recreating a dead WordPress look from screenshots.
This post is what happened next (because I have a problem just taking a win and finishing a project). I extracted the theme out of the blog and it became three npm packages under @half-built. The blog now installs its own look from the registry at an exact version, the same way it would install anything else.
Why bother, for a one-person blog? I had already planned on creating subsites out of the half-built ecosystem, and I did not want to rebuild K sites every time the theme changed. Also, I am maybe neurotic. I want a neat package with a bow on it. It just feels more well engineered than trusting that each site’s copy of the CSS won’t drift.
A second site, ui.half-built-robots.com, renders every component on one page so I can see what the packages do without the blog’s content in the way. The hero image up top is that site, screenshotted on the day I wrote this.
Same disclaimer as before. Claude did most of the mechanical work and a good share of the reading. I made the calls and checked the screenshots. The recipe is at the end, written so you can hand it to your own agent.
What a design system is, for a blog
For a one-person blog a design system is a small thing (with a lack of hipsters sipping lattes unless thats your thing). It is the list of colors and spacings the site is allowed to use (the tokens), the handful of visual recipes that keep showing up (a pressable box, a caption, a small label with a border), the components that render the chrome and the content, the little scripts that make a lightbox or a theme toggle work, and the linting that stops me from breaking my own rules. That is it. Every site I build needs the same pile. Until this project it only existed inside this blog.
The packages split along those lines. @half-built/css holds the tokens, the base styles, the patterns, and the prose styling. @half-built/astro holds the components, the content components you see in posts (callouts, galleries, image plates, code blocks), the island scripts, and a few pure helpers like pagination and reading time. @half-built/tooling holds the ESLint, Stylelint, and html-validate presets plus the browser test kit. The three packages share one version number and release together.
Read the whole thing first
The first thing that happened was a review, never code. I had Claude read the blog’s source in four passes (the CSS, the components and layouts, the client scripts, and the plumbing that isn’t UI) with one question for every file. If another site installed this, what would break?
More than I expected, was the answer. The tokens were a flat bag of variables where --primary-color was the text color and every border color at once, because the old theme was monochrome and Claude had measured it faithfully. The visual recipes had never been written down anywhere, so the same two-pixel border was typed out by hand in 23 places and the image-plus-lightbox markup existed six separate times. The components accepted whatever props they happened to accept. Site settings were scattered through the components that read them. And every island script (the little bits of JavaScript behind the lightbox, the theme toggle, and so on) mounted itself its own way, so nothing could tell whether a script had already run.
None of that was a bug. The site rendered fine and had been doing so for weeks. It was just the shape you get when you rebuild a theme from measurements under an imaginary deadline (I need to chill on my hobbies), with looking right as the only bar. Fine in one place but not sustainable to develop.
The review is the design document for the packages. Every step that followed points back at a finding in it, which turned out to be handy whenever a fresh session asked why.
Refactor in place, cut last
Copying the good parts into a fresh repo and fixing them there would have been the quick way. It would also have left me with two half-right copies of the theme for as long as the fixing took, and no way to prove the new one still rendered this site. So the packages did not get built. The blog got refactored into them.
Every step of that had a dated plan, a scope, and an exit gate. The review proposed eleven steps. I ended up with sixteen plans (a defects pass went in front, and a second review added a half-step in the middle). Tokens came first, then the patterns, the components and their API, the islands, and last the WordPress vocabulary. entry-content became prose and the old theme’s class prefixes finally went away. The full list is in the recipe at the end.
The exit gate was the same every time. Claude ran a clean production build before and after the step, diffed the HTML through a normalizer that strips the hashes, and took 28 full-page screenshots that had to come back byte-identical. The screenshots always did, right up to the vocabulary change. The HTML was less tidy. A few steps collapsed whitespace or moved inline scripts into their own files. Each time the diff got read and ruled harmless, and the normalizer learned to ignore that kind of change. So the gate was never really an empty diff. Every line in the diff had to be explained before the step could close.
The amber I should not have borrowed
The accessibility suite in CI found the first problem. The tagline was drawn in the accent amber on a white background, at a contrast ratio of 1.9 to 1, and the gate wants 4.5. That was a whole thing. Claude’s first move was to grab a darker amber from another project I am cooking up that shares the accent, without thinking about it, and that one measured 4.24 to 1. The darker ambers it kept trying after that passed the number and just did not read as dynamic enough. What we settled on was a callout box behind the tagline. The box gets the contrast right in the light theme and the tagline still feels like itself. I also wrote a rule into the package repo’s instructions file. The other project is a prototype and not a source of values, and the design rules come from the blog.
The second problem was a lighter amber, the one that draws the first data track in the path player when the light theme is on. It is not text, so the bar is 3 to 1 instead of 4.5. The script that derives the palette checks that bar, but the default value shipped in the package had never been checked against it and did not pass. Claude retuned it in OKLCH with the hue and chroma held, and it went out in 0.2.0. That was the only visible change in 0.2.0. The breaking change was the rename.
The rename was for other sites. The accent variables are named --brand-1 and --brand-2 now instead of amber and cyan, so a site that overrides them with its own colors is not stuck with a variable called amber that holds blue. Those six variables are the whole theming surface (which I make dynamic in the reference site).
Henry stays home
Henry is the robot in the corner of this site. He is mine, he is not open source, and he is not going into the packages. No artwork, no animation engine, not even a file with his name on it. The packages get generic icons instead (Lucide, which is permissively licensed), and a site that wants Henry vendors him from his own repo the same way this one does.
That is what let me put a plain MIT license on the packages with no carve-out for the mascot. It did cost some work. Henry’s animation engine shared two bits of code with things that were going into the package, the localStorage handling with the stasis easter egg and the frame loop with the fluid path player. Both bits became plain modules in the package with nothing of Henry in them. Henry kept his own repo and his own license.
The swap
Moving the code out was the last step. Claude scaffolded the new repo, moved the files over, and wrote a script that checked every moved file against the original. 72 came through byte-identical, 25 needed small edits (import paths, mostly), and none went missing. The packages got their own tests and their own lint run, and a dry-run of the publish confirmed the license file lands in every tarball.
Then the blog switched over. Its own copies of the theme got deleted, the posts got their imports pointed at the package, and package.json got an exact version pin (no caret, the blog upgrades when I say so). The parity check ran one more time and passed. Then I deleted node_modules and installed from the public registry with nothing local to fall back on, which is the test that proves the packages are real.
There is one rule for living with the packages now. A bug in a package gets fixed in the package, released as a patch, and the blog bumps its pin. The blog never edits anything under node_modules and never copies a package file back into the site to tweak it. The rule runs the other direction as well. Within days of the swap, a component that had only ever lived in the blog (the editor’s note that holds the fill-in questions on my drafts) turned out to be useful anywhere, so it moved into the package with a prop for the part that used to be a blog setting, and the blog kept a thin wrapper around it.
Tags are releases
I ran the first publish by hand from my laptop, because npm’s trusted publishing (the thing that lets GitHub Actions publish without a token) is a setting on a package that already exists. After that every release has been the same three moves. Merge to main, tag it, push the tag. A workflow runs the tests and the demo site build, then publishes all three packages with no token anywhere and a provenance stamp on each one. It has failed exactly once, when 0.2.0 refused to publish because the manifests did not name their own repo, which is the kind of thing you only learn by having it fail. A small test now refuses any package change that does not come with a new version number, so a tag can never try to republish a taken one. I bump the version numbers by hand.
Two traps
Two problems cost me time that I want to write down so they do not cost you any.
The first is that the parity check only means something on a production build. The test suite builds the site in dev mode as part of its setup, and a dev build adds debugging attributes and shuffles style blocks around from one run to the next. I diffed two of those once, got a pile of differences, and spent a while hunting a regression that did not exist. Compare production builds. If a difference only shows up in the test build, it is not real.
The second is that a GitHub runner has no mouse. The browser tests run headless Chrome there, and Chrome reports that the device cannot hover, so anything behind a hover media query declines to mount, and a test that passes on my laptop fails in CI. I assumed I could tell Chrome to pretend it has a mouse through its debugging protocol. That setting is silently ignored. What works is a Blink launch flag that declares the pointer type up front, and it lives in the test kit’s Chrome launcher now with a long comment, along with the inverse flags so I can reproduce the CI failure on my desk when I need to.
One footer, five sites
There is a smaller project hiding inside this one. By the time the reference site went live there were five sites in the half-built ecosystem, and the ones with footers each listed a different subset of the others. The reference site’s footer linked the same GitHub repo twice. It was the K sites problem from the top of this post in miniature, and the design spec has me on record. “I don’t want to have to build K sites each time I add a new thing to the ecosystem.”
So there is now a JSON document at ecosystem.half-built-robots.com listing every property, served from its own tiny Pages project with no build step. A script in the package reads it and fills the footer’s Ecosystem group when the page loads. The footer still ships a static list baked in at build time, and every failure path (no JavaScript, dead endpoint, malformed document) preserves the prebaked list. Adding a new site in the ecosystem is easy. I edit one JSON file, filling in a few attributes, commit, and push. Pages then builds and serves the file where the blog and various subsites pick it up within a few minutes with no rebuild. The next site to adopt the packages gets the list for free. And you, dear reader, can pull the file down if you so choose as well (I don’t pretend to know why you would).
The recipe, written for your robot
Here is the process, written for your agent rather than for you. The first thing it should hand back is a review of your site, not a repo, and the order of the steps below is most of the recipe.
Step zero. Review, then ask
- Read the whole source in passes by concern. Styles, components and layouts, client scripts, and the plumbing that is not UI. For every file, answer one question. What would break if another site installed this?
- Count the duplication. How many times is the same border typed out, the same markup written, the same recipe copied? Those counts are the blockers list, and they are also the before numbers for the writeup.
- Find every place the styling reads the site’s content, config, or voice. Each one is either a prop with a default or a reason the piece stays behind.
- Ask the owner what stays home. For me it was the mascot, the site’s own scripts, and anything that only makes sense with this blog’s posts in it. The answer decides the license.
- Ask the owner about names. Package scope, repo name, the reference site’s hostname, and whether hue-specific token names survive (mine did not).
- Ask about the consumer rule. Exact pins or ranges, who bumps them, and whether patching
node_modulesis ever allowed. Write the answer down before the first publish, because the first patch release will test it.
The ladder every step climbs
Every refactor step, whatever it contains, runs the same loop.
- A dated plan with a scope, the review findings it closes, and an exit gate.
- A clean production build and full-page screenshots before any code changes. Save them.
- The refactor, with tests, in small commits.
- A clean production build after. Normalize both outputs (strip hashes, sort attributes, blank the style bodies) and diff them. Compare the screenshots byte for byte.
- An empty diff after normalizing is the exit gate. A non-empty diff is either a bug or a decision. The owner makes the decision, and if the ruling is that a new class of change is inert, the normalizer learns it.
- Write back what the step learned into the rules file before closing it.
The refactors, in order
- Tokens first. Split the flat variables into primitives and roles, and prove the roles work by adding a second theme.
- Patterns. Every recipe that is typed out more than twice becomes a class.
- The stylesheet. Break the one big file into a base layer and feature files.
- Component API. Typed props, class pass-through, heading levels.
- Primitives. Collapse the copied markup into the smallest set of components that can render it.
- View models. Chrome components stop importing the site’s content types.
- Config. Every site setting into one file, then every chrome component onto typed props instead of reading that file.
- Layout primitives. The page shell and the column layout as components.
- Islands. One mount contract, idempotent, with a destroy.
- The split of anything shared between the package and what stays home.
- Retire the old vocabulary. This is the first step where the output changes on purpose, so its gate is a rename-aware diff and an owner review, not an empty one.
The cut
- Scaffold the new repo with the packages, the reference site, and the license files, and prove it builds using only package specifiers.
- Move the files with a script that compares every destination against its source and reports identical, adapted, and missing. Zero missing is the gate.
- Give the packages their own suites and lint them with their own preset.
- Dry-run the pack and confirm the license lands in every tarball.
Release hygiene before the first publish
Two of these I learned after a release failed. Do them first anyway.
- Protect the production branch. A committed pre-push hook is fine if server-side protection is not available.
- Write the release workflow to fire on version tags, with identity permissions and no tokens. Put the
repositoryfield in every manifest so provenance can verify. - Publish the first version by hand, then turn on trusted publishing for each package.
- Add the bump guard. Any package changed since the newest tag must carry a new version.
- Decide against version bots, or for them, on purpose.
The swap and after
- Delete the site’s copies, rewrite the imports, pin the exact version.
- Run the parity gate one more time, then a clean install from the registry with nothing local to fall back on.
- Write the standing rule into the site’s rules file. Fix in the package, publish, bump the pin.
- Put the reference site on its own hostname with its own sitemap. Subdomains are separate hosts to a crawler.
- If more than two sites share a footer, give the family list its own endpoint and let a script fill the footer, with the static list as the fallback.
So who did what?
Claude did the review, every refactor step, the move, the release workflow, the parity tooling, and the reference site. It also wrote the accessibility suite that caught the tagline and then did the contrast math on the amber it had borrowed without thinking. My part was the decisions. Three packages, Henry stays out, the token names go neutral, the prototype is not a source of values, exact pins, no bots. I looked at every screenshot pair, ran the first publish from my own laptop because the registry wanted a human for that one, and I have tagged every release since.
The packages are on npm under @half-built, the source is at curthenrichs/half-built-ui under MIT, and every component is rendered at ui.half-built-robots.com. If you install them and something reads as blog-specific, that is a finding the review missed, and I would like to hear about it. Questions, suggestions, and comments are welcome on Bluesky or X.
Thanks for reading. Stay tuned and keep building.