β 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
A design-tools.jsM public/index.htmlM server.js
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 β