A technical deep-dive into Cascade: a two-model arena where the benchmark is a constraint — pure HTML+CSS animation rendered with JavaScript switched off at the browser level.
1. The problem with vibes-based model comparison
When a new model drops, the announcement ritual is familiar: a bigger benchmark number, a cherry-picked transcript, a quote-tweet saying "it just feels smarter." None of that survives contact with a skeptic. Benchmarks leak into training data, transcripts are curated, and "feels smarter" isn't falsifiable.
I wanted a comparison format with a specific property: the viewer can see the result is real without trusting me. Not a score — a demonstration, recorded on video, where cheating is structurally impossible rather than merely disallowed.
That led to Cascade: send both models the same prompt — "Build an animated [X] using ONLY HTML and CSS…" — render both replies side by side, and disable JavaScript in the renderer. A model that reaches for setInterval produces a frozen frame next to a dancing sibling. The constraint does the judging; I just point a screen recorder at it.
2. The core insight: the medium is the test
CSS animations (@keyframes, transitions) execute in the browser's style and compositor pipeline — no script engine involved. JavaScript-driven animation needs allow-scripts. So an <iframe sandbox=""> with the empty sandbox attribute is a perfect discriminator:
- Pure-CSS reply → moves. ✅
- JS-dependent reply → frozen on frame zero. ❌ — visibly, undeniably.
No custom harness, no headless browser instrumentation. The browser vendors already built the enforcement mechanism; Cascade just frames it (literally) and adds a "JavaScript: DISABLED" banner so the viewer understands what they're seeing.
// frontend/src/App.tsx — the entire trust model in one line
<iframe className="preview" title="model-a" sandbox="" srcDoc={resA.html} />
Note what's absent: no allow-scripts, no allow-same-origin. The reply is treated as hostile. A second layer — a server-side static audit — reports violations, but the sandbox is what enforces. Defense in depth, with the browser doing the heavy lifting.
3. Architecture: a thin, honest broker
React UI (:5173) ──POST /api/cascade──► Express (:3001) ──► Slot A provider ┐
(concurrent, identical prompt+nonce)
◄── SlotResult × 2 ── Slot B provider ┘
Three decisions shaped the design:
All model calls go through the backend. This is what makes local providers work with zero configuration — the browser can't reach http://127.0.0.1:11434 (Ollama) or :1234 (LM Studio) without CORS battles, and API keys must never ship to client code. The Express server forwards OpenAI-compatible /chat/completions calls; the Vite dev server proxies /api → localhost:3001 so the frontend needs no URL config.
Per-slot everything. Each slot has its own provider, base URL, key, and model name — Particle.ai vs. Ollama, cloud vs. local, old vs. new. The provider dropdown (PARTICLE.ai / Ollama / LM Studio / OpenRouter / Custom) exists precisely so you can stage matchups like "last month's API model vs. the quantized local one."
Audit before render, from the real bytes. The server extracts HTML from the reply, audits those exact bytes, parses the CSS with postcss, and hashes with SHA-256 — all before the UI ever sees it. The frontend displays; it never judges.
4. Code walkthrough
4.1 The fan-out: identical prompt, fresh nonce
Response caches silently fake determinism — repeat a prompt, get the cached reply, conclude nothing changed. So every run appends one fresh random nonce, identical for both slots (same prompt across models, zero reuse across runs):
// server/src/index.ts
const nonce = crypto.randomBytes(8).toString("hex");
const promptWithNonce = `${prompt.trim()}\n\n<!-- cascade-run:${nonce} -->`;
const [ra, rb] = await Promise.all([
callSlot(a, promptWithNonce, nonce),
callSlot(b, promptWithNonce, nonce),
]);
The system prompt is fixed and minimal — "You are a precise assistant. Answer the user's request directly." — so the only variable is the model.
4.2 Provider capability rules (detection, not assumptions)
Real-world provider quirks, encoded once in callSlot() (server/src/cascade.ts):
// Only Particle.ai + deepseek-* understands this field; others ignore or reject it.
if (slot.disableReasoning && isParticle && isDeepseek) {
body.chat_template_kwargs = { enable_thinking: false };
}
And the empty-budget retry: an HTTP 200 with empty content usually means a hidden chain-of-thought consumed max_tokens, so we retry once with double, capped at 4000 — never misreported as a refusal:
if (!content.trim()) {
if (attempt === 0) { maxTokens = Math.min(maxTokens * 2, 4000); continue; }
return { ...empty, error: "Empty content after retry…" };
}
Local-model ergonomics matter too: a dead Ollama returns Cannot reach http://127.0.0.1:11434 — is Ollama running? (fetch failed) — the provider's real error text, never "Something went wrong." And runs are never gated on /models succeeding, because deepseek-v4-flash-0731 doesn't appear in Particle.ai's model list and responds anyway.
4.3 The audit: report, don't hide
// server/src/cascade.ts
export function auditHtml(html: string) {
const violations: string[] = [];
if (/<script[\s>]/i.test(html)) violations.push("script tag (<script>)");
if (/\son\w+\s*=/i.test(html.replace(/<\s*style[\s\S]*?<\/\s*style\s*>/gi, ""))) {
violations.push("on* event handler attribute");
}
if (/javascript\s*:/i.test(html)) violations.push("javascript: URL");
for (const tag of ["img","svg","canvas","video","audio","iframe","object","embed"]) {
if (new RegExp(`<${tag}[\\s>/]`, "i").test(html)) violations.push(`<${tag}> tag`);
}
if (/@import/i.test(html)) violations.push("@import rule");
// …remote <link>, off-site url()…
const disqualified = /<script[\s>]/i.test(html) || /* on* test */;
return { violations, disqualified };
}
Two subtleties worth noting. First, the on* check strips <style> blocks first — naive regexes false-positive on CSS like divison=… well, on property text containing "on". Second, disqualification triggers only on <script> or on* (executable behavior); everything else is a VIOLATION badge. The UI shows all of it: pill, red banner, violation list. A disqualified cheater is content, not an error to swallow.
4.4 HTML extraction with a paper trail
Models don't return clean files — they return fences, apologies, and prose. extractHtml() tries
```html
fence → generic fence containing markup → <html/doctype → <style → raw, recording the path:
const fence = reply.match(/```
{% endraw %}
html\s*([\s\S]*?)
{% raw %}
```/i);
if (fence) return { html: fence[1].trim(), path: "fence:```
{% endraw %}
html" };
{% raw %}
The extractionPath ships in every result, so when a reply renders oddly you can see how it was recovered. Small thing, large debuggability payoff.
4.5 Reasoning-token honesty
usage.completion_tokens_details.reasoning_tokens — read it if present, else null. The UI renders null as "n/a" and hides the thinking readout entirely. It never prints 0 and never invents a number, which matters because most local models report nothing. Critically, reasoning_content (the CoT text) is dropped on the floor in callSlot() — never logged, displayed, or persisted. Only the count may be shown.
4.6 Chips from a real parser
The side panel listing animation, transform, conic-gradient, @keyframes beat isn't curated — it's walked from the reply's actual <style> blocks with postcss:
ts
const root = postcss.parse(css);
root.walk((node) => {
if (node.type === "rule" || node.type === "atrule") cssRuleCount++;
if (node.type === "atrule" && node.name === "keyframes") { animationCount++; … }
…
});
This is the "how" that makes the video watchable: viewers see which CSS machinery each model reached for, grounding the visual comparison in technique.
5. Verification: trust, but run the poison test
Cascade ships a verification protocol (the Verify constraint button):
-
Poison test. A canned
setIntervalanimation is POSTed to/api/auditand must come backDISQUALIFIED— while rendering frozen in its ownsandbox=""pane. Real output:
json
{"violations": ["script tag (<script>)"], "disqualified": true,
"badge": "DISQUALIFIED", "cssRuleCount": 3, "animationCount": 2,
"chips": ["@keyframes spin", "animation", "transform"]}
- Motion proof. The "loading spinner" preset must visibly move in both panes with JS disabled — pure CSS, QED.
- Divergence. SHA-256, DOM-node, and CSS-rule counts must differ between slots; byte-identical outputs mean you're rendering one string twice.
Plus a sandbox-attribute assertion over the live DOM (sandbox === "" on every preview frame). Constraint enforced, not claimed.
6. Lessons and limits
- Regex audits are reporters, not enforcers. The audit can be fooled by exotic encodings; that's fine, because the sandbox can't. Keep the two roles separate.
-
Static presets beat live discovery for reliability, live discovery beats presets for local models. Cascade does both: Particle.ai ships known names, Ollama/LM Studio read
GET /v1/models, and the text field always accepts a typed name. - The share-card export is honestly frozen. A PNG can't contain motion; the export snapshots current frames via the browser's own rendering (SVG foreignObject) rather than re-drawing. The UI says so.
-
What I'd add next: tournament brackets, CSS diff view, GIF export of the slider view, and a
prefers-reduced-motion-aware kiosk mode for recording.
Code & more: https://www.dailybuild.xyz/project/268-cascade



Top comments (0)