[object Object]

← back to Model Arena

design tools for arena models: figma / opendesign / hyperframes tool belt β€” OpenAI-style function-calling loop for GPT/Grok/Kimi + tool-capable Ollama models (qwen3, hermes3), design-pack prompt injection for the rest; per-battle 🎨 toggle, per-run toolCalls recorded and badged in UI

7df2798554bc0789a20656403cfa88e04690b221 Β· 2026-07-23 07:35:01 -0700 Β· Steve Abrams

Files touched

Diff

commit 7df2798554bc0789a20656403cfa88e04690b221
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 23 07:35:01 2026 -0700

    design tools for arena models: figma / opendesign / hyperframes tool belt β€” OpenAI-style function-calling loop for GPT/Grok/Kimi + tool-capable Ollama models (qwen3, hermes3), design-pack prompt injection for the rest; per-battle 🎨 toggle, per-run toolCalls recorded and badged in UI
---
 design-tools.js   | 232 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 public/index.html |   7 +-
 server.js         | 112 +++++++++++++++++++++++---
 3 files changed, 339 insertions(+), 12 deletions(-)

diff --git a/design-tools.js b/design-tools.js
new file mode 100644
index 0000000..bf75060
--- /dev/null
+++ b/design-tools.js
@@ -0,0 +1,232 @@
+// design-tools.js β€” graphic-design tool belt for arena models.
+// Three tools (figma / opendesign / hyperframes), exposed to models via
+// OpenAI-style function calling (metered + tool-capable ollama models) or
+// injected as a compact "design pack" for models without tool support.
+// Every tool returns INLINE-ABLE material only (hex values, CSS, SVG paths,
+// vanilla JS) because artifacts run under CSP default-src 'none' β€” no CDNs.
+'use strict';
+
+// ---------- opendesign: curated open design-system data ----------
+
+const PALETTES = [
+  { name: 'luxe-noir',       mood: 'luxury, editorial, high-end product', bg: '#0d0d0f', surface: '#17171c', text: '#f5f2ea', muted: '#8a8578', accent: '#c9a961', accent2: '#7d6a45' },
+  { name: 'ivory-gallery',   mood: 'minimal, airy, portfolio, interior design', bg: '#faf8f4', surface: '#ffffff', text: '#1c1a17', muted: '#9b948a', accent: '#b0472e', accent2: '#2f4c39' },
+  { name: 'synthwave',       mood: 'retro, neon, game, music', bg: '#12002b', surface: '#1e0b3d', text: '#f3e9ff', muted: '#8d7bb0', accent: '#ff2e88', accent2: '#00e5ff' },
+  { name: 'terracotta-sun',  mood: 'warm, organic, food, craft', bg: '#f7efe6', surface: '#fff9f1', text: '#3a2c22', muted: '#a08c7a', accent: '#c65f38', accent2: '#e0a458' },
+  { name: 'deep-forest',     mood: 'calm, natural, wellness, botanical', bg: '#0f1e16', surface: '#18291f', text: '#e9f0e4', muted: '#7f9683', accent: '#8fc07a', accent2: '#d9b96a' },
+  { name: 'ocean-glass',     mood: 'tech, SaaS, dashboard, clean', bg: '#0a1220', surface: '#101c30', text: '#e6eefb', muted: '#7a8aa5', accent: '#3aa6ff', accent2: '#39e0c8' },
+  { name: 'brutalist-ink',   mood: 'bold, graphic, poster, statement', bg: '#f2f0eb', surface: '#ffffff', text: '#111111', muted: '#666666', accent: '#ff3b00', accent2: '#0033ff' },
+  { name: 'candy-pop',       mood: 'playful, kids, sweets, fun', bg: '#fff4f8', surface: '#ffffff', text: '#43203a', muted: '#b087a4', accent: '#ff5fa2', accent2: '#5ecbf7' },
+  { name: 'mono-ink',        mood: 'typographic, serious, news, docs', bg: '#ffffff', surface: '#f6f6f6', text: '#161616', muted: '#8c8c8c', accent: '#161616', accent2: '#c40000' },
+  { name: 'midnight-violet', mood: 'AI, futuristic, premium tech', bg: '#0b0716', surface: '#150e29', text: '#efeaff', muted: '#8e83b3', accent: '#8b5cf6', accent2: '#f0abfc' },
+];
+
+const TYPOGRAPHY = {
+  stacks: {
+    'display-serif': `Georgia, 'Times New Roman', serif`,
+    'humanist-sans': `-apple-system, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif`,
+    'geometric-sans': `'Avenir Next', 'Century Gothic', Futura, 'Segoe UI', sans-serif`,
+    'mono': `'SF Mono', 'Cascadia Code', Consolas, Menlo, monospace`,
+    'luxe-display': `'Didot', 'Bodoni MT', 'Playfair Display', Georgia, serif`,
+  },
+  scale: { ratio: 1.333, steps: { caption: '12px', body: '16px', lead: '21px', h3: '28px', h2: '38px', h1: '50px', display: '67px' } },
+  rules: [
+    'Body line-height 1.5-1.7; headings 1.05-1.2; display type gets negative letter-spacing (-0.02em to -0.04em).',
+    'ALL-CAPS labels: 11-13px, letter-spacing 0.12-0.2em, muted color β€” the cheapest way to look designed.',
+    'Max text measure 60-75ch. One display face + one text face maximum.',
+    'Hierarchy through SIZE CONTRAST (jump 2+ scale steps between display and body), not through many mid sizes.',
+  ],
+};
+
+const SHADOWS = {
+  elevation: [
+    '0 1px 2px rgba(0,0,0,.06)',
+    '0 2px 8px rgba(0,0,0,.08)',
+    '0 8px 24px rgba(0,0,0,.12)',
+    '0 16px 48px rgba(0,0,0,.18)',
+    '0 32px 80px rgba(0,0,0,.28)',
+  ],
+  glow: 'color-matched glow: box-shadow: 0 0 24px 2px <accent at 35% alpha>; pair with a 1px border of the accent at 60%.',
+  softUi: 'inset light: box-shadow: inset 0 1px 0 rgba(255,255,255,.08); use on dark surfaces for a premium beveled read.',
+};
+
+const LAYOUT = {
+  spacing: [4, 8, 12, 16, 24, 32, 48, 64, 96, 128],
+  radius: { tight: '6px', card: '14px', pill: '999px' },
+  container: 'max-width 1100-1200px centered; generous vertical rhythm (section padding 96-128px desktop, 48-64 mobile).',
+  grid: 'CSS grid with repeat(auto-fit, minmax(260px, 1fr)) gap 24px for card grids; 12-col only when you need asymmetry.',
+  hero: 'Hero = one display headline (2 scale jumps above body) + one-line sub + ONE primary CTA. Whitespace above the fold is a feature.',
+};
+
+// lucide/feather-style 24x24 stroke icons β€” inline as:
+// <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="..."/></svg>
+const ICONS = {
+  x: 'M18 6 6 18M6 6l12 12',
+  check: 'M20 6 9 17l-5-5',
+  plus: 'M12 5v14M5 12h14',
+  menu: 'M3 6h18M3 12h18M3 18h18',
+  'arrow-right': 'M5 12h14M12 5l7 7-7 7',
+  'chevron-down': 'm6 9 6 6 6-6',
+  search: 'M11 3a8 8 0 1 0 0 16 8 8 0 0 0 0-16zM21 21l-4.35-4.35',
+  star: 'm12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z',
+  heart: 'M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z',
+  play: 'm5 3 14 9-14 9V3z',
+  zap: 'M13 2 3 14h9l-1 8 10-12h-9l1-8z',
+  sun: 'M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10zM12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42',
+  moon: 'M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z',
+  user: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8z',
+  mail: 'M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zM22 6l-10 7L2 6',
+  cart: 'M9 20a1 1 0 1 0 0 2 1 1 0 0 0 0-2zM20 20a1 1 0 1 0 0 2 1 1 0 0 0 0-2zM1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6',
+  layers: 'm12 2 10 6.5-10 6.5L2 8.5 12 2zM2 15.5 12 22l10-6.5',
+};
+
+// ---------- hyperframes: motion & animation catalog ----------
+
+const EASINGS = {
+  'swift-out (default UI)': 'cubic-bezier(.22,.9,.24,1)',
+  'power-in-out (scene moves)': 'cubic-bezier(.77,0,.18,1)',
+  'overshoot (playful pop)': 'cubic-bezier(.34,1.56,.64,1)',
+  'anticipate (wind up then go)': 'cubic-bezier(.6,-.28,.74,.05)',
+  'ease-out-quint (hero entrances)': 'cubic-bezier(.22,1,.36,1)',
+};
+
+const MOTION = {
+  entrances: `@keyframes fadeUp{from{opacity:0;transform:translateY(28px)}to{opacity:1;transform:none}}
+@keyframes scaleIn{from{opacity:0;transform:scale(.92)}to{opacity:1;transform:none}}
+@keyframes clipReveal{from{clip-path:inset(0 100% 0 0)}to{clip-path:inset(0)}}
+@keyframes blurIn{from{opacity:0;filter:blur(12px)}to{opacity:1;filter:blur(0)}}
+/* usage: .el{animation:fadeUp .7s cubic-bezier(.22,1,.36,1) both} β€” stagger siblings with animation-delay:calc(var(--i)*90ms) and style="--i:N" */`,
+  stagger: `// vanilla stagger β€” no libraries needed
+document.querySelectorAll('.stagger > *').forEach((el,i)=>{el.style.animationDelay=(i*90)+'ms';el.classList.add('in');});`,
+  textFx: `// letter-by-letter reveal
+function splitReveal(el){const t=el.textContent;el.textContent='';[...t].forEach((ch,i)=>{const s=document.createElement('span');s.textContent=ch;s.style.cssText='display:inline-block;opacity:0;transform:translateY(.6em);animation:fadeUp .5s cubic-bezier(.22,1,.36,1) forwards;animation-delay:'+(i*35)+'ms';el.appendChild(s);});}
+/* gradient text: background:linear-gradient(90deg,A,B);-webkit-background-clip:text;color:transparent */
+/* count-up: animate with rAF over 1.2s using t=1-Math.pow(1-p,4) (quart-out) */`,
+  background: `/* slow ambient gradient β€” premium, cheap */
+@keyframes drift{0%{background-position:0% 50%}50%{background-position:100% 50%}100%{background-position:0% 50%}}
+.bg{background:linear-gradient(120deg,#0b0716,#150e29,#0b1220);background-size:300% 300%;animation:drift 18s ease infinite}
+/* floating particles: one <canvas>, 60-120 dots, each {x,y,r:.5-2,vy:-.1..-.4,alpha:.2-.6}, wrap at top; draw with ctx.arc β€” keep count low for 60fps */
+/* film grain: tiny repeating radial-gradient overlay at 3-4% opacity, mix-blend-mode:overlay */`,
+  rules: [
+    'One timeline feel: pick ONE ease pair (an out for entrances, an in-out for moves) and use it everywhere.',
+    'Durations: micro 150-250ms, entrances 500-800ms, ambient loops 12-25s. Nothing between 1-3s (reads as lag).',
+    'Motion must PERFORM, not wobble: everything that moves should enter, settle, and STOP; only ambient background layers loop.',
+    'Stagger children 60-120ms apart; cap visible stagger at ~8 items then batch the rest.',
+    'Respect prefers-reduced-motion: wrap loops in @media (prefers-reduced-motion:no-preference).',
+  ],
+};
+
+// ---------- figma: live brand tokens via REST (needs FIGMA_TOKEN) ----------
+
+function figmaFetch(pathname, token) {
+  return new Promise((resolve, reject) => {
+    const req = require('https').request({ hostname: 'api.figma.com', path: pathname, headers: { 'X-Figma-Token': token } }, res => {
+      let buf = ''; res.on('data', d => buf += d);
+      res.on('end', () => { try { resolve({ status: res.statusCode, json: JSON.parse(buf) }); } catch { resolve({ status: res.statusCode, json: null }); } });
+    });
+    req.setTimeout(20000, () => req.destroy(new Error('figma timeout')));
+    req.on('error', reject); req.end();
+  });
+}
+
+function walkFills(node, out) {
+  if (!node || out.size >= 40) return;
+  if (Array.isArray(node.fills)) for (const f of node.fills) {
+    if (f.type === 'SOLID' && f.color && f.visible !== false) {
+      const c = f.color, hex = '#' + [c.r, c.g, c.b].map(v => Math.round(v * 255).toString(16).padStart(2, '0')).join('');
+      out.set(hex, (out.get(hex) || 0) + 1);
+    }
+  }
+  if (Array.isArray(node.children)) for (const ch of node.children) walkFills(ch, out);
+}
+
+async function figmaTokens(fileKey) {
+  const token = process.env.FIGMA_TOKEN;
+  if (!token) return { unavailable: true, note: 'FIGMA_TOKEN not configured on the arena server β€” design from the opendesign palettes instead.' };
+  const key = String(fileKey || process.env.FIGMA_ARENA_FILE || '').replace(/^.*figma\.com\/(?:file|design)\//, '').split(/[/?]/)[0];
+  if (!key) return { unavailable: true, note: 'no Figma file key given and no FIGMA_ARENA_FILE default is set.' };
+  const r = await figmaFetch('/v1/files/' + encodeURIComponent(key) + '?depth=3', token);
+  if (r.status !== 200 || !r.json || !r.json.document) return { unavailable: true, note: 'figma API ' + r.status + (r.json && r.json.err ? ': ' + r.json.err : '') };
+  const fills = new Map();
+  walkFills(r.json.document, fills);
+  const colors = [...fills.entries()].sort((a, b) => b[1] - a[1]).slice(0, 16).map(([hex, n]) => ({ hex, uses: n }));
+  const styles = Object.values(r.json.styles || {}).slice(0, 30).map(s => ({ name: s.name, type: s.styleType }));
+  return { file: r.json.name, colors, styles, note: 'colors are the most-used solid fills in the file β€” treat as the brand palette.' };
+}
+
+// ---------- the tool belt ----------
+
+const clamp = (s, n) => { s = JSON.stringify(s, null, 1); return s.length > n ? s.slice(0, n) + '…' : s; };
+
+const TOOLS = [
+  {
+    name: 'opendesign',
+    description: 'Open design-system library: curated color palettes (by mood), typography stacks + modular scale, spacing/radius/layout recipes, elevation shadows, and inline SVG icon paths. Ask for what you need and inline the values.',
+    parameters: {
+      type: 'object',
+      properties: {
+        ask: { type: 'string', enum: ['palettes', 'typography', 'layout', 'shadows', 'icons'], description: 'which library section' },
+        query: { type: 'string', description: 'optional filter β€” a mood ("luxury", "playful", "tech") for palettes, or comma-separated icon names for icons' },
+      },
+      required: ['ask'],
+    },
+    run: async (a) => {
+      const q = String(a.query || '').toLowerCase();
+      if (a.ask === 'palettes') {
+        const hits = q ? PALETTES.filter(p => (p.mood + ' ' + p.name).includes(q)) : PALETTES;
+        return clamp({ palettes: (hits.length ? hits : PALETTES).slice(0, 5), usage: 'pick ONE palette; bg/surface/text/muted/accent map directly to CSS custom properties' }, 3000);
+      }
+      if (a.ask === 'typography') return clamp(TYPOGRAPHY, 2500);
+      if (a.ask === 'layout') return clamp(LAYOUT, 2000);
+      if (a.ask === 'shadows') return clamp(SHADOWS, 1500);
+      if (a.ask === 'icons') {
+        const names = q ? q.split(/[,\s]+/).filter(Boolean) : Object.keys(ICONS).slice(0, 8);
+        const found = {}; for (const n of names) if (ICONS[n]) found[n] = ICONS[n];
+        return clamp({ viewBox: '0 0 24 24', render: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="PATH"/></svg>', available: Object.keys(ICONS), paths: found }, 3000);
+      }
+      return 'unknown ask β€” use palettes|typography|layout|shadows|icons';
+    },
+  },
+  {
+    name: 'hyperframes',
+    description: 'HyperFrames motion catalog: named easing curves, entrance/reveal keyframes, stagger + text-effect + ambient-background snippets (pure CSS/vanilla JS β€” no libraries), and the motion-doctrine rules that make animation feel designed.',
+    parameters: {
+      type: 'object',
+      properties: { ask: { type: 'string', enum: ['easings', 'entrances', 'stagger', 'text-effects', 'background', 'rules'], description: 'which part of the motion catalog' } },
+      required: ['ask'],
+    },
+    run: async (a) => {
+      if (a.ask === 'easings') return clamp(EASINGS, 1200);
+      if (a.ask === 'entrances') return MOTION.entrances;
+      if (a.ask === 'stagger') return MOTION.stagger;
+      if (a.ask === 'text-effects') return MOTION.textFx;
+      if (a.ask === 'background') return MOTION.background;
+      if (a.ask === 'rules') return clamp(MOTION.rules, 1500);
+      return 'unknown ask β€” use easings|entrances|stagger|text-effects|background|rules';
+    },
+  },
+  {
+    name: 'figma',
+    description: 'Pull live brand design tokens (most-used colors, named styles) from a Figma file via the Figma API. Use when the challenge references a brand or a Figma file; falls back gracefully if no token is configured.',
+    parameters: {
+      type: 'object',
+      properties: { file_key: { type: 'string', description: 'Figma file key or full figma.com URL (optional β€” server default used if omitted)' } },
+      required: [],
+    },
+    run: async (a) => clamp(await figmaTokens(a.file_key), 3000),
+  },
+];
+
+// compact design pack for models that can't call tools β€” the essentials, pre-injected
+function designPack() {
+  const p = PALETTES.slice(0, 4).map(x => `${x.name} (${x.mood}): bg ${x.bg} surface ${x.surface} text ${x.text} muted ${x.muted} accent ${x.accent}/${x.accent2}`).join('\n');
+  return `DESIGN PACK (use this material β€” it is what separates a designed artifact from a default one):
+Palettes (pick ONE):
+${p}
+Type: display ${TYPOGRAPHY.stacks['luxe-display']} | body ${TYPOGRAPHY.stacks['humanist-sans']} | scale ${JSON.stringify(TYPOGRAPHY.scale.steps)} | ALL-CAPS labels 12px letter-spacing .15em muted.
+Layout: spacing scale ${LAYOUT.spacing.join('/')}px, card radius 14px, card grid repeat(auto-fit,minmax(260px,1fr)) gap 24px, section padding 96px.
+Shadows: card ${SHADOWS.elevation[2]}; hover ${SHADOWS.elevation[3]}; glow = 0 0 24px accent@35%.
+Motion: entrances via @keyframes fadeUp{from{opacity:0;transform:translateY(28px)}to{opacity:1;transform:none}} .7s cubic-bezier(.22,1,.36,1) both, stagger siblings 90ms; ambient bg = slow 18s gradient drift; micro-interactions 150-250ms; everything settles and STOPS.`;
+}
+
+module.exports = { TOOLS, designPack };
diff --git a/public/index.html b/public/index.html
index 9c37fee..b849f39 100644
--- a/public/index.html
+++ b/public/index.html
@@ -177,6 +177,7 @@ tr.clk{cursor:pointer}tr.clk:hover td{background:rgba(0,229,255,.06)}
       <div class="presets" id="presets"></div>
       <textarea id="f-prompt" placeholder="The real-world build challenge, e.g. 'Build a playable neon-city driving game in a single HTML file…'"></textarea>
       <div class="models" id="model-picks"></div>
+      <label class="blindtog" title="Models get a graphic-design tool belt: opendesign (palettes, type scales, icons, shadows), hyperframes (motion/easing catalog), figma (live brand tokens when FIGMA_TOKEN is set). Tool-capable models (GPT, Grok, Kimi, Qwen3, Hermes3) call them live; the rest get the same material injected as a design pack. Tool rounds add ~30-60% tokens on metered models."><input type="checkbox" id="f-tools"> 🎨 Design tools β€” figma Β· opendesign Β· hyperframes</label>
       <div class="note">Local models ($0) and Kimi K2.5 (~$0.03) are pre-checked. Other metered models are opt-in per battle β€” estimated cost shown, actuals logged to the cost ledger.</div>
       <div class="note" id="est"></div>
       <button class="btn" id="f-go">βš” Fight</button>
@@ -345,7 +346,7 @@ $('#f-go').onclick = async ()=>{
   const title=$('#f-title').value.trim(), prompt=$('#f-prompt').value.trim(), ids=picked();
   if (!title||!prompt||!ids.length) return alert('Need a title, a challenge prompt, and at least one model.');
   $('#f-go').disabled = true;
-  const r = await fetch(API+'/api/challenges',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title,prompt,models:ids,category:window.curCat||''})});
+  const r = await fetch(API+'/api/challenges',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title,prompt,models:ids,category:window.curCat||'',designTools:$('#f-tools').checked})});
   $('#f-go').disabled = false;
   if (!r.ok) return alert('Error: '+(await r.text()));
   const c = await r.json();
