Canon CSS: one right way to do each thing
Why LLMs write inconsistent UI, what happened when I gave one a framework with no alternatives, and then let agents refactor this entire site with it.
Ask a model to center a div with a utility framework and you get a different answer every time. There are hundreds of valid combinations, and a language model picks between them by statistical distribution. Ask five times, ship five slightly different interfaces.
Our frameworks have too many degrees of freedom. So I built one that has almost none, and this blog is written with it.
Semantic restriction
Canon is a pure-CSS framework with a closed vocabulary. Six spacing values. Seven text sizes. Seven layouts, twelve components, one canonical form per pattern. The API is data attributes that declare what something is, never how it looks:
<div data-layout="grid" data-cols="3" data-gap="lg">
<article data-component="card">
<div data-slot="header">Title</div>
<div data-slot="body">Content</div>
</article>
</div>No build step, zero JavaScript, one 4kb-gzipped file. If a value has no token, it does not exist. And the documentation ships as a ~750-token system prompt you inject into any model.
Does it actually work?
The claim is testable, so I tested it. Over three training iterations, clean-context LLMs, given only the prompt, generated 20 pages: dashboards, landings, a blog, an e-commerce catalog, and a complete 7-page product written by 7 agents that never saw each other's output.
- Zero rule violations across every generation, verified mechanically by
canon-lint. - Iteration 1 exposed 5 vocabulary gaps. Iteration 2, two cosmetic ones. Iteration 3, none.
- The 7 blind agents produced one coherent product: same identity on every page, dark mode exactly where it belonged.
The interesting failures came from ambiguity: metric numbers rendered tiny in one run and huge in another because there was no canonical way to write "a big number". The fix each time was to close the gap in the vocabulary, so the ugly output became hard to produce.
The real test: this site
The bigger test was this site: six LLM agents, working in parallel, ported every page and every component from Tailwind to Canon. Three languages, the forms, the SEO, with the illustrations left untouched.
The brand survived inside a theme file of roughly 60 lines: token overrides for the ink (#0a0a0a), the teal accent, the GeneralSans display face, the 17px body, a clamp() display scale and a wider reading measure, plus a three-rule @layer canon.theme block for pill buttons and display tracking. Net result: 770 fewer lines and zero Tailwind left in the codebase.
The port exposed gaps, and every one of them is now part of Canon:
- Animations had no expression, so now there is
data-motion={rise|float|pulse|lift}, auto-respectingprefers-reduced-motion. - The mobile burger menu became a zero-JS
<details>pattern in the topbar. - Monospace brand labels became
data-mono. - Responsive visibility became
data-hide. - Hardcoded reading widths became
--widthtokens a theme can override.
The loop was the same every time: the port hits a wall, the wall becomes a vocabulary proposal, the framework, the prompt, the skill and the linter update together, and the page gets regenerated.
Valid markup being possible is not enough. Ugly markup has to be difficult.
What Canon is not
It is not a Tailwind replacement for humans who want pixel-level control: when you care about every pixel of a branded design, utilities remain the right tool. But the strongest counterargument to "a closed vocabulary must look generic" is the site you are reading. This was a hand-crafted Tailwind portfolio, and LLM agents refactored all of it to Canon while keeping the brand. Canon is for the case where a model writes most of the UI and you want every page it produces to look like one product: internal tools, dashboards, MVPs, agent-generated apps. And, as it turns out, a personal site with a strong identity.
Try it
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/marcelodevelop/canonframework@main/dist/canon.css">Grab the system prompt from the repo, paste it into your model, ask for a page, and validate the result with npx canon-lint. The whole loop takes a minute: github.com/marcelodevelop/canonframework.