feat(studio): un solo set de iconos en vez de emojis mezclados con símbolos

En la misma pantalla convivían dos lenguajes: emojis a color (🌙 📊 🧠 ✍️ 📄
🌐 🔁) y símbolos tipográficos monocromos (✎ ✕ ← ↻ ⚠ ⬇ ✓). Eso se lee como
descuido, y encima los emojis los dibuja el sistema operativo — el mismo panel
se ve distinto en Mac, en Windows y en Android, sin que podamos hacer nada.

Ahora hay un set propio de iconos SVG de trazo: mismo grosor de línea, misma
caja, y heredan currentColor, así que toman el color del texto que los rodea y
funcionan igual en tema claro y oscuro. Al lado uno de otro tienen el mismo
peso visual, que es lo que hacía falta.

El emoji suelto de la pantalla de inicio pasa a ser Umi, que ya es el personaje
del producto.

Un detalle de implementación que costó dos intentos: los iconos NO pueden
declararse como cadenas de HTML para v-html. Dentro de un <svg>, v-html parsea
como HTML y los <path> quedan sin el namespace de SVG: aparecen en el DOM pero
no dibujan nada, así que la primera versión se veía perfecta en el código y en
blanco en pantalla. Van declarados como pares [etiqueta, atributos] para que
Vue los cree con el namespace correcto.

