marceloacevedo
let's talk
Launch

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.

MAMarcelo Acevedo · August 2026

Ask a model to center a div with a utility framework and you get a different answer every time. Not a wrong answer: a different one. There are hundreds of valid combinations, and a language model picks between them by statistical distribution, not by reasoning. Ask five times, ship five slightly different interfaces.

The problem is not technical. It is epistemological: 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, because docs a model reads are docs that get followed.

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 were never rule-breaking. They were 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 was never better prompting. It was closing the gap in the vocabulary, so the ugly output became hard to produce.

The real test: this site

Benchmarks are one thing. Betting your own brand is another, so that is what I did: six LLM agents, working in parallel, ported every page and every component of this site 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.

What broke was the interesting part, because everything that broke became vocabulary. 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-respecting prefers-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 --width tokens 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. That is the whole doctrine in one motion:

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.