How it works

Figure Lab

Claude never draws a figure. It writes a short JSON recipe of sliders and formulas, and a small engine in the page turns that recipe into live Plotly plots. Below are the six ideas that make that split work, each with the lines of code that carry it.

  • Interface design
  • Declarative specs
  • Plotly & math.js
  • LLM tool use
  • No backend

From a sentence to a figure

  1. Describe“a cone inscribed in a sphere, with a slider for its height”
  2. WriteClaude answers with a recipe: sliders, formulas, objects, readouts
  3. Samplethe engine evaluates each formula on a grid of points
  4. DrawPlotly redraws every time a slider moves
  5. Sharethe recipe goes into a link or a standalone HTML file

The ideas

  1. 01A figure is a recipe, not a picture
  2. 02Formulas as data, in a sandbox
  3. 03One render function for everything
  4. 04A layout that reorders itself
  5. 05Claude writes the recipe, then fixes it
  6. 06Sharing with no server
  7. →Read further
01

A figure is a recipe, not a picture

The JSON format everything else is built on

Every figure, whether one of the eight examples or one Claude just generated, is a plain object with the same parts: params (the sliders), defs (named quantities computed from them), panels of objects to draw, readouts and notes. Every number inside can be a formula string instead. Here is the start of the drilled-sphere example:

index.htmllines 862–878
  title:"Drilled sphere",
  subtitle:"A ball of radius R with a cylindrical hole through its center. Change R and the volume left stays the same.",
  params:[
    {id:"R", label:"Sphere radius R", min:2, max:8, step:0.05, value:5},
    {id:"L", label:"Hole length 2H", min:0.5, max:"2*R-0.05", step:0.05, value:8},
    {id:"s", label:"Slice height", min:-1, max:1, step:0.01, value:0, display:"z = {z0:2}"},
    {id:"ghost", label:"Show the material drilled out", type:"toggle", value:0}
  ],
  defs:{ H:"min(L/2, R*0.999)", r:"sqrt(R^2 - H^2)", z0:"s*H", rho:"sqrt(R^2 - z0^2)" },
  panels:[
    { kind:"3d", view:{ x:["-R-0.5","R+0.5"], y:["-R-0.5","R+0.5"], z:["-R-0.5","R+0.5"], camera:{x:1.55,y:1.35,z:0.95} },
      objects:[
        {type:"parametric_surface", name:"Sphere surface that's left", color:"accent", opacity:0.45, u:[0,"2*pi"], v:["-H","H"], n:[72,40],
         x:"sqrt(R^2-v^2)*cos(u)", y:"sqrt(R^2-v^2)*sin(u)", z:"v"},
        {type:"parametric_surface", name:"Wall of the hole", color:"gold", opacity:0.85, u:[0,"2*pi"], v:["-H","H"], n:[72,8],
         x:"r*cos(u)", y:"r*sin(u)", z:"v"},
        {type:"curve", name:"Rims", color:"ink", width:4, t:[0,"2*pi"], n:120, x:"r*cos(t)", y:"r*sin(t)", z:"H"},
  1. Slider bounds can be formulas too. The hole length tops out at 2*R-0.05, so it can never be longer than the ball is wide, whatever R is.
  2. Each def can use the sliders and the defs before it. H and r are written once and reused by surfaces, curves and readouts.
  3. A sphere with the poles cut off is just a parametric surface whose v range is [-H, H]. Moving a slider changes the range, and the shape follows.

Recipes written by a person, or by a model, are often slightly off. normalize fills in what's missing before anything is drawn, so a figure with one 3D panel can skip the panels wrapper entirely.

index.htmllines 404–421
function normalize(spec){
  if(!spec || typeof spec!=='object') throw new Error('The recipe must be a JSON object');
  var s=JSON.parse(JSON.stringify(spec));
  if(!Array.isArray(s.panels)){
    s.panels=[{kind:s.kind||'3d', title:s.panelTitle, view:s.view||{}, objects:s.objects||[]}];
  }
  s.panels.forEach(function(p){
    p.kind = (String(p.kind||'3d').toLowerCase().indexOf('2')===0) ? '2d' : '3d';
    p.view = p.view||{}; p.objects = Array.isArray(p.objects)?p.objects:[];
  });
  s.params = Array.isArray(s.params)?s.params:[];
  s.params.forEach(function(p){
    if(!p.id) throw new Error('Every slider needs an "id"');
    if(RESERVED.indexOf(p.id)>=0) throw new Error('"'+p.id+'" is reserved for plotting variables; rename that slider');
    p.type = p.type||'slider';
    if(p.type==='toggle'){ p.value = p.value?1:0; }
    else if(p.type==='choice'){ p.options=p.options||[]; if(p.value===undefined && p.options[0]) p.value=p.options[0].value; }
  });
  1. A recipe with top-level objects is wrapped into a single panel.
  2. Any kind that starts with “2” is a 2D panel; everything else is 3D. “2D”, “2d” and “2d-plot” all work.
  3. The one hard rule: a slider can't be called x, t, u and so on, because the engine sets those while sampling. The error message says how to fix it.
Why a recipe

Because the figure is data, the same object can be edited by hand under “Figure recipe”, sent back to Claude for changes, packed into a link or embedded in a file. None of those features needed a second format.

02

Formulas as data, in a sandbox

math.js instead of eval

Recipes come from a model or from a shared link, so their formulas are untrusted text. Running them as JavaScript would let a link run any code on the page. Instead each string is parsed by math.js, whose expression language can only reach math functions. The engine adds three of its own and compiles each formula once.

index.htmllines 316–337
M.import({
  Sum: Sum,
  clamp: function(v,a,b){ return Math.min(b,Math.max(a,v)); },
  heaviside: function(v){ return v<0?0:1; }
}, {override:true});

var CC = new Map();
function comp(expr){
  var key=String(expr), c=CC.get(key);
  if(!c){ c=M.compile(key); CC.set(key,c); }
  return c;
}
function ev(expr, scope){
  if(typeof expr==='number') return expr;
  if(typeof expr==='boolean') return expr?1:0;
  if(expr==null || expr==='') return NaN;
  return num(comp(expr).evaluate(scope));
}
function fnOf(expr){
  if(typeof expr==='number'||typeof expr==='boolean'){ var v=num(expr); return function(){return v;}; }
  var c=comp(expr); return function(s){ return num(c.evaluate(s)); };
}
  1. The only additions to the language: Sum for finite sums, clamp and heaviside. Nothing that touches the page.
  2. Compiling is slow and evaluating is fast. A surface evaluates the same string thousands of times per frame, so compiled formulas are cached by their text.
  3. Everything comes back as a plain number. The math instance is created with predictable:true (line 282), so sqrt of a negative gives NaN rather than a complex number, and a NaN later becomes a gap in the plot.
index.htmllines 428–438
function buildScope(spec, values){
  var s={}, k;
  for(k in values) s[k]=values[k];
  var defs=spec.defs;
  for(k in defs){
    var key=k.trim();
    if(/^[A-Za-z_]\w*\s*\(.*\)$/.test(key)) comp(key+' = '+defs[k]).evaluate(s);   // function, e.g. "f(x)": "x^2"
    else s[key]=ev(defs[k], s);
  }
  return s;
}
  1. A def named like f(x) becomes a real math.js function, usable in every formula after it.
  2. Other defs are evaluated in order into one scope object. That scope is what every object, readout and label template sees.
03

One render function for everything

Sample, draw, and keep going when one part fails

Each object type has a small builder that turns a formula into numbers Plotly can draw. A surface z = f(x, y) is a grid: set x and y in the scope, evaluate, repeat.

index.htmllines 451–457
  surface: function(o, sc, cx){
    var X=rng(o.x,sc,cx.vx||[-5,5]), Y=rng(o.y,sc,cx.vy||[-5,5]), n=pair(o.n,56,56);
    var nx=cap(n[0],2,160), ny=cap(n[1],2,160), f=fnOf(o.z), xs=lin(X[0],X[1],nx), ys=lin(Y[0],Y[1],ny);
    var zr=cx.vz, pad=zr?(zr[1]-zr[0])*0.02:0;
    var Z=ys.map(function(y){ sc.y=y; return xs.map(function(x){ sc.x=x; var z=f(sc); if(zr && (z<zr[0]-pad||z>zr[1]+pad)) return null; return nz(z); }); });
    var t=surfStyle(o,cx.c,cx.name); t.x=xs; t.y=ys; t.z=Z; return [t];
  },
  1. The grid size is capped at 160 × 160 whatever the recipe asks for, so a careless recipe can't freeze the tab.
  2. Points that fall outside the view's z range become null, which Plotly leaves as holes. Without this, a surface like 1/r would stretch the axes to infinity.

Moving a slider only updates a value and asks for a frame. render then rebuilds the scope and every trace from scratch and hands them to Plotly.react, which diffs against what is on screen. Animation is the same thing: the play button moves one value per frame and calls render.

index.htmllines 790–809
    plots.forEach(function(pl, pi){
      var p=pl.panel, B=p.kind==='3d'?B3:B2, traces=[], ann=[], nLeg=0, vx=null, vy=null, vz=null;
      try{ vx=rng(p.view.x,scope,null); vy=rng(p.view.y,scope,null); vz=rng(p.view.z,scope,null); }catch(e){ errs.push({where:'panel '+(pi+1)+' view', msg:e.message}); }
      if(p.title) pl.wrap.querySelector('.fl-ptitle').textContent=tpl(p.title,scope);
      p.objects.forEach(function(o, oi){
        var where=(o.name||o.type||'object')+' (panel '+(pi+1)+', object '+(oi+1)+')';
        try{
          if(!truthy(o.show, scope)) return;
          var b=B[o.type];
          if(!b) throw new Error('unknown type "'+o.type+'" for a '+p.kind+' panel. Allowed: '+Object.keys(B).join(', '));
          var sc=Object.assign({}, scope);
          var cx={c:color(o.color, oi), name:o.name?tpl(o.name,scope):'', vx:vx, vy:vy, vz:vz, ann:ann};
          var tr=b(o, sc, cx);
          tr.forEach(function(t){ if(t.showlegend) nLeg++; if(!t.name) t.showlegend=false; t.legendgroup=t.name||undefined; });
          traces.push.apply(traces, tr);
        }catch(e){ errs.push({where:where, msg:e.message}); }
      });
      var lay; try{ lay=layoutFor(p, scope, th, rev, nLeg); }catch(e){ errs.push({where:'panel '+(pi+1)+' view', msg:e.message}); lay={}; }
      if(ann.length) lay.annotations=ann;
      Plotly.react(pl.div, traces, lay, {responsive:true, displaylogo:false, scrollZoom:p.kind==='2d',
  1. The show field is a formula too, which is how a toggle hides the “drilled out” caps.
  2. Each object gets its own copy of the scope, since builders overwrite x, y, t while sampling.
  3. An object that throws is skipped and its error is collected. The rest of the figure still draws, and the first four errors are shown above it in plain words.
  4. Plotly.react updates the plot in place. The layout carries a fixed uirevision per figure, so the 3D camera stays where you rotated it while the sliders move.
04

A layout that reorders itself

The figure first on a phone, and colours that follow the theme

On a wide screen the figure fills the left and a sticky sidebar holds the title, sliders, readouts and notes. On a phone that sidebar would end up below both plots, far from the figure it controls. The fix is CSS only: dissolve the two columns and give each card a position in one list.

index.htmllines 121–136
/* stage */
.stage{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:20px;align-items:start;padding-bottom:28px}
@media (max-width:980px){.stage{grid-template-columns:1fr}}
.side{display:grid;gap:18px;position:sticky;top:76px}
@media (max-width:980px){
  /* phones and tablets: title, figure, controls, readouts, companion plot, notes */
  .stage{display:flex;flex-direction:column;align-items:stretch;gap:14px}
  .side,#panels{display:contents}
  .side>.card:first-child{order:0}
  #panels>.fl-err{order:1}
  #panels>.fl-panel{order:1}
  #ctlCard{order:2} #roCard{order:3}
  #panels>.fl-panel~.fl-panel{order:4}
  #notesCard{order:5} .side>.actions{order:6}
  .fl-plot{height:clamp(320px,52vh,520px)}
}
  1. Two columns: the plot takes what's left, the sidebar a fixed 330px. minmax(0,1fr) lets Plotly shrink instead of pushing the page sideways.
  2. display:contents removes the sidebar and panel wrappers from layout, so their children become items of one flex column.
  3. The order on a phone: title, main figure, sliders, readouts, then the companion 2D plot and the notes. The sliders sit right under the plot they move.

Recipes name colours by role (accent, gold, teal…), not by hex code. The engine reads the page's CSS variables at render time, so the same recipe looks right in light and dark mode, and switching theme just calls render again.

index.htmllines 386–400
function color(c, i){
  if(!c) c=AUTO[i%AUTO.length];
  if(NAMED.indexOf(c)>=0) return cssVar('--'+c) || '#888888';
  return c;
}
function rgba(c, a){
  var h=toHex(c); if(h[0]!=='#') return c;
  var n=parseInt(h.slice(1),16);
  return 'rgba('+(n>>16&255)+','+(n>>8&255)+','+(n&255)+','+a+')';
}
function theme(){
  return {ink:cssVar('--ink'), muted:cssVar('--muted'), rule:cssVar('--rule'), surface:cssVar('--surface'), bg:cssVar('--bg'),
          sans:cssVar('--sans')||'sans-serif', mono:cssVar('--mono')||'monospace'};
}
function gradient(){ return [[0,color('accent')],[0.5,color('teal')],[1,color('gold')]]; }
  1. A named colour is looked up as a CSS custom property, such as --teal, on every render. Any other CSS colour is passed through.
  2. Axis text, grid lines and fonts come from the same variables, so Plotly's chrome matches the rest of the page.
05

Claude writes the recipe, then fixes it

Forced tool use and one repair round

The system prompt is the recipe manual: every object type, the expression syntax, guidelines such as “keep total grid points per surface under about 12,000”, and one worked example. The request forces a single tool call, show_figure, so the answer is always a JSON object and never prose with a code block in it.

index.htmllines 1351–1371
async function generate(refine){
  var text=$('prompt').value.trim();
  if(!text){ $('prompt').focus(); setStatus(refine?'Describe the change you want.':'Describe a figure first.',true); return; }
  if(!canGenerate()){ openSettings(); return; }
  var msg = refine && current
    ? 'Here is the recipe of the figure on screen:\n```json\n'+JSON.stringify(current)+'\n```\nChange it as follows, keeping everything else that still makes sense: '+text
    : 'Make an interactive figure: '+text;
  var messages=[{role:'user',content:msg}];
  setBusy(true);
  try{
    var res=await callClaude(messages), spec=res.block.input, problems=null;
    try{ load(spec,{source:'generated'}); problems=engine.errors.length?engine.errors:null; }
    catch(e){ problems=[{where:'recipe',msg:e.message}]; }
    if(problems){
      setStatus('<span class="spin"></span>Fixing a problem in the figure…');
      messages.push({role:'assistant',content:res.content});
      messages.push({role:'user',content:[{type:'tool_result',tool_use_id:res.block.id,is_error:true,
        content:'The renderer reported these problems:\n'+problems.map(function(p){return '- '+p.where+': '+p.msg}).join('\n')+'\nCall show_figure again with a corrected, complete recipe.'}]});
      var res2=await callClaude(messages);
      load(res2.block.input,{source:'generated'});
    }
  1. “Change this figure” sends the current recipe back with the requested change. Editing is the same call as generating, with more context.
  2. The recipe is drawn immediately. Any problems the renderer collected in section 03 become a list of errors.
  3. If something failed, those exact errors go back to Claude as a failed tool result, and it is asked for a corrected recipe. The renderer is the test; the model reads its output.

Generation needs an API key. By default each visitor pastes their own, which stays in their browser's storage and is sent only to Anthropic. The optional proxy, a 58-line Supabase function, lets visitors use William's key behind a passcode instead. It forwards only what Figure Lab needs.

proxy.tslines 33–50
  if (!ORIGINS.includes(origin)) return reply(403, { error: { message: "This site isn't allowed to use the proxy" } });
  if (PASSCODE && req.headers.get("x-lab-passcode") !== PASSCODE) return reply(401, { error: { message: "Wrong passcode" } });
  if (!KEY) return reply(500, { error: { message: "ANTHROPIC_API_KEY is not set on the server" } });

  const text = await req.text();
  if (text.length > MAX_BODY) return reply(413, { error: { message: "Request too large" } });
  let body: Record<string, unknown>;
  try { body = JSON.parse(text); } catch { return reply(400, { error: { message: "Bad JSON" } }); }

  // Only forward the fields Figure Lab uses, with a capped size and an allowed model.
  const payload = {
    model: MODELS.includes(String(body.model)) ? body.model : MODELS[0],
    max_tokens: Math.min(Number(body.max_tokens) || MAX_TOKENS, MAX_TOKENS),
    system: body.system,
    tools: body.tools,
    tool_choice: body.tool_choice,
    messages: body.messages,
  };
  1. Requests must come from an allowed site and carry the passcode; anything else is turned away before a request reaches Anthropic.
  2. The body is rebuilt field by field: the model must be on a short list and max_tokens is capped at 12,000, so a passcode holder can't use the key for anything larger.
06

Sharing with no server

The recipe travels in the link or in the file

There is no database. “Copy link” compresses the recipe and puts it after the # of the URL, which browsers never send to a server. Examples get a short link by name instead, like #ex=drilled-sphere.

index.htmllines 1192–1210
function b64u(bytes){ var s=''; for(var i=0;i<bytes.length;i++) s+=String.fromCharCode(bytes[i]); return btoa(s).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,''); }
function unb64u(str){ var s=atob(str.replace(/-/g,'+').replace(/_/g,'/')); var b=new Uint8Array(s.length); for(var i=0;i<s.length;i++) b[i]=s.charCodeAt(i); return b; }
async function pack(obj){
  var raw=new TextEncoder().encode(JSON.stringify(obj));
  if(window.CompressionStream){
    var out=await new Response(new Blob([raw]).stream().pipeThrough(new CompressionStream('deflate-raw'))).arrayBuffer();
    return 'z.'+b64u(new Uint8Array(out));
  }
  return 'j.'+b64u(raw);
}
async function unpack(str){
  var kind=str.slice(0,2), bytes=unb64u(str.slice(2));
  if(kind==='z.') bytes=new Uint8Array(await new Response(new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw'))).arrayBuffer());
  return JSON.parse(new TextDecoder().decode(bytes));
}
$('shareBtn').addEventListener('click',async function(){
  if(!current) return;
  var url=location.origin+location.pathname+(exampleId&&source==='example'?'#ex='+exampleId:'#fig='+await pack(current));
  copy(url,'Link copied. Anyone with it sees this figure');
  1. The browser's built-in CompressionStream deflates the JSON, and the bytes are written in URL-safe base64. The z. prefix records that it was compressed.
  2. Opening the link reverses it. Browsers without compression fall back to plain base64 under a j. prefix.
  3. An unedited example shares its name; anything else shares the whole recipe.

“Download HTML” builds a single page for someone with no link and no key, such as a class handout. It doesn't re-implement the viewer: it copies the page's own colour variables, viewer CSS and engine script by reading their text out of the DOM.

index.htmllines 1231–1236
    '<script src="https://cdn.jsdelivr.net/npm/plotly.js-dist-min@2.35.2/plotly.min.js"><\/script>\n'+
    '<script src="https://cdn.jsdelivr.net/npm/mathjs@13.2.0/lib/browser/math.js"><\/script>\n'+
    '<script>'+$('engine').textContent+'<\/script>\n'+
    '<script>var SPEC='+spec+';var N=FigureEngine.normalize(SPEC);if(!N.params.length)document.getElementById("cc").hidden=true;if(!N.readouts.length)document.getElementById("rc").hidden=true;'+
    'var E=FigureEngine.mount(SPEC,{panels:document.getElementById("panels"),controls:document.getElementById("controls"),readouts:document.getElementById("readouts")});'+
    'matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(){E.render()});<\/script>\n</body>\n</html>\n';
  1. The engine lives in a <script id="engine"> tag, so its source can be copied verbatim. The lab and the downloaded file run the same code.
  2. The recipe is inlined and mounted into the same three containers. Plotly and math.js load from a CDN, which keeps the file at about 40 KB instead of 9 MB.
→

Read further

Where everything lives

  • index.html the whole app: styles, the figure engine (<script id="engine">), the eight examples, the Claude prompt, sharing and export
  • proxy.ts the optional passcode proxy, a Supabase Edge Function