7 min read · Last updated
People and Agents Should Read the Same Hub
How I built a small publishing Hub in a week of tickets and chat sessions, and why the hard parts were law, motion, listing restraint, and agents — not the stack.
On this page
The same Hub, twice
I did not want a site people browse and a second surface machines scrape.
If a Post is public, it is public on a page — and public again as Markdown, a sitemap, RSS, and a file an agent can fetch without guessing. That second reading is the Agent Surface. Not an API bolted on afterwards. The same Hub, published twice.
That decision forced the rest:
- one kind of content: a Post
- one flat list, ordered by date
- publishing = committing an MDX file with valid frontmatter
- no CMS, no database, no account
The URL is the contract. This is the first real Post on the Hub. It is about how it was built, and which parts actually hurt.
What I refused
The tempting shape is familiar: a database of posts, an editor in the browser, drafts as rows, comments, a newsletter that pretends to be a product, auth because we might need it. I even asked whether side sections needed a database. They did not.
I am one author. I already have git. I already review diffs.
A CMS would have given me a nicer editor and a worse publishing story. The source of truth would have left the repo, and agents would have needed a sync step I do not want. The file in the repo is the Post. Invalid frontmatter fails the build and names the slug and the field. draft: true means production does not resolve the page. That is the whole editorial workflow.
Also out:
- comments under the Post
- a newsletter-as-product
- accounts — a door on writing I wanted findable
Smallness is not a phase. It is the design. The stack is ordinary on purpose: TypeScript, Next.js, MDX on disk, a semantic theme, Playwright against a production build, Vercel. None of that is the thesis. The thesis is that publication should be boring enough that writing is the expensive part.
The surfaces that fought back
DESIGN.md is law, not mood. Without it, tokens drift. A "nicer" colour appears beside the Zinc theme. Light stays the default in name. Dark becomes the afterthought. Contrast fails on a pair nobody checked because both themes were not actually rendered.
Light is default. Dark has to be equal in quality. WCAG AA is a floor, measured, not assumed.
Concrete failure: the mark in the header. In light, the coloured n sat on a light canvas and read as a signature. In dark, the same colour sat on a dark canvas and vanished. The bug was not "dark mode is hard". The bug was that I had designed one theme and tinted the other.
The hero
Longest fight. A generated field behind a portrait is one parameter away from looking machine-made. Sessions went into layout, coverage, grain, wave order, alignment, performance, accessibility.
What survived is dull on purpose:
- a small 2D canvas — no WebGL
- no pointer interaction
- colour from the same theme
- motion slow enough that a reader should not notice it
- no horizontal overflow at 320px — hard failure, not polish
- one composition that stacks; a second mobile hero had to go
If a library ignores prefers-reduced-motion, the Hub wraps it. Reduced motion means the lava field stops. It does not mean the portrait disappears.
The listing, the footer, the glyphs
The listing wanted to be a magazine: dates, times, extra chrome, hover tricks. I stripped the time off the repeated previews. A Cover and a title are enough. The rest belongs on the Post's own page.
The footer had its own argument: where the theme control lives, whether it sits next to the Agent Surface links, and what happens when you restore a control you had just moved.
Icons were a different class of mistake. Phosphor is the only family, and installing it was not free. Import the default package into a Server Component and it throws — those icons expect client context. The cheap fix is "use client" on the header. That fix is wrong: it turns a document shell into a client tree so a glyph can render. The correct import is the SSR entry. I had to stop an agent from applying the cheap fix, then write the rule down so the next session would not rediscover it.
shadcn, same pattern. These components are Base UI, not Radix. Composition uses render, not asChild. A contact control that opens mailto: is an anchor. Looking like a primary button does not make it a <button>. Hover that steals the pointer, a sticky header that fights the canvas, a Next.js image warning on the portrait: small, real defects. The durable fix was not a one-off. It was an instruction.
Two seams, and a formatter that lies
The failure mode of a test suite is not "too few tests". It is that everything migrates into the faster seam, and the seam a reader can actually observe withers.
Two seams. Not a pyramid.
Browser seam — primary. Runs against a production build, because draft exclusion and static generation only behave correctly there. It owns pages, listings, the Agent Surface, overflow at 320px, the hero with reduced motion, contrast in both themes.
Unit seam — three pure functions. Frontmatter validation, Cover assignment, contrast arithmetic. Nothing else. No component render tests. No route unit tests. No second copy of a behaviour the browser already asserts.
If a test is easier to write at the unit seam, that is the pull being described — not an exception.
CI taught a second lesson. Editor format-on-save does not fire for files an agent writes. A green local check is not proof the last commit is clean. Biome drifted between sessions until the rule was written down: run the write check before commit, and commit whatever it touches. The Agent Surface XML feed had its own formatting fight. Machines are as fussy about well-formed output as people are about type.
Agents need law, not vibes
Most of this Hub was built in conversation with agents: scaffold, tickets, implement the next issue, then the long tail of layout, motion, and naming. Useful when the task is specified. Expensive when it is not.
Context7 and MCP helped when the question was a library API. They did not help when the question was whether the Hub should exist in that shape at all.
The glossary is load-bearing. For a while the long-form piece was named like a magazine. Then it was a Post, everywhere: routes, fixtures, tests, instructions. Agents copy the words they see. One term in a file and another in the law, and you get a third term by Thursday. Machines do not share an unspoken culture. They share files.
An agent will solve the error in front of it:
"use client"silences the icon error- a
<button>silences "make it look like a button" - a custom colour silences "this contrast feels off" without touching the theme
- a second hero for mobile silences a layout bug and creates two sources of truth
Locally rational. Globally wrong. The only correction that survives the next session is writing the constraint where the agent will load it.
That is also why the Agent Surface exists. I am not asking crawlers to infer the Hub from a pile of client JavaScript. I am publishing the same Posts in forms a model can read.
I still read the diff. Law in the repo does not transfer accountability. If an agent implements a ticket, I own the merge. If this Post is wrong, I wrote it.
The hard parts were not choosing Next.js. They were refusing a bigger system, making the hero and the listing smaller than they wanted to be, keeping both themes honest, stopping tests and formatters from sliding, and writing the constraints down so a collaborator — human or not — could not "help" them away.