ANTIGRAVITY LABJP
Articles/Editor View
Editor View/2026-08-29Beginner

Charts from Generative UI break the moment you leave the CDN links in

Antigravity 2.11.0 renders HTML artifacts inline, and most of them arrive with CDN references. Here is a 20-line checker that counts external refs, plus the inlining step, with measured numbers.

Generative UIChart.jsHTMLAntigravity 2.11

Antigravity 2.11.0, released on August 26, renders HTML artifacts directly inside the chat. KaTeX math, Chart.js bar charts, Plotly figures — they all come to life in the preview pane instead of sitting there as a wall of markup.

The first thing I asked for was small: a bar chart of how many assets sit in each category of a wallpaper app I maintain. When you are sorting thousands of images into buckets, you periodically want to see whether nature has quietly grown to twice the size of abstract. A chart answers that in a glance; a table makes you read.

The file that came back was 499 bytes.

The code that actually draws the chart was not in it.

The whole thing fits in 499 bytes, and that is the problem

Here is the file, exactly as I used it:

<!doctype html>
<meta charset="utf-8">
<title>Category counts</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<canvas id="c" width="640" height="320"></canvas>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.js"></script>
<script>
new Chart(document.getElementById('c'), {
  type: 'bar',
  data: {
    labels: ['nature', 'city', 'abstract', 'animal'],
    datasets: [{ label: 'assets', data: [1840, 1220, 980, 640] }]
  }
});
</script>

One canvas, one call into Chart.js, and you get a perfectly presentable bar chart.

But the file is 499 bytes because the drawing code lives somewhere else. A <script src="..."> line is a request — please go fetch roughly 200 KB of library from this host — not the library itself.

Inside the chat preview, that distinction never surfaces. It surfaces the moment the file leaves your machine.

Counting external references takes about twenty lines

Skimming for https:// by eye stops being reliable as soon as the file grows. References hide inside @import rules and CSS url() values. So I keep a small script around that does nothing but count them.

// selfcheck.mjs — count external references in an HTML file
import { readFileSync } from 'node:fs';
 
const html = readFileSync(process.argv[2], 'utf8');
const pats = [
  /<script[^>]+src=["']([^"']+)["']/gi,
  /<link[^>]+href=["']([^"']+)["']/gi,
  /<img[^>]+src=["']([^"']+)["']/gi,
  /@import\s+(?:url\()?["']([^"']+)["']/gi,
  /url\(\s*["']?(https?:\/\/[^"')]+)["']?\s*\)/gi,
];
 
const refs = new Set();
for (const p of pats) for (const m of html.matchAll(p)) refs.add(m[1]);
const remote = [...refs].filter((u) => /^(https?:)?\/\//.test(u));
 
console.log(`file=${process.argv[2]} bytes=${Buffer.byteLength(html)} refs=${refs.size} remote=${remote.length}`);
for (const u of remote) console.log('  REMOTE ' + u);
 
process.exit(remote.length ? 1 : 0);

Run it against the file above and you get:

$ node selfcheck.mjs cdn.html
file=cdn.html bytes=499 refs=2 remote=2
  REMOTE https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.js
  REMOTE https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css

Two references, exit code 1.

The exit code is the point. If you have any step that saves generated HTML as an artifact, putting this in front of it stops a file that cannot travel from being saved in the first place. The habit of verifying agent output locally is the same one I described in Building a verification loop with the embedded terminal in Antigravity 2.10.0 — this checker is a one-line addition to that loop.

It is regex-based, so it will not catch a script tag assembled at runtime through document.createElement('script'). For the kind of HTML I ask for, that has never come up, and I would rather have twenty readable lines than a parser.

When the reference cannot be fetched, only the canvas survives

I checked what an unreachable host actually costs by pointing the hostname at a closed port:

$ curl -sS -o /dev/null --connect-timeout 5 \
    --resolve cdn.jsdelivr.net:443:127.0.0.1 \
    https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.js
curl: (7) Failed to connect to cdn.jsdelivr.net port 443 after 0 ms: Connection refused

Refused in 0.0001 seconds. There is not even a timeout to wait through.

In a browser, the same failure means the <script> never loads and execution moves on. new Chart(...) dies with Chart is not defined, and the <canvas> stays exactly what it was declared as: 640 by 320 pixels of nothing.

What makes this awkward is that the page is not broken. The title renders, the layout holds, and the document validates. All the recipient sees is a blank rectangle where a chart should be, with almost nothing on screen to suggest why.

There is no error banner, because from the browser's point of view nothing exceptional happened: a resource was unavailable, and the script that depended on it did not run. Unless the person you sent it to thinks to open the developer console, the failure is silent. In my experience people do not open the console for a chart someone sent them — they assume the chart is simply empty, and reply asking whether the data was missing.

Where it is openedWith external refs (499 bytes)Self-contained (201,146 bytes)
Chat previewRendersRenders
Browser with networkRendersRenders
Offline, on a planeBlankRenders
Corporate network blocking CDNsBlankRenders
Reopened months laterDepends on the CDNExactly as saved

That last row is the one I care about most. The reason to keep a chart at all is to look at it again later. If it is blank in six months, keeping it accomplished nothing.

Working as an indie developer, the asset mix in my apps shifts slowly across years rather than weeks. Some imbalances only become visible when you put a chart from six months ago next to today's, so losing the older one is a real cost rather than a hypothetical.

Inlining is a single replace

All you are doing is swapping the <script src> line for the library's contents.

// inline.mjs — replace the CDN reference with the file itself
import { readFileSync, writeFileSync } from 'node:fs';
 
let html = readFileSync('cdn.html', 'utf8');
const js = readFileSync('chart.umd.min.js', 'utf8');
 
html = html.replace(
  /<script src="https:\/\/cdn\.jsdelivr\.net[^"]*"><\/script>/,
  '<script>' + js + '</script>'
);
html = html.replace(/<link rel="stylesheet" href="https:\/\/cdn\.jsdelivr\.net[^"]*">\n/, '');
 
writeFileSync('inline.html', html);

There is no math in this chart, so the KaTeX stylesheet gets dropped rather than inlined. Keeping only what you use is what keeps the result reasonable.

Running the checker again:

$ node selfcheck.mjs inline.html
file=inline.html bytes=201146 refs=0 remote=0

Zero external references, exit code 0. The file went from 499 bytes to 201,146 bytes, and nearly all of that is the minified Chart.js UMD bundle at 200,807 bytes.

Speed, for the record, is not the reason to do this. I timed three fetches of that bundle from the CDN: 0.107 s, 0.117 s, and 0.110 s. On any decent connection, that round trip is invisible.

The reason is portability. A 200 KB file opens from an email attachment, from a repository, from a USB stick, with nothing else required. Nothing about it can rot: the version of the library is pinned by virtue of being physically present, so a breaking change in a future release cannot reach backwards and alter what you saved.

That also means the inlined file is a snapshot of a specific library version, which is worth remembering if you keep dozens of them around. When Chart.js ships a version you want, you inline the new one going forward and leave the old files alone. I find that preferable to the alternative, where every saved chart quietly follows whatever the CDN currently serves.

Sometimes leaving the references in is the right call

I do not inline everything. The split I use looks like this:

SituationWhat I doWhy
Glance at it once in chat, then move onLeave itThe preview renders either way; inlining costs more than it saves
Still swapping libraries or tweaking optionsLeave itRe-inlining after every change is wasted motion
Sending it to someoneInlineYou do not know their network or their restrictions
Committing it or keeping it as a recordInlineReproducibility should not depend on a CDN's roadmap
Planning to review it offlineInlineIt opens with no network at all

The dividing line is simply whether the file is disposable. If it is, 499 bytes is fine and you throw it away. If it is not, fold it into one piece.

Since Generative UI landed, I ask for a quick chart far more often than I used to — and because it is easy, a few of those charts turn out to be worth keeping. Only those get the checker run on them. That has been enough.

Pick the last HTML file you had generated and run node selfcheck.mjs against it. Either it comes back remote=0, or you get a list. Either way, you will know immediately whether it needs folding.

Thank you for reading — I hope the checker earns its twenty lines on your machine as well.

Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $15 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Editor View2026-08-27
Building a Verification Loop With Antigravity 2.10.0's Embedded Terminal
Antigravity 2.10.0 puts a terminal in the sidebar. Here is how I pinned my pre-merge checks down to three commands, scoped them to changed files only, and what the measured difference turned out to be.
Editor View2026-08-27
Three Byte-Level Checks I Run Before an Agent Edits Files That Contain Japanese
When an agent edit swaps a single multi-byte character, git shows it as an ordinary one-line change. Here is how I fold invalid UTF-8, replacement characters, and normalization drift into one pass.
Editor View2026-08-23
A Triage Order for WebP and Opus Attachments That Never Reach the Agent
Hub 2.9.1 added WebP attachments on August 20, and CLI 1.1.17 fixed Ogg-family audio being rejected by the model on the same day. Here is why rejection happens on the sending side rather than in the file, with the MIME results I actually measured.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →