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.cssTwo 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 refusedRefused 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 opened | With external refs (499 bytes) | Self-contained (201,146 bytes) |
|---|---|---|
| Chat preview | Renders | Renders |
| Browser with network | Renders | Renders |
| Offline, on a plane | Blank | Renders |
| Corporate network blocking CDNs | Blank | Renders |
| Reopened months later | Depends on the CDN | Exactly 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=0Zero 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:
| Situation | What I do | Why |
|---|---|---|
| Glance at it once in chat, then move on | Leave it | The preview renders either way; inlining costs more than it saves |
| Still swapping libraries or tweaking options | Leave it | Re-inlining after every change is wasted motion |
| Sending it to someone | Inline | You do not know their network or their restrictions |
| Committing it or keeping it as a record | Inline | Reproducibility should not depend on a CDN's roadmap |
| Planning to review it offline | Inline | It 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.