A funny thing happened to front-end work in the last two years: a lot of the time, the person typing the class names isn't a person. It's an assistant. You describe a pricing page, and something writes the markup. That changes what a good CSS framework has to be.
Most frameworks were designed for a human who reads docs, runs a build, and remembers conventions. An AI holds the keyboard differently. It half-remembers class names from training data and confidently invents ones that never existed. It can't run your Sass pipeline. And it has no way to know your framework got a new component last month. If you want AI-written UI to actually work, you have to design for those three failure modes directly.
That's what Farvist is designed around, including where it falls short.
1. Be findable
An assistant can only use what it can retrieve. So Farvist ships the machine-readable surface that's becoming a convention for AI-first sites:
llms.txt— a concise index at the root, the llmstxt.org convention.llms-full.txt— every convention, component, icon and copy-paste recipe in one paste-able file (about 35 KB).ai-context.json— the same catalog as structured JSON, generated from the compiled CSS so it can't drift from reality.farvist.cursorrules— the condensed house rules, to drop straight into a project so Cursor, Copilot or Claude generate correct markup with no setup.
robots.txt explicitly welcomes the AI crawlers — GPTBot, ChatGPT-User, OAI-SearchBot, ClaudeBot, anthropic-ai, Claude-Web, PerplexityBot, Perplexity-User, Google-Extended, Applebot-Extended and CCBot each get their own Allow: / — and every content page's <head> carries <link rel="alternate" type="text/plain" href="/llms.txt">. None of this is exotic. It's just making sure the answer to "what classes does Farvist have?" is one fetch away.
The interesting part isn't the file list. It's that two of those four files are generated and two are hand-written, and that split has turned out to matter more than any of them.
The catalog is compiled, not written
The obvious way to ship an AI catalog is to write one: a big markdown file listing your components. It works for about a week. Then you ship a component, forget the file, and the most authoritative-looking document about your framework starts lying to every model that fetches it. Doc rot is a nuisance for a human, who can go read the source. For a model with no other information, a stale catalog is indistinguishable from a correct one — it will confidently emit a class you deleted, or refuse to use one you shipped.
So scripts/gen-ai-context.mjs derives the catalog from dist/farvist.css — the exact bytes we publish. The class list is one pass over the compiled output:
const classes = [...new Set([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map((m) => m[1]))].sort();
Skins come from the compiled [data-theme] blocks, minus the built-in light theme, which predates skins and isn't one:
const skinNames = [...new Set([...css.matchAll(/\[data-theme=['"]?([\w-]+)['"]?\]/g)].map((m) => m[1]))]
.filter((n) => n !== 'light').sort();
Icon names come out of the sprite's id="i-*" attributes. Build sizes are gzipped at generation time from the real files, so no one ever hand-updates a KB figure:
const gzKb = (f) => (gzipSync(readFileSync(join(root, f))).length / 1024).toFixed(1);
Nothing in any of those lists is a number a human typed. As of v1.7.4 they report 2,022 class tokens, 55 icons, 5 skins, 56 design tokens, and 21.3 / 19.2 / 6.6 KB gzip across the three builds. Delete a skin and the catalog loses it in the same commit that deletes it.
Generation alone isn't enough, though — generated files still have to be regenerated. CI does that part:
- name: Build (sass + autoprefixer + ai-context)
run: |
rm -f dist/*.css
npm run build:all
- name: Committed build artifacts are fresh
run: |
if [ -n "$(git status --porcelain -- dist ai-context.json llms-full.txt)" ]; then
git status --porcelain -- dist ai-context.json llms-full.txt
echo "::error::dist/ or generated files are stale, orphaned, or uncommitted — run 'npm run build:all' and commit."
exit 1
fi
Two details there were bought with pain. Deleting dist/*.css before rebuilding means an orphaned output — a Sass entry renamed while the old compiled file stayed committed — shows up as a deletion instead of silently passing. And git status --porcelain rather than git diff, because git diff reports modified files but not deleted or untracked ones, so a generated file that was never committed at all sailed straight through the old gate.
The tokens were the hard part
Runtime theming is the entire customization story, which makes the token list the highest-value thing in the catalog and the easiest to get subtly wrong. The first version was three lines:
const rootBlock = (css.match(/:root\s*\{([^}]+)\}/) || [, ''])[1];
const tokens = {};
for (const t of rootBlock.matchAll(/--fv-([\w-]+):\s*([^;]+);/g)) tokens[`--fv-${t[1]}`] = t[2].trim();
An adversarial review of v1.6 found two problems with it.
First, that || [, ''] fallback. If the :root shape ever changed — a selector rename, a wrapping at-rule the regex doesn't survive — the match fails, rootBlock becomes an empty string, and the generator cheerfully writes a catalog with zero tokens and exits 0. Silent, and about the worst possible failure, since the token list is the whole override surface. It now refuses:
if (!Object.keys(tokens).length) {
throw new Error('gen-ai-context: token extraction found no --fv-* tokens in the first :root block — the selector shape probably changed; fix the regex.');
}
The second one is more interesting, because the file was wrong in a way that a human reading it would never notice. Not every token has a :root default. Some exist only as override hooks, consumed with a fallback baked into the call site:
.btn-primary { --fv-btn-color: var(--fv-primary-contrast, #ffffff); }
.badge-primary { color: var(--fv-primary-contrast, #ffffff); }
--fv-primary-contrast has no default in the base :root block — only the five skins declare it, so on a default-theme page nothing does. It's there so a brand whose primary is light can set readable text on filled surfaces. Because the extractor only reads that first :root block, the derived token list didn't contain it — while the catalog's own brand-theme recipe, a few hundred lines further down the same generated file, told assistants to set exactly that property. The document contradicted itself, and the machine-readable half was the wrong half.
The fix enumerates optional tokens from the var() call sites instead of from declarations:
for (const m of css.matchAll(/var\(--fv-([\w-]+)\s*[,)]/g)) {
const name = `--fv-${m[1]}`;
if (!(name in tokens) && !(name in optionalTokens)) {
optionalTokens[name] = /-contrast$/.test(name)
? 'unset (optional — readable text color on that color\'s fills; …)'
: /-text$/.test(name)
? 'unset (optional — readable text tint of that color on the page surface)'
: 'unset (optional — per-skin/per-context override; falls back to a compiled default)';
}
}
That surfaces 26 optional hooks alongside the 55 defaults: nine --fv-{color}-contrast, eight --fv-{color}-text, the eight --fv-btn-* properties components use to retint themselves, and --fv-status. Each ships with an explicit "unset (optional — …)" value rather than being absent, so a model reading the catalog can tell the difference between "this token does not exist" and "this token exists and has no default." That distinction is invisible to a person skimming and decisive for a machine.
Where generation stops helping
Three limits, none of which we've solved.
It was a regex, not a parser. The class list matched dot-prefixed tokens in raw text, so www.w3.org inside an inline SVG data URI yielded two “classes”, w3 and org. This post originally said we had left them rather than pretend a regex is a CSS parser. That aged badly: an audit for v1.7.4 pointed out that a catalog teaching assistants two class names that do not exist is not a rounding error, it is a wrong answer waiting to be copied. The extractor now scans selector text only — everything before a rule’s {, with declaration values excluded — and the count went 2,024 → 2,022. Still read it as the shape of the surface rather than a certified inventory: it folds in every responsive variant, so m-* alone is 108 entries.
The guard catches total failure, not partial. [^}]+ stops at the first closing brace. If a } ever appeared inside a token value, the block would truncate and we'd publish a shorter, wrong token list that passes the emptiness check without complaint. Nothing currently does. That's luck, not design.
Half the catalog is still hand-written. The 42 component entries with their examples, the 14 recipes, the conventions prose — a machine can see that .tool-call-status exists; it cannot see that you must keep the status text so state never rides on colour alone. That's judgment, and judgment rots on the normal schedule. Case in point, found while writing this piece: the compiled CSS has shipped a 0–8 spacing scale since v0.5.0, and the generated class list carries .m-8 (4rem) — while the hand-written conventions string sitting a few lines away in that same script still describes a 0–7 scale. Root llms.txt and farvist.cursorrules are hand-authored end to end, and llms.txt drifted far enough that it was still advertising "30+ components" with no mention of skins, runtime theming, the command palette or Farvist.stream() until we rewrote it in v1.6.1. The generated files could not have done that. That's the argument in one bug.
2. Speak a dialect models already know
There's a real design fork here. Utility-only frameworks are powerful, but they push all the composition into the markup, and an AI reconstructing a component from memory has more chances to get a detail wrong. Farvist uses the component-plus-utility model — closer to Bootstrap's — because "a card is .card" is a smaller, more stable thing for a model to remember than a specific stack of a dozen utilities.
Concretely, here is what .card compiles to:
.card {
display: flex;
flex-direction: column;
min-width: 0;
border-radius: 1.125rem;
overflow: hidden;
background-color: var(--fv-glass-bg);
backdrop-filter: blur(var(--fv-glass-blur)) saturate(var(--fv-glass-saturate));
-webkit-backdrop-filter: blur(var(--fv-glass-blur)) saturate(var(--fv-glass-saturate));
border: 1px solid var(--fv-glass-border);
box-shadow: var(--fv-shadow-lg), inset 0 1px 0 rgba(255, 255, 255, 0.1);
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
An assistant rebuilding that from utilities has to remember that saturation rides along with the blur, that the -webkit- duplicate is still required across our Safari baseline, that min-width: 0 is what stops the card from blowing out its grid track, and that the inset 0 1px 0 highlight is the line that makes glass read as glass rather than as a grey box. Eleven declarations, four of them non-obvious. One token to recall versus eleven chances to be almost right.
The honest counterweight: component classes only pay off if the model knows the component exists. That's the real cost of this fork. A model that has never heard of .timeline can still assemble something timeline-shaped out of utilities, whereas with us it either knows the name or falls back to hand-written CSS that won't retheme. Named components beat reconstruction only when the name list is one fetch away and correct — which is why the generated catalog isn't a nice extra sitting next to the class model. It's the thing that makes the class model the right choice at all. Component-plus-utility with a stale catalog is strictly worse than utilities.
On top of the components, the catalog ships 14 recipes: whole sections — navbar, hero, login card, pricing grid, chat UI, dashboard shell, ⌘K palette, code diff — as copy-paste blocks. They aren't tutorials; they're the exact markup, including the parts that are easy to drop under time pressure: aria-label on the icon-only send button, role="list" on the navbar's <ul>, matched <label for> pairs in the login card. A model assembling a page from named recipes inherits all of that.
Recipes are also where the traps get encoded. assistant-markdown-reply exists because message bubbles preserve raw newlines, so rendered markdown has to be wrapped in .prose or it comes out as one run-on block. code-diff keeps the + and − characters inside the line content rather than as a CSS pseudo-element, so the meaning of a diff never rides on colour alone. Those are the sort of rules that a model will violate every single time unless the example it's copying already gets them right. The names are the interface; the examples are the spec.
3. Don't require a build step
This is the one people underestimate. An AI can write CSS, but it can't run your Dart Sass compile. So anything that requires a build to customize is off-limits to a model working in a chat window or a live sandbox.
Farvist's answer is that everything themeable is a CSS custom property, and every derived shade resolves from those variables at runtime. Not "the buttons follow" — the whole derived layer follows, because each derived token is itself declared in terms of the brand tokens:
--fv-glow-primary: 0 0 26px color-mix(in srgb, var(--fv-primary) 55%, transparent);
--fv-focus-ring: 0 0 0 3px color-mix(in srgb, var(--fv-primary) 45%, transparent);
--fv-gradient-primary: linear-gradient(135deg, var(--fv-primary) 0%, var(--fv-info) 100%);
--fv-bg-gradient: radial-gradient(48rem 48rem at 82% -12%,
color-mix(in srgb, var(--fv-primary) 28%, transparent), transparent 60%), …;
So to re-brand the entire framework — gradients, glows, the frosted backdrops, hover states, focus rings, the body's mesh orbs — an assistant overrides four custom properties:
:root {
--fv-primary: #10b981;
--fv-accent: #a3e635;
--fv-info: #2dd4bf;
--fv-primary-text: #6ee7b7;
}
That property is only true if no component rule anywhere hard-codes a brand colour, and "no component rule anywhere" is not something you enforce by intention. scripts/check-theming.mjs runs in CI and walks the compiled CSS line by line: the seven brand hexes, and their compiled rgb() forms, may appear only on a line that defines a custom property.
const isVarDef = /^\s*--fv-[\w-]+:/.test(line);
if (lower.includes(hex) && !isVarDef) offenders.push(`${i + 1}: [${name} hex] …`);
One stray #6d4af5 in a :hover rule fails the build. Without a gate like that, runtime re-branding degrades one commit at a time, and nobody finds out until a user's green build renders a purple focus ring.
Prebuilt skins are the same idea packaged: data-theme="synthwave" and four others (cyber, noir, forest, and dawn, which is light). Because the derivation is already runtime, each skin is a small block of token overrides — seven for synthwave, sixteen for dawn, which has more surfaces to flip because it's light — and a second CI gate, check-skins.mjs, parses those blocks out of the compiled CSS and fails if any skin's readable-text tokens drop below 4.5:1 on that skin's own surfaces. It caught a 4.40:1 miss in dawn before release. A framework that can only be customized through a build step has quietly excluded the fastest-growing category of its users.
What this doesn't fix
None of this makes an AI infallible. A model can still choose an ugly layout, misuse a component, or write inaccessible markup. What these three things do is shrink the error surface — they remove the failures that come from stale knowledge, invented class names, and un-runnable tooling. The taste is still on you (or on how well you prompt).
Accessibility is the sharpest example. The recipes carry correct labels and roles, and our main pages are gated on axe via pa11y-ci — but that gate covers our HTML, not the HTML a model writes on your machine. The moment an assistant free-hands a component instead of copying a recipe, every aria-label is a coin flip. Shipping good examples raises the floor; it doesn't install a ceiling.
Retrieval isn't guaranteed either. llms.txt is a convention with no enforcement behind it: publishing one does not mean any given model reads it, and we have no telemetry claiming otherwise. The paths we're actually confident in are the ones with a human in them — a developer pasting llms-full.txt into a context window, or dropping farvist.cursorrules into a repo. The crawler-facing files are a bet on where the tooling is going, not a measured win.
And generation only guarantees that the catalog agrees with the build. If the build is wrong, the catalog is confidently, machine-readably wrong in exactly the same way. Consistency is not correctness; it just means you have one thing to fix instead of two.
It also isn't a moat in the "nobody can copy this" sense. Any framework could ship an llms.txt tomorrow, and generating a catalog from your own compiled output is maybe 120 lines of Node with no dependencies. The point isn't secrecy; it's that these are the right defaults for the moment we're in, and most frameworks were built for a different one.
The bet
Bootstrap won an era by being the thing every developer already knew, so reaching for it was the path of least resistance. The interesting question now is what becomes the path of least resistance when an assistant is reaching. Our bet is that it's the framework that's easiest to find, written in a dialect the model already speaks, and customizable without a toolchain. That's what we're building toward, in the open.
Farvist is free and MIT-licensed. Read the docs, or just point your assistant at farvist.com/llms-full.txt and ask it to build something.