For v1.7 we planned a "slim" build: Farvist minus the 11 AI-interface components. The AI kit was the newest and most elaborate code in the framework, so cutting it clearly had to be where the weight was. We measured before shipping the claim. We were wrong — and then, after switching compressors, wrong a second time in the opposite direction.
How to measure a layer, not a file
Farvist's Sass is one partial per component or utility group, and every partial compiles on its own. That makes per-partial cost a three-line experiment rather than an estimate:
printf "@use 'utilities/spacing';\n" > tmp.scss
npx sass tmp.scss:tmp.css --load-path=scss --style=compressed --no-source-map
node -p "require('zlib').gzipSync(require('fs').readFileSync('tmp.css')).length"
# → 4198
Run that over the 51 rule-emitting partials in the full build and sort by gzip. The top of the list:
partial min raw gzip ratio
utilities/spacing 34,153 B 4,198 B 8.1:1
utilities/utilities 15,370 B 2,996 B 5.1:1
components/buttons 12,485 B 1,601 B 7.8:1
base/root 3,573 B 1,069 B 3.3:1
utilities/backgrounds 5,669 B 1,007 B 5.6:1
layout/grid 6,797 B 996 B 6.8:1
components/chat 2,490 B 957 B 2.6:1
…
components/rating 201 B 163 B 1.2:1
One partial of spacing utilities outweighs every single component, by a wide margin. All 11 AI components compiled together come to 14.4 KB minified, 2.98 KB gzip — less than three quarters of what utilities/spacing costs on its own.
The arithmetic nobody does in their head
This isn't mysterious once you write out the loop nest. scss/utilities/_spacing.scss is four @each loops deep:
@each $bp in map.keys($grid-breakpoints) { // 6 breakpoints
@include media-up($bp) {
@each $prop, $abbrev in (margin: 'm', padding: 'p') { // 2
@each $size, $length in $spacers { // 9
@each $side, $targets in $spacing-sides { // 7
.#{$abbrev}#{$side}#{$infix}-#{$size} { … }
6 × 2 × 9 × 7 = 756 rules. The same file then adds negative margins (8 non-zero steps × 6 selectors × 6 breakpoints = 288) and auto margins (6 × 6 = 36). Total: 1,080. Counting the class selectors in the compiled output gives exactly 1,080, which is a good sanity check that no @each is quietly doing more than you think.
Now compile components/_cards.scss. It emits 11 rules: .card, .card-solid, .card-glow:hover, .card-body, .card-title, .card-subtitle, .card-text, .card-text:last-child, .card-header, .card-footer, .card-img-top. That's the whole component.
Across the shipped build, the four utility partials produce 1,538 of the 2,339 style rules in dist/farvist.css — 66% of the framework's rules. All 11 AI components produce 142, about 6%. A component is written once and costs once. A utility is written once and costs breakpoints × properties × scale × sides. In a token-driven framework the loop nests, not the components, are where the gzip budget goes.
Standalone numbers lie
Here's the trap in the table above. Sum all 51 standalone gzip measurements and you get 33.59 KB. The real build is 21.26 KB. The parts add up to 58% more than the whole, because gzip finds redundancy across partials, not just inside them. One identical glass declaration — backdrop-filter: blur(var(--fv-glass-blur)) saturate(var(--fv-glass-saturate)) — appears 20 times in dist/farvist.css; measured one partial at a time, every partial pays for its own copy.
So standalone cost answers "how big is this on its own", not "what would removing it save". For the second question you need the marginal number: rebuild the framework with one @use line deleted and diff. Every entry below is a real compile of scss/farvist.scss with lines removed:
full build 21.26 KB gzip —
minus utilities/spacing 16.27 KB −4.99 KB
minus all 4 utility partials 12.40 KB −8.86 KB
minus 11 AI components 19.15 KB −2.11 KB
minus components/buttons 20.03 KB −1.23 KB
minus layout/grid 20.26 KB −1.00 KB
minus base/skins 20.95 KB −0.31 KB
One spacing partial is 23% of the framework's compressed weight. The utility layer as a whole is 42%. The entire AI kit — the thing we were about to cut — is 10%. Five prebuilt skins, which look like the most extravagant feature on the page, cost 0.31 KB between them, because each skin is just a block of 7–16 --fv-* token overrides.
Then we ran it again with brotli
Everything above is gzip, which is what our CI gate measures. Whether that's the number that matters depends on what your CDN and browser negotiate, so we repeated the leave-one-out at brotli quality 11. The conclusion inverted:
gzip brotli
full build 21.26 KB 14.32 KB
minus utilities/spacing 16.27 KB 12.99 KB (−1.34 brotli)
minus 11 AI components 19.15 KB 12.73 KB (−1.59 brotli)
Under gzip, the spacing generator costs 2.4× what the whole AI kit costs. Under brotli, cutting the entire responsive spacing system saves less than cutting the 11 AI components. Brotli's larger window and static dictionary compress 1,080 near-identical rules far harder than they compress 142 hand-written ones that share much less text.
Which means the tidy headline — "generated utilities dominate the byte budget" — is a gzip-era statement. It's true of the artifact our CI measures and can be false of the bytes that reach the browser. The shipped dist/farvist.min.css compresses 6.6:1 under gzip (139.5 KB raw → 21.3 KB) and 9.6:1 under brotli (→ 14.5 KB). If you're auditing your own framework and your CDN negotiates brotli, your utility layer is cheaper than the gzip number suggests and your components are a bigger share than you think.
What we shipped, and what we didn't claim
We shipped the slim build anyway: dist/farvist-slim.min.css, 19.1 KB gzip against the full build's 21.3 — the same 2.11 KB the leave-one-out predicted, and 1.8 KB under brotli. It's small, and the docs and CHANGELOG say so in plain words — v1.7.0's release notes read "the slim saving is honest but modest (~2 KB): the AI kit is efficient — most of Farvist's weight is the utility system."
It stayed in because it's cheap to maintain (one extra Sass entry, one extra CI budget line) and because bytes aren't the only reason to drop code — a site that never renders a conversation also drops 142 rules it will never match, and farvist.js probes for the missing snippet CSS on load and skips injecting copy buttons entirely. But we are not going to sell 2 KB as a performance story.
The cut worth making turned out to be the mirror image. If the AI kit is only 2.11 KB of marginal weight inside the framework, it's small enough to stand alone: dist/farvist-ai.min.css, 6.4 KB gzip (5.5 KB brotli), for a site already running Tailwind or Bootstrap that wants a finished chat UI without adopting a second framework. It carries the 11 components plus the tokens, skins, buttons, avatars, icons and toasts they depend on, and deliberately no reset, grid or utilities — the host page keeps its own.
Note the asymmetry, because it's the same lesson: 6.4 KB standalone against 2.11 KB marginal. The add-on is three times its own marginal cost because it re-ships supporting layers the full framework already had. If you're already on Farvist, loading it would be strictly worse than using the full build.
Why the budget is committed to the repo
None of this survives contact with future commits unless something enforces it. scripts/check-size.mjs is 27 lines, and the important part is a literal:
const BUDGETS = [
{ file: 'dist/farvist.min.css', kb: 22 },
{ file: 'dist/farvist-slim.min.css', kb: 20 },
{ file: 'dist/farvist-ai.min.css', kb: 7 },
{ file: 'dist/farvist-ai-compat.css', kb: 2 },
];
It gzips each file, prints the number, and exits 1 if any build is over. CI runs it on every push to main and every pull request, as the "Bundle-size budget" step. Today it prints:
dist/farvist.min.css — 139.5 KB raw · 21.3 KB gzip (budget 22 KB gzip)
dist/farvist-slim.min.css — 125.1 KB raw · 19.1 KB gzip (budget 20 KB gzip)
dist/farvist-ai.min.css — 37.5 KB raw · 6.4 KB gzip (budget 7 KB gzip)
dist/farvist-ai-compat.css — 3.7 KB raw · 0.9 KB gzip (budget 2 KB gzip)
✔ all builds within budget
The budgets sit 0.6–1.1 KB above current size on purpose. That's enough headroom for a normal component but not for a new loop nest. The point of writing the number down is that you choose it while calm; when a PR blows past it, raising the ceiling becomes a visible one-line diff someone has to approve, rather than a slow drift nobody notices until a rewrite. A budget nobody committed is a preference, and preferences lose to deadlines.
Two honest limits. The gate measures gzip of a file on disk — not brotli, not what your CDN negotiates, and not what the browser does with it. And byte count says nothing about the cost of 2,339 style rules in the style engine; we haven't measured that, so we don't claim anything about it.
If you're measuring your own
Three things we'd pass on. Measure compressed, never raw — raw size ranks a generated block far above a hand-written one, and compression is exactly what closes that gap. Measure marginal, not standalone: our standalone costs summed to 58% more than the real build, which makes every layer look worse than it is. And measure with the algorithm your CDN actually serves, because ours changed the answer from "cut the utilities" to "the AI kit costs slightly more".
Mostly, though: be suspicious of your assumption about which part is heavy. Ours was wrong twice, and the experiment that caught it was a few dozen lines of Node. We were one commit away from justifying a build with a guess.
Farvist is free and MIT-licensed. The size gate is scripts/check-size.mjs; the three builds are documented in the installation section of the docs.