Y AgenteDetail usaba cinco iconos sin importar el componente. Verificado ahora
sobre todos los archivos: cada componente usado está importado donde se usa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-22 10:45:43 -05:00
co-authored by Claude Opus 5
parent 002b75e71b
commit ee9bb2cfb7
14 changed files with 173 additions and 54 deletions
+2 -1
View File
@@ -3,6 +3,7 @@ import Sidebar from './components/Sidebar.vue'
import { tema, alternarTema } from './lib/tema.js'
import { menuAbierto } from './lib/ui.js'
import DespertarUmind from './components/DespertarUmind.vue'
import UiIcono from './components/ui/UiIcono.vue'
</script>
<template>
@@ -27,7 +28,7 @@ import DespertarUmind from './components/DespertarUmind.vue'
:title="tema === 'oscuro' ? 'Cambiar a claro' : 'Cambiar a oscuro'"
@click="alternarTema"
>
<span class="text-base leading-none">{{ tema === 'oscuro' ? '☀️' : '🌙' }}</span>
<UiIcono :nombre="tema === 'oscuro' ? 'sol' : 'luna'" :tam="17" />
</button>
</header>
<div class="flex-1 max-w-5xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8 animate-aparecer">
+3 -2
View File
@@ -1,4 +1,5 @@
<script setup>
import UiIcono from './ui/UiIcono.vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api.js'
@@ -191,14 +192,14 @@ onMounted(() => {
title="Editar"
@click="editarTenant(t)"
>
<UiIcono nombre="lapiz" :tam="14" />
</button>
<button
class="p-1 text-tenue hover:text-red-600"
title="Eliminar"
@click="eliminar(t)"
>
<UiIcono nombre="cerrar" :tam="14" />
</button>
</div>
</div>
+111
View File
@@ -0,0 +1,111 @@
<script setup>
// Un solo set de iconos para todo uMind.
//
// Antes convivían emojis a color (🌙 📊 🧠 ✍️) con símbolos tipográficos
// monocromos (✎ ✕ ← ↻). Dos lenguajes distintos en la misma pantalla se leen
// como descuido, y encima los emojis los dibuja el sistema operativo: el mismo
// panel se ve distinto en Mac, en Windows y en Android, sin que podamos hacer
// nada.
//
// Estos son SVG de trazo que heredan currentColor, así que toman el color del
// texto que los rodea y funcionan igual en tema claro y oscuro. Mismo grosor de
// línea y misma caja en todos: al lado uno de otro tienen el mismo peso visual.
defineProps({
nombre: { type: String, required: true },
tam: { type: [Number, String], default: 16 },
})
// Cada icono es una lista de [etiqueta, atributos]. No es una cadena de HTML a
// propósito: v-html dentro de un <svg> parsea como HTML y los <path> quedan sin
// el namespace de SVG — se insertan en el DOM pero no dibujan nada. Declarados
// así, Vue los crea con el namespace correcto porque ve el <svg> padre.
const ICONOS = {
luna: [['path', { d: 'M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z' }]],
sol: [
['circle', { cx: 12, cy: 12, r: 4 }],
['path', { d: 'M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4' }],
],
lapiz: [
['path', { d: 'M12 20h9' }],
['path', { d: 'M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z' }],
],
cerrar: [['path', { d: 'M18 6 6 18M6 6l12 12' }]],
atras: [['path', { d: 'M19 12H5M12 19l-7-7 7-7' }]],
grafico: [
['path', { d: 'M3 3v18h18' }],
['path', { d: 'M7 15v3M12 10v8M17 6v12' }],
],
cerebro: [
['path', { d: 'M9.5 3A2.5 2.5 0 0 0 7 5.5v.2A2.7 2.7 0 0 0 5 8.3c0 .8.3 1.5.9 2A2.8 2.8 0 0 0 5 12.5c0 1 .5 1.9 1.3 2.4-.2.4-.3.8-.3 1.2A2.9 2.9 0 0 0 9 19a2.5 2.5 0 0 0 3 2V3.8A2.5 2.5 0 0 0 9.5 3Z' }],
['path', { d: 'M14.5 3A2.5 2.5 0 0 1 17 5.5v.2a2.7 2.7 0 0 1 2 2.6c0 .8-.3 1.5-.9 2A2.8 2.8 0 0 1 19 12.5c0 1-.5 1.9-1.3 2.4.2.4.3.8.3 1.2A2.9 2.9 0 0 1 15 19a2.5 2.5 0 0 1-3 2' }],
],
imagen: [
['rect', { x: 3, y: 3, width: 18, height: 18, rx: 2 }],
['circle', { cx: 8.5, cy: 8.5, r: 1.5 }],
['path', { d: 'm21 15-5-5L5 21' }],
],
microfono: [
['rect', { x: 9, y: 2, width: 6, height: 12, rx: 3 }],
['path', { d: 'M5 10a7 7 0 0 0 14 0M12 17v5' }],
],
escribir: [
['path', { d: 'M3 21h18' }],
['path', { d: 'M5 17 17.5 4.5a2.1 2.1 0 0 1 3 3L8 20l-4 1Z' }],
],
archivo: [
['path', { d: 'M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7Z' }],
['path', { d: 'M14 2v5h5' }],
],
sitio: [
['circle', { cx: 12, cy: 12, r: 9 }],
['path', { d: 'M3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18Z' }],
],
refrescar: [
['path', { d: 'M21 12a9 9 0 1 1-3-6.7' }],
['path', { d: 'M21 4v5h-5' }],
],
descargar: [
['path', { d: 'M12 3v12' }],
['path', { d: 'm7 11 5 5 5-5' }],
['path', { d: 'M4 21h16' }],
],
alerta: [
['path', { d: 'M12 9v4M12 17h.01' }],
['path', { d: 'M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z' }],
],
ok: [['path', { d: 'M20 6 9 17l-5-5' }]],
copiar: [
['rect', { x: 9, y: 9, width: 12, height: 12, rx: 2 }],
['path', { d: 'M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1' }],
],
mensaje: [['path', { d: 'M21 11.5a8.4 8.4 0 0 1-9 8.4 8.5 8.5 0 0 1-3.9-.9L3 21l1.9-5.1A8.4 8.4 0 0 1 4 11.5a8.5 8.5 0 0 1 8.5-8.5A8.4 8.4 0 0 1 21 11.5Z' }]],
auto: [
['path', { d: 'M17 2.1 21 6l-4 3.9' }],
['path', { d: 'M3 11V9a4 4 0 0 1 4-4h14' }],
['path', { d: 'M7 21.9 3 18l4-3.9' }],
['path', { d: 'M21 13v2a4 4 0 0 1-4 4H3' }],
],
}
</script>
<template>
<svg
:width="tam"
:height="tam"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
class="inline-block shrink-0 align-[-0.15em]"
aria-hidden="true"
>
<component
v-for="(el, i) in ICONOS[nombre] || []"
:key="i"
:is="el[0]"
v-bind="el[1]"
/>
</svg>
</template>
+2 -1
View File
@@ -1,4 +1,5 @@
<script setup>
import UiIcono from './UiIcono.vue'
defineProps({ titulo: { type: String, default: '' }, ancho: { type: String, default: 'max-w-lg' } })
const emit = defineEmits(['cerrar'])
</script>
@@ -12,7 +13,7 @@ const emit = defineEmits(['cerrar'])
<div :class="['card w-full p-6 max-h-[90vh] overflow-y-auto animate-escalar shadow-2xl', ancho]">
<div v-if="titulo" class="flex items-center justify-between mb-4">
<h2 class="font-semibold text-texto">{{ titulo }}</h2>
<button class="btn-ghost !px-2 !py-1" type="button" @click="emit('cerrar')"></button>
<button class="btn-ghost !px-2 !py-1" type="button" @click="emit('cerrar')"><UiIcono nombre="cerrar" :tam="15" /></button>
</div>
<slot />
</div>
+10 -8
View File
@@ -5,6 +5,7 @@ import { api } from '../lib/api.js'
import { apiUmind, contexto } from '../lib/contexto.js'
import UiEmptyState from '../components/ui/UiEmptyState.vue'
import UiMascota from '../components/ui/UiMascota.vue'
import UiIcono from '../components/ui/UiIcono.vue'
const props = defineProps({
tenantId: { type: String, required: true },
@@ -414,7 +415,7 @@ async function enviarChatPrueba() {
const r = await api.post(apiUmind('/umind/chat'), { agente_id: agenteIdNum.value, session_id: chatSessionId, mensaje: texto })
chatMensajes.value.push({ role: 'assistant', content: r.respuesta })
} catch (e) {
chatMensajes.value.push({ role: 'assistant', content: ` ${e.message}` })
chatMensajes.value.push({ role: 'assistant', content: `${e.message}` })
} finally {
chatEnviando.value = false
}
@@ -507,7 +508,7 @@ watch(
<!-- Un archivo que se reenvía sirve para justificar el gasto puertas
adentro; un panel al que hay que entrar, no. -->
<a :href="urlReporte" class="btn-ghost shrink-0" title="Conversaciones y consumo del mes en Excel">
Reporte del mes
<UiIcono nombre="descargar" :tam="15" /> Reporte del mes
</a>
</div>
@@ -532,12 +533,12 @@ watch(
<div class="card p-4 mb-4">
<div class="flex gap-1 mb-3">
<button
v-for="[k, l] in [['texto', '✍️ Escribir'], ['archivo', '📄 Subir archivo'], ['url', '🌐 Sitio web']]"
v-for="[k, ico, l] in [['texto', 'escribir', 'Escribir'], ['archivo', 'archivo', 'Subir archivo'], ['url', 'sitio', 'Sitio web']]"
:key="k"
class="px-3 py-1.5 rounded-lg text-sm"
class="px-3 py-1.5 rounded-lg text-sm inline-flex items-center gap-1.5"
:class="fuenteNueva === k ? 'bg-brand text-white font-medium' : 'text-tenue hover:text-texto'"
@click="fuenteNueva = k"
>{{ l }}</button>
><UiIcono :nombre="ico" :tam="15" /> {{ l }}</button>
</div>
<!-- Lo que el dueño sabe y no está escrito en ningún lado. -->
@@ -616,7 +617,7 @@ watch(
class="text-xs text-tenue hover:text-texto"
:title="d.auto_actualizar ? 'Dejar de releer sola' : 'Releer el sitio cada semana'"
@click="alternarAuto(d)"
>{{ d.auto_actualizar ? '🔁 auto' : 'manual' }}</button>
><UiIcono :nombre="d.auto_actualizar ? 'auto' : 'refrescar'" :tam="13" class="mr-1" />{{ d.auto_actualizar ? 'auto' : 'manual' }}</button>
<!-- Solo lo que tiene texto propio guardado: una URL se rehace
crawleando, editarla a mano se perdería en la próxima pasada. -->
<button
@@ -740,7 +741,7 @@ watch(
<label class="label flex items-center gap-1">
<input v-model="p.requerido" type="checkbox" /> req.
</label>
<button type="button" class="text-red-400 text-xs" @click="quitarParametro(i)"></button>
<button type="button" class="text-red-400 text-xs" @click="quitarParametro(i)"><UiIcono nombre="cerrar" :tam="13" /></button>
</div>
<p v-if="toolForm.parametros.length === 0" class="text-xs text-gray-400">Sin parámetros.</p>
</div>
@@ -788,7 +789,8 @@ watch(
:class="widgetCopiado ? 'bg-green-600 text-white' : 'bg-brand hover:bg-brand-dark text-white'"
@click="copiarWidget"
>
{{ widgetCopiado ? '✓ Copiado' : 'Copiar código' }}
<UiIcono v-if="widgetCopiado" nombre="ok" :tam="14" />
{{ widgetCopiado ? 'Copiado' : 'Copiar código' }}
</button>
</div>
<p class="label mb-2">
+2 -1
View File
@@ -3,6 +3,7 @@ import { ref, watch } from 'vue'
import { api } from '../lib/api.js'
import { apiUmind } from '../lib/contexto.js'
import UiEmptyState from '../components/ui/UiEmptyState.vue'
import UiIcono from '../components/ui/UiIcono.vue'
const props = defineProps({ id: { type: String, required: true } })
@@ -122,7 +123,7 @@ async function borrar(c) {
<div>
<div class="flex items-start justify-between gap-4 mb-6">
<div>
<router-link :to="`/tenants/${id}`" class="text-sm text-tenue hover:text-texto"> Agentes</router-link>
<router-link :to="`/tenants/${id}`" class="text-sm text-tenue hover:text-texto inline-flex items-center gap-1.5"><UiIcono nombre="atras" :tam="14" /> Agentes</router-link>
<h1 class="text-xl font-semibold text-texto mt-1">Tu cuenta de IA</h1>
<p class="text-sm text-tenue mt-1 max-w-xl">
Podés usar tu propia cuenta de OpenAI, Claude o Gemini en vez de la nuestra.
+1 -1
View File
@@ -68,7 +68,7 @@ onMounted(irAlAgenteSiEsUnoSolo)
</div>
<div v-else class="flex flex-col items-center justify-center text-center py-24">
<div class="text-4xl mb-4">💬</div>
<UiMascota estado="normal" :tam="64" class="mx-auto mb-4 text-brand" />
<h1 class="text-lg font-medium text-texto">
{{ contexto.esPortal ? 'Elegí tu espacio de la izquierda' : 'Elegí un espacio de la izquierda' }}
</h1>
+5 -4
View File
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import { api } from '../lib/api.js'
import { apiUmind, contexto } from '../lib/contexto.js'
import UiEmptyState from '../components/ui/UiEmptyState.vue'
import UiIcono from '../components/ui/UiIcono.vue'
const props = defineProps({ id: { type: String, required: true } })
const tenantId = computed(() => Number(props.id))
@@ -168,7 +169,7 @@ watch(() => props.id, cargar, { immediate: true })
<p class="text-xs text-tenue mt-1">{{ tenant.dominios_permitidos || 'sin dominios configurados' }}</p>
</div>
<router-link :to="`/tenants/${tenantId ?? id}/ia`" class="btn-ghost">Tu IA</router-link>
<router-link :to="`/tenants/${tenantId ?? id}/uso`" class="btn-ghost">📊 Consumo</router-link>
<router-link :to="`/tenants/${tenantId ?? id}/uso`" class="btn-ghost inline-flex items-center gap-1.5"><UiIcono nombre="grafico" /> Consumo</router-link>
</div>
</div>
@@ -243,13 +244,13 @@ watch(() => props.id, cargar, { immediate: true })
</div>
<div class="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button class="p-1 text-tenue hover:text-texto" title="Editar" @click.prevent.stop="editarAgente(a)"></button>
<button class="p-1 text-tenue hover:text-texto" title="Editar" @click.prevent.stop="editarAgente(a)"><UiIcono nombre="lapiz" :tam="14" /></button>
<button
class="p-1 text-tenue hover:text-texto"
title="Duplicar: copia el conocimiento y las herramientas a un agente nuevo"
@click.prevent.stop="duplicarAgente(a)"
></button>
<button class="p-1 text-tenue hover:text-red-600" title="Eliminar" @click.prevent.stop="eliminarAgente(a)"></button>
><UiIcono nombre="copiar" :tam="14" /></button>
<button class="p-1 text-tenue hover:text-red-600" title="Eliminar" @click.prevent.stop="eliminarAgente(a)"><UiIcono nombre="cerrar" :tam="14" /></button>
</div>
</div>
+8 -7
View File
@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue'
import { api } from '../lib/api.js'
import { apiUmind } from '../lib/contexto.js'
import UiEmptyState from '../components/ui/UiEmptyState.vue'
import UiIcono from '../components/ui/UiIcono.vue'
const props = defineProps({ id: { type: String, required: true } })
@@ -16,11 +17,11 @@ const desde = ref(new Date(hoy.getFullYear(), hoy.getMonth(), 1).toISOString().s
const hasta = ref(hoy.toISOString().slice(0, 10))
const ETIQUETAS = {
ia: { nombre: 'Inteligencia artificial', icono: '🧠', color: '#8eb02f' },
ocr: { nombre: 'Lectura de imágenes', icono: '🖼️', color: '#2f7fb0' },
whisper: { nombre: 'Transcripción de audio', icono: '🎙️', color: '#b0752f' },
ia: { nombre: 'Inteligencia artificial', icono: 'cerebro', color: '#8eb02f' },
ocr: { nombre: 'Lectura de imágenes', icono: 'imagen', color: '#2f7fb0' },
whisper: { nombre: 'Transcripción de audio', icono: 'microfono', color: '#b0752f' },
}
const etiqueta = (t) => ETIQUETAS[t] || { nombre: t, icono: '', color: '#94a3b8' }
const etiqueta = (t) => ETIQUETAS[t] || { nombre: t, icono: 'grafico', color: '#94a3b8' }
async function cargar() {
cargando.value = true
@@ -88,7 +89,7 @@ watch(() => props.id, cargar, { immediate: true })
<h1 class="text-xl font-semibold text-texto">Consumo</h1>
<p class="text-sm text-tenue mt-0.5">Lo que usaron tus agentes y cuánto cuesta.</p>
</div>
<router-link :to="`/tenants/${id}`" class="btn-ghost"> Agentes</router-link>
<router-link :to="`/tenants/${id}`" class="btn-ghost inline-flex items-center gap-1.5"><UiIcono nombre="atras" :tam="15" /> Agentes</router-link>
</div>
<!-- Filtros -->
@@ -162,12 +163,12 @@ watch(() => props.id, cargar, { immediate: true })
</div>
<UiEmptyState
v-if="resumen.length === 0"
icono="📊"
estado="durmiendo"
titulo="Sin consumo en este período"
detalle="Cuando tus agentes respondan mensajes, lean imágenes o transcriban audios, vas a verlo acá."
/>
<div v-for="r in resumen" :key="r.tipo" class="px-4 py-3 flex items-center gap-3">
<span class="text-lg">{{ etiqueta(r.tipo).icono }}</span>
<UiIcono :nombre="etiqueta(r.tipo).icono" :tam="18" class="text-tenue" />
<div class="min-w-0 flex-1">
<p class="text-sm text-texto">{{ etiqueta(r.tipo).nombre }}</p>
<p class="text-xs text-tenue tabular-nums">