#!/usr/bin/env node
/**
* WebForge — Style↔Palette Compatibility Matrix
*
* Maps 100 StylePrompts styles to 40 WebForge palettes
* using keyword matching on: category, mood, industry, palette description
*/
const fs = require('fs');
const path = require('path');
const styles = JSON.parse(fs.readFileSync(path.join(__dirname, 'styles-complete.json'), 'utf8'));
const palettes = JSON.parse(fs.readFileSync(path.join(__dirname, 'palettes/palettes-complete.json'), 'utf8')).palettes;
// ═══════════════════════════════════════
// MAPPING RULES
// ═══════════════════════════════════════
// Style category → palette categories affinity
const categoryAffinity = {
minimalist: ['profesional', 'minimal', 'tech', 'fresco'],
bold: ['energético', 'premium'],
retro: ['cálido', 'energético', 'creativo'],
futuristic: ['tech', 'premium', 'energético'],
organic: ['natural', 'suave', 'cálido'],
geometric: ['profesional', 'tech', 'minimal'],
artistic: ['creativo', 'suave', 'energético'],
material: ['tech', 'profesional', 'fresco'],
dark: ['premium', 'tech', 'minimal'],
'3d': ['tech', 'energético', 'creativo'],
editorial: ['minimal', 'premium', 'profesional'],
themed: ['cálido', 'energético', 'creativo'],
zen: ['natural', 'suave', 'fresco'],
};
// Style palette description keywords → palette mood keywords
const paletteKeywordMap = {
'neutral': ['neutro', 'minimalista', 'profesional'],
'gray': ['neutro', 'minimalista', 'discreto'],
'monochrome': ['minimalista', 'editorial', 'artístico'],
'black and white': ['minimalista', 'editorial', 'oscuro'],
'neon': ['vibrante', 'audaz', 'futurista', 'energético'],
'pastel': ['suave', 'dulce', 'delicado'],
'warm': ['cálido', 'acogedor', 'rústico'],
'cool': ['fresco', 'moderno', 'limpio'],
'earth': ['cálido', 'rústico', 'artesanal', 'natural'],
'vibrant': ['vibrante', 'audaz', 'energético', 'dinámico'],
'muted': ['suave', 'elegante', 'sofisticado'],
'dark': ['oscuro', 'premium', 'nocturno'],
'gradient': ['futurista', 'creativo', 'mágico'],
'gold': ['lujoso', 'exclusivo', 'premium'],
'green': ['natural', 'orgánico', 'fresco'],
'blue': ['confianza', 'profesional', 'tech'],
'purple': ['creativo', 'premium', 'exclusivo'],
'red': ['urgente', 'poderoso', 'energético'],
'orange': ['energético', 'optimista', 'cálido'],
'pink': ['femenino', 'divertido', 'dulce'],
'cyan': ['tech', 'futurista', 'fresco'],
'teal': ['fresco', 'moderno', 'confiable'],
'organic': ['natural', 'orgánico', 'sostenible'],
'minimal': ['minimalista', 'limpio', 'moderno'],
'bold': ['audaz', 'vibrante', 'impactante'],
'elegant': ['elegante', 'sofisticado', 'premium'],
'playful': ['divertido', 'joven', 'dinámico'],
'translucent': ['moderno', 'tech', 'futurista'],
'limited': ['minimalista', 'elegante', 'preciso'],
'primary colors': ['artístico', 'geométrico', 'audaz'],
'natural': ['natural', 'orgánico', 'calmado'],
'vintage': ['cálido', 'rústico', 'artesanal'],
};
// Industry overlap matching
function industryOverlap(styleBusinesses, paletteIndustries) {
const normalizeSet = arr => arr.map(s => s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''));
const sNorm = normalizeSet(styleBusinesses);
const pNorm = normalizeSet(paletteIndustries);
let score = 0;
for (const sb of sNorm) {
for (const pi of pNorm) {
if (sb === pi) score += 3;
else if (sb.includes(pi) || pi.includes(sb)) score += 2;
else {
const sWords = sb.split(/\s+/);
const pWords = pi.split(/\s+/);
for (const sw of sWords) {
if (sw.length > 3 && pWords.some(pw => pw.includes(sw) || sw.includes(pw))) {
score += 1;
break;
}
}
}
}
}
return Math.min(score, 5);
}
// Palette description keyword matching
function paletteDescMatch(stylePaletteDesc, paletteMoods) {
const desc = stylePaletteDesc.toLowerCase();
let score = 0;
for (const [keyword, moods] of Object.entries(paletteKeywordMap)) {
if (desc.includes(keyword)) {
for (const mood of moods) {
if (paletteMoods.includes(mood)) {
score += 1;
}
}
}
}
return Math.min(score, 5);
}
// Category affinity matching
function categoryMatch(styleCategory, paletteCategory) {
const affinities = categoryAffinity[styleCategory] || [];
if (affinities[0] === paletteCategory) return 3;
if (affinities[1] === paletteCategory) return 2;
if (affinities.includes(paletteCategory)) return 1;
return 0;
}
// ═══════════════════════════════════════
// GENERATE MATRIX
// ═══════════════════════════════════════
const matrix = [];
const styleIds = Object.keys(styles).sort((a, b) => {
const na = parseInt(a.slice(1)), nb = parseInt(b.slice(1));
return na - nb;
});
for (const sid of styleIds) {
const style = styles[sid];
const styleScores = [];
for (const palette of palettes) {
const catScore = categoryMatch(style.category, palette.category);
const indScore = industryOverlap(style.businesses || [], palette.industries || []);
const descScore = paletteDescMatch(style.styleDNA?.palette || '', palette.mood || []);
// Weighted total (max ~5)
const total = Math.min(5, Math.round((catScore * 1.5 + indScore * 2 + descScore * 1.5) / 3));
styleScores.push({
paletteId: palette.id,
paletteName: palette.name,
score: total,
catScore,
indScore,
descScore,
});
}
// Sort by score desc
styleScores.sort((a, b) => b.score - a.score || b.indScore - a.indScore);
matrix.push({
styleId: sid,
styleName: style.name,
styleCategory: style.category,
businesses: style.businesses,
topPalettes: styleScores.slice(0, 5).map(s => ({
id: s.paletteId,
name: s.paletteName,
score: s.score,
})),
allScores: styleScores.map(s => ({ id: s.paletteId, score: s.score })),
});
}
// ═══════════════════════════════════════
// OUTPUTS
// ═══════════════════════════════════════
// 1. Full matrix JSON
fs.writeFileSync(path.join(__dirname, 'compatibility-matrix.json'), JSON.stringify(matrix, null, 2));
// 2. Lightweight top-5 per style
const lite = matrix.map(m => ({
style: m.styleId,
name: m.styleName,
top5: m.topPalettes,
}));
fs.writeFileSync(path.join(__dirname, 'compatibility-top5.json'), JSON.stringify(lite, null, 2));
// 3. CSV
const csvH = ['Style ID', 'Style Name', 'Category', 'Best Palette 1', 'Score 1', 'Best Palette 2', 'Score 2', 'Best Palette 3', 'Score 3'];
const csvRows = matrix.map(m => {
const t = m.topPalettes;
return [
m.styleId, `"${m.styleName}"`, m.styleCategory,
t[0]?.id + ' ' + t[0]?.name, t[0]?.score,
t[1]?.id + ' ' + t[1]?.name, t[1]?.score,
t[2]?.id + ' ' + t[2]?.name, t[2]?.score,
].join(',');
});
fs.writeFileSync(path.join(__dirname, 'compatibility-matrix.csv'), [csvH.join(','), ...csvRows].join('\n'));
// 4. Interactive HTML
const htmlMatrix = `<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>WebForge — Style↔Palette Compatibility</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:Inter,sans-serif;background:#0a0a0a;color:#fafafa;padding:2rem}
h1{font-size:2rem;font-weight:800;margin-bottom:.25rem}
.subtitle{color:#a3a3a3;margin-bottom:1.5rem;font-size:.9rem}
.search{width:100%;max-width:500px;padding:.75rem 1rem;border-radius:12px;border:1px solid #333;background:#111;color:#fff;font-size:1rem;margin-bottom:1.5rem}
.stats{display:flex;gap:2rem;margin-bottom:2rem;font-size:.85rem;color:#888}
.stats span{color:#fff;font-weight:700}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(440px,1fr));gap:1rem}
.card{border-radius:12px;background:#111;border:1px solid #1a1a1a;padding:1.25rem;transition:border-color .2s}
.card:hover{border-color:#333}
.card-head{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:.75rem}
.style-name{font-weight:700;font-size:1rem}
.style-id{font-size:.75rem;color:#555}
.style-cat{font-size:.7rem;padding:.2rem .5rem;border-radius:8px;background:#1a1a2e;color:#818cf8;border:1px solid #2e2e5e}
.matches{display:flex;flex-direction:column;gap:.5rem}
.match{display:flex;align-items:center;gap:.75rem;padding:.5rem .75rem;border-radius:8px;background:#0a0a0a;border:1px solid #1a1a1a}
.match-rank{font-size:.7rem;font-weight:700;color:#555;width:18px}
.match-colors{display:flex;gap:2px}
.match-swatch{width:20px;height:20px;border-radius:4px;border:1px solid #222}
.match-info{flex:1}
.match-name{font-size:.85rem;font-weight:600}
.match-meta{font-size:.7rem;color:#666}
.match-score{display:flex;gap:2px}
.dot{width:8px;height:8px;border-radius:50%;background:#222}
.dot.on{background:#22c55e}
.dot.on.mid{background:#f59e0b}
.dot.on.low{background:#ef4444}
.businesses{display:flex;flex-wrap:wrap;gap:.25rem;margin-top:.5rem}
.biz{font-size:.65rem;padding:.15rem .4rem;border-radius:6px;background:#0a1e14;color:#6ee7b7;border:1px solid #1a2e20}
</style>
</head>
<body>
<h1>🔗 Style↔Palette Compatibility</h1>
<p class="subtitle">100 estilos × 40 paletas — Top 5 matches por estilo con score de afinidad</p>
<input class="search" placeholder="Buscar estilo, paleta, industria..." id="search">
<div class="stats">
<div><span>100</span> estilos</div>
<div><span>40</span> paletas</div>
<div><span>4,000</span> combinaciones analizadas</div>
</div>
<div class="grid" id="grid"></div>
<script>
const matrix = ${JSON.stringify(lite)};
const palettes = ${JSON.stringify(palettes.map(p => ({
id: p.id, name: p.name, category: p.category, mood: p.mood,
colors: p.colors, industries: p.industries
})))};
const paletteMap = Object.fromEntries(palettes.map(p => [p.id, p]));
const stylesData = ${JSON.stringify(Object.fromEntries(styleIds.map(id => [id, { businesses: styles[id].businesses }])))};
const searchEl = document.getElementById('search');
const gridEl = document.getElementById('grid');
searchEl.addEventListener('input', render);
function render() {
const q = searchEl.value.toLowerCase();
const filtered = matrix.filter(m => {
if (!q) return true;
const searchable = [m.style, m.name, ...m.top5.map(t=>t.name), ...(stylesData[m.style]?.businesses||[])].join(' ').toLowerCase();
return searchable.includes(q);
});
gridEl.innerHTML = filtered.map(m => {
const biz = stylesData[m.style]?.businesses || [];
return \`<div class="card">
<div class="card-head">
<div>
<div class="style-name">\${m.name}</div>
<div class="style-id">\${m.style}</div>
</div>
</div>
<div class="businesses">\${biz.map(b=>'<span class="biz">'+b+'</span>').join('')}</div>
<div class="matches" style="margin-top:.75rem">
\${m.top5.map((t,i) => {
const p = paletteMap[t.id];
if (!p) return '';
const c = p.colors.light;
const dots = Array(5).fill(0).map((_,j) =>
'<div class="dot'+(j<t.score?' on'+(t.score<=2?' low':t.score<=3?' mid':''):'')+'"></div>'
).join('');
return \`<div class="match">
<div class="match-rank">#\${i+1}</div>
<div class="match-colors">
<div class="match-swatch" style="background:\${c.primary}"></div>
<div class="match-swatch" style="background:\${c.accent}"></div>
<div class="match-swatch" style="background:\${c.secondary}"></div>
</div>
<div class="match-info">
<div class="match-name">\${t.id} \${t.name}</div>
<div class="match-meta">\${p.category} · \${p.mood.slice(0,2).join(', ')}</div>
</div>
<div class="match-score">\${dots}</div>
</div>\`;
}).join('')}
</div>
</div>\`;
}).join('');
}
render();
</script>
</body>
</html>`;
fs.writeFileSync(path.join(__dirname, 'compatibility-matrix.html'), htmlMatrix);
// Stats
const avgScore = matrix.reduce((sum, m) => sum + (m.topPalettes[0]?.score || 0), 0) / matrix.length;
const perfect = matrix.filter(m => m.topPalettes[0]?.score >= 4).length;
const good = matrix.filter(m => m.topPalettes[0]?.score >= 3).length;
console.log('✅ Compatibility Matrix Generated');
console.log(` Styles: ${matrix.length}`);
console.log(` Palettes: ${palettes.length}`);
console.log(` Combinations: ${matrix.length * palettes.length}`);
console.log(` Avg top score: ${avgScore.toFixed(1)}/5`);
console.log(` Perfect matches (≥4): ${perfect}`);
console.log(` Good matches (≥3): ${good}`);
console.log(`\nFiles:`);
console.log(` compatibility-matrix.json`);
console.log(` compatibility-top5.json`);
console.log(` compatibility-matrix.csv`);
console.log(` compatibility-matrix.html`);