@@ -370,7 +371,7 @@ async function renderDetail(id, statusOnly){
   const c = await r.json(); current = c;
   $('#d-title').textContent = c.title + (c.winner ? '  β€”  πŸ‘‘ ' + mLabel(c.winner) : '');
   const remixNote = c.remixOf ? '🧬 remixed from ' + mLabel(c.remixSource) + "'s artifact\n" : '';
-  $('#d-meta').textContent = 'πŸ•“ ' + fmtWhen(c.created_at) + (c.category?'  Β·  '+c.category:'') + '\n' + remixNote + c.prompt;
+  $('#d-meta').textContent = 'πŸ•“ ' + fmtWhen(c.created_at) + (c.category?'  Β·  '+c.category:'') + (c.designTools?'  Β·  🎨 design tools':'') + '\n' + remixNote + c.prompt;
   $('#export').href = API+'/export/'+c.id;
   // Re-run entire challenge β€” re-queue+run every model in this battle from scratch.
   { const rb = $('#rerun-all'); if (rb) rb.onclick = async () => {
@@ -420,6 +421,8 @@ async function renderDetail(id, statusOnly){
     if (run.seconds!=null) stats.push(run.seconds+'s');
     if (run.bytes) stats.push(Math.round(run.bytes/1024)+' KB');
     stats.push(run.cost ? '$'+run.cost.toFixed(3) : '$0');
+    if (run.toolCalls && run.toolCalls.length) stats.push(`<span title="design tools called: ${esc(run.toolCalls.join(', '))}">🎨 ${run.toolCalls.length}</span>`);
+    else if (c.designTools) stats.push(`<span title="design pack injected into the prompt (model has no tool-calling)">🎨 pack</span>`);
     if (typeof run.aiScore==='number'){
       const panel = run.aiScores ? Object.entries(run.aiScores).map(([m,s])=>m.split(':')[0]+' '+s).join(', ') : '';
       stats.push(`<span title="vision panel: ${panel}${run.aiSpread?' Β· spread '+run.aiSpread:''}">πŸ€– ${run.aiScore.toFixed(1)}${run.aiSpread>=4?' ⚑':''}</span>`);
diff --git a/server.js b/server.js
index bb3c664..b9d62c9 100644
--- a/server.js
+++ b/server.js
@@ -12,6 +12,7 @@ process.on('uncaughtException', (e) => console.error('[uncaughtException]', (e &
 const http = require('http'), fs = require('fs'), path = require('path'), os = require('os');
 const crypto = require('crypto');
 const { execFile } = require('child_process');
+const { TOOLS: DESIGN_TOOLS, designPack } = require('./design-tools');
 
 // headless-Chrome path (macOS default; override with CHROME_BIN)
 const CHROME_BIN = process.env.CHROME_BIN || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
@@ -47,9 +48,9 @@ fs.mkdirSync(ART, { recursive: true });
 // Local ollama models are free and enabled by default. Metered models require
 // their env key AND explicit per-run selection in the UI (never pre-checked).
 const MODELS = [
-  { id: 'qwen3-14b',  label: 'Qwen3 14B',    kind: 'local', host: 'http://localhost:11434',      model: 'qwen3:14b',  estCost: 0 },
+  { id: 'qwen3-14b',  label: 'Qwen3 14B',    kind: 'local', host: 'http://localhost:11434',      model: 'qwen3:14b',  estCost: 0, tools: true },
   { id: 'gemma3-12b', label: 'Gemma3 12B',   kind: 'local', host: 'http://localhost:11434',      model: 'gemma3:12b', estCost: 0 },
-  { id: 'hermes3-8b', label: 'Hermes3 8B',   kind: 'local', host: 'http://localhost:11434',      model: 'hermes3:8b', estCost: 0 },
+  { id: 'hermes3-8b', label: 'Hermes3 8B',   kind: 'local', host: 'http://localhost:11434',      model: 'hermes3:8b', estCost: 0, tools: true },
   // was Mac1 (192.168.1.133) β€” that Ollama host wedges (model pinned in VRAM, generations hang);
   // repointed to Mac2-local qwen2.5 for reliable liveness. Set OLLAMA_MAC1=1 to use Mac1 again.
   { id: 'qwen25-7b',  label: 'Qwen2.5 (local)', kind: 'local', host: process.env.OLLAMA_MAC1 ? 'http://192.168.1.133:11434' : 'http://localhost:11434', model: process.env.OLLAMA_MAC1 ? 'qwen2.5:7b' : 'qwen2.5:latest', estCost: 0 },
@@ -58,9 +59,9 @@ const MODELS = [
   // Claude on Steve's MAX PLAN via the Claude Code CLI β€” $0 marginal (subscription-covered, not API credits)
   { id: 'claude-code', label: 'Claude (Max plan)', kind: 'cli', estCost: 0 },
   // { id: 'claude',  label: 'Claude Fable 5 (API)', kind: 'metered', provider: 'anthropic', model: 'claude-fable-5', envKey: 'ANTHROPIC_API_KEY', estCost: 0.15 }, // needs API credits
-  { id: 'kimi',       label: 'Kimi K2.5',      kind: 'metered', provider: 'moonshot',  model: 'kimi-k2.5',        envKey: 'MOONSHOT_API_KEY',  estCost: 0.03 },
-  { id: 'gpt',        label: 'GPT-5.1',        kind: 'metered', provider: 'openai',    model: process.env.OPENAI_MODEL || 'gpt-5.1', envKey: 'OPENAI_API_KEY', estCost: 0.10 },
-  { id: 'grok',       label: 'Grok 4.5',       kind: 'metered', provider: 'xai',       model: process.env.GROK_MODEL || 'grok-4.5', envKey: 'XAI_API_KEY', estCost: 0.10 },
+  { id: 'kimi',       label: 'Kimi K2.5',      kind: 'metered', provider: 'moonshot',  model: 'kimi-k2.5',        envKey: 'MOONSHOT_API_KEY',  estCost: 0.03, tools: true },
+  { id: 'gpt',        label: 'GPT-5.1',        kind: 'metered', provider: 'openai',    model: process.env.OPENAI_MODEL || 'gpt-5.1', envKey: 'OPENAI_API_KEY', estCost: 0.10, tools: true },
+  { id: 'grok',       label: 'Grok 4.5',       kind: 'metered', provider: 'xai',       model: process.env.GROK_MODEL || 'grok-4.5', envKey: 'XAI_API_KEY', estCost: 0.10, tools: true },
 ];
 
 // Claude via the Claude Code CLI β€” runs on Steve's Max SUBSCRIPTION (OAuth), not
@@ -153,14 +154,21 @@ function backfillThumbs() {
 setTimeout(backfillThumbs, 2000);
 
 // ---------- generation ----------
-const ARENA_PROMPT = (challenge) => `You are competing in a model arena against other AI models. Challenge:
+const ARENA_PROMPT = (challenge, design) => `You are competing in a model arena against other AI models. Challenge:
 
 ${challenge}
 
 Rules:
 - Return ONE complete, self-contained single-file HTML document (inline CSS + JS, no external resources, no network requests).
 - It must run instantly when opened in a browser iframe.
-- Output ONLY the HTML document starting with <!DOCTYPE html>. No markdown fences, no commentary before or after.`;
+- Output ONLY the HTML document starting with <!DOCTYPE html>. No markdown fences, no commentary before or after.${
+  design === 'tools' ? `
+
+You have GRAPHIC-DESIGN TOOLS available (opendesign, hyperframes, figma). Winners in this arena are judged on visual quality β€” call the tools FIRST to fetch a real palette, type scale, and motion snippets, then inline that material into your artifact. 1-3 tool calls is ideal; then output the final HTML document as normal text.`
+  : design === 'pack' ? `
+
+${designPack()}`
+  : ''}`;
 
 function extractHtml(text) {
   if (!text) return null;
@@ -284,6 +292,85 @@ async function generateMetered(m, prompt) {
   return { text, cost, tokens: { in: inTok, out: outTok } };
 }
 
+// ---------- design-tools loop (graphic-design tool belt: opendesign / hyperframes / figma) ----------
+const TOOL_SCHEMAS = DESIGN_TOOLS.map(t => ({ type: 'function', function: { name: t.name, description: t.description, parameters: t.parameters } }));
+async function runDesignTool(name, args, calls) {
+  const t = DESIGN_TOOLS.find(x => x.name === name);
+  calls.push(name);
+  if (!t) return 'unknown tool: ' + name;
+  try { return String(await t.run(args || {})); }
+  catch (e) { return 'tool error: ' + String(e.message || e).slice(0, 150); }
+}
+
+// OpenAI-compatible function-calling loop (openai / xai / moonshot share the wire format)
+async function generateMeteredTools(m, prompt) {
+  const key = process.env[m.envKey];
+  if (!key) throw new Error(m.envKey + ' not set β€” metered model unavailable');
+  const base = { moonshot: 'https://api.moonshot.ai/v1', openai: 'https://api.openai.com/v1', xai: 'https://api.x.ai/v1' }[m.provider];
+  if (!base) return generateMetered(m, prompt); // anthropic et al: no tool loop wired β€” plain path
+  const messages = [{ role: 'user', content: prompt }];
+  const calls = [];
+  let inTok = 0, outTok = 0, text = '';
+  for (let round = 0; round < 6; round++) {
+    const body = m.provider === 'openai'
+      ? { model: m.model, max_completion_tokens: 16000, messages, tools: TOOL_SCHEMAS }
+      : { model: m.model, temperature: m.provider === 'moonshot' ? 1 : 0.7, max_tokens: 16000, messages, tools: TOOL_SCHEMAS };
+    const r = await httpJson(base + '/chat/completions', { headers: { Authorization: 'Bearer ' + key } }, body, 300000);
+    const ch = r.json && r.json.choices && r.json.choices[0];
+    if (!ch) throw new Error(m.provider + ' error: ' + JSON.stringify(r.json && r.json.error || r.raw).slice(0, 200));
+    inTok += (r.json.usage && r.json.usage.prompt_tokens) || 0; outTok += (r.json.usage && r.json.usage.completion_tokens) || 0;
+    const tc = ch.message.tool_calls;
+    if (tc && tc.length) {
+      messages.push(ch.message);
+      for (const call of tc) {
+        let args = {}; try { args = JSON.parse(call.function.arguments || '{}'); } catch {}
+        messages.push({ role: 'tool', tool_call_id: call.id, content: await runDesignTool(call.function.name, args, calls) });
+      }
+      continue;
+    }
+    text = ch.message.content || ch.message.reasoning_content || '';
+    break;
+  }
+  const cost = meteredCost(m.provider, inTok, outTok);
+  const entry = { ts: new Date().toISOString(), provider: m.provider, model: m.model, task: 'model-arena-tools', input_tokens: inTok, output_tokens: outTok, cost_usd: +cost.toFixed(6) };
+  appendJsonl(COST_FILE, entry); appendJsonl(GLOBAL_LEDGER, entry);
+  return { text, cost, tokens: { in: inTok, out: outTok }, toolCalls: calls };
+}
+
+// Ollama function-calling loop (qwen3 / hermes3 support tools); falls back to the
+// plain design-pack path if this host/model rejects the tools field
+async function generateLocalTools(m, prompt) {
+  const messages = [{ role: 'user', content: prompt }];
+  const calls = [];
+  let outTok = 0;
+  try {
+    for (let round = 0; round < 5; round++) {
+      const r = await httpJson(m.host + '/api/chat', {}, {
+        model: m.model, stream: false, messages, tools: TOOL_SCHEMAS,
+        options: { temperature: 0.7, num_predict: 6144 },
+      }, 600000);
+      if (!r.json || !r.json.message) throw new Error('ollama bad response: ' + (r.raw || r.status));
+      outTok += r.json.eval_count || 0;
+      const msg = r.json.message, tc = msg.tool_calls;
+      if (tc && tc.length) {
+        messages.push(msg);
+        for (const call of tc) messages.push({ role: 'tool', content: await runDesignTool(call.function.name, call.function.arguments || {}, calls) });
+        continue;
+      }
+      return { text: msg.content, cost: 0, tokens: { out: outTok }, toolCalls: calls };
+    }
+    return { text: '', cost: 0, tokens: { out: outTok }, toolCalls: calls };
+  } catch (e) {
+    if (calls.length) throw e; // failed mid-loop β€” surface it
+    // model/host rejected tool calling entirely β€” regenerate with the injected design pack
+    return generateLocal(m, ARENA_PROMPT_REBUILD(prompt));
+  }
+}
+// rebuild a tools-flavored prompt as a pack-flavored one (used only by the local fallback)
+function ARENA_PROMPT_REBUILD(toolsPrompt) {
+  return String(toolsPrompt).replace(/\n\nYou have GRAPHIC-DESIGN TOOLS[\s\S]*$/, '\n\n' + designPack());
+}
+
 // ---------- AI auto-referee (a PANEL of local vision models looks at each artifact) ----------
 // multiple models vote β†’ a robust consensus score + variance (qwen2.5vl leads, minicpm-v seconds).
 const VISION_MODELS = (process.env.VISION_MODELS || 'qwen2.5vl:7b,minicpm-v:latest').split(',').map(s => s.trim()).filter(Boolean);
@@ -355,14 +442,17 @@ function runModel(challenge, modelId) {
   run.status = 'queued'; run.error = null; run.started_at = null; run.finished_at = null;
   run.queued_at = new Date().toISOString(); // real queue-entry time β€” the stuck-queue watchdog ages from THIS, not challenge age
   saveChallenges(challenges);
-  const prompt = ARENA_PROMPT(challenge.prompt);
+  const dt = !!challenge.designTools;
+  // tool-capable models get the live tool loop; the rest get the same material injected as a design pack
+  const prompt = ARENA_PROMPT(challenge.prompt, dt ? (m.tools ? 'tools' : 'pack') : null);
   const exec = async () => {
     run.status = 'running'; run.started_at = new Date().toISOString(); saveChallenges(challenges);
     const t0 = Date.now();
     try {
-      const out = m.kind === 'local' ? await generateLocal(m, prompt)
+      const out = m.kind === 'local' ? (dt && m.tools ? await generateLocalTools(m, prompt) : await generateLocal(m, prompt))
         : m.kind === 'cli' ? await generateCli(m, prompt)
-        : await generateMetered(m, prompt);
+        : (dt && m.tools ? await generateMeteredTools(m, prompt) : await generateMetered(m, prompt));
+      if (out.toolCalls) run.toolCalls = out.toolCalls;
       const html = extractHtml(out.text);
       if (!html) throw new Error('no HTML document in model output (' + String(out.text || '').length + ' chars)');
       const dir = path.join(ART, challenge.id);
@@ -493,6 +583,7 @@ const server = http.createServer(async (req, res) => {
       id: crypto.randomBytes(6).toString('hex'),
       title, prompt,
       category: (String(body.category || '').slice(0, 40)) || inferCategory({ title, prompt }),
+      designTools: !!body.designTools,
       created_at: new Date().toISOString(),
       winner: null,
       runs: ids.map(id => ({ model: id, status: 'queued', error: null, seconds: null, cost: null })),
@@ -525,6 +616,7 @@ const server = http.createServer(async (req, res) => {
       id: crypto.randomBytes(6).toString('hex'),
       title: 'Remix: ' + c.title.replace(/^Remix: /, ''),
       prompt: remixPrompt, remixOf: c.id, remixSource: srcId,
+      designTools: 'designTools' in body ? !!body.designTools : !!c.designTools,
       created_at: new Date().toISOString(), winner: null,
       runs: ids.map(id => ({ model: id, status: 'queued', error: null, seconds: null, cost: null })),
     };

← 72239ef auto-save: 2026-07-23T07:19:17 (50 files) β€” data/challenges.  Β·  back to Model Arena  Β·  README: document the design-tools belt afeb092 β†’