Pontificia Universidad Católica del Ecuador · 2026

Bolsillo

Tu dinero, ordenado sin esfuerzo.

RDA 3 — Codificación de Diseño UI de Figma

Autor

Pablo Revilla

Infraestructura

Mashacorp

En producción

bolsillo.mashaec.net

Marco del problema

El abandono no es falta de interés. Es costo de uso.

52,5%

No registra los gastos cotidianos

68%

Rechaza la terminología financiera

41,8%

Abandona su método por resultar tedioso

El diseño tenía un objetivo medible: reducir el costo de registrar un movimiento.

Usuario objetivo

Diego Morales

26 años · Quito · USD 800 mensuales
Método actual: registro manual en cuaderno.

«Sé que gasto más de lo que debería, pero al cierre del mes no puedo identificar en qué se destinó el dinero.»

Restricción de diseño derivada de la investigación

Si no comprende la interfaz en dos o tres minutos, abandona el sitio.

Método de validación

Evaluación heurística de Nielsen sobre el producto terminado.

18/40

Portada

17/40

Panel de control

0

Hallazgos del análisis estático de código sobre el mismo proyecto

Un segundo detector sobre el DOM sí encontró 19 anti-patrones, pero ninguno de los defectos de uso. Son cosas distintas.

Heurística 6 · Reconocimiento antes que recuerdo

La diferenciación dependía de un estado que en táctil no existe.

src/piezas/FilaMovimiento.tsx// Antes: la acción destructiva solo se distinguía al pasar el puntero
"text-texto-3 hover:text-gasto"

// Ahora: color y fondo propios en reposo, sin interacción previa
"text-gasto bg-gasto-sutil"

Verificación

Una prueba automatizada comprueba la distinción sin simular puntero.

Consecuencia

En un teléfono, eliminar y editar eran visualmente idénticos hasta el momento de pulsar.

El 100 % de las sesiones móviles quedaba sin esa señal.

Heurística 5 · Prevención de errores

Un defecto de formato alteraba el monto en dos órdenes de magnitud.

src/utils/moneda.ts// El backend responde "120.50"; el formato local usa "1.248,50".
// Si hay coma, la coma es decimal y los puntos son miles.
const normalizado = crudo.includes(',')
  ? crudo.replace(/\./g, '').replace(',', '.')
  : crudo   // ← sin esta rama, "120.50" se convertía en 12050

Impacto

Un consumo de USD 10,00 quedaba registrado como USD 1.000,00.

Control implantado

Caso cubierto en la suite: aNumero('12.40') === 12.4

Del archivo de Figma al código

Un único origen para los tokens del sistema.

src/index.css@theme {
  /* Escala extraída de la fotografía de portada */
  --color-lavanda-900: #553d78;
  --color-borde-fuerte: #cbd5e1;  /* 3:1, WCAG 1.4.11 */

  /* 20px y no 14: `grande` y `extra` valían lo mismo
     y el sistema prometía una distinción inexistente */
  --radius-extra: 20px;
}

Alcance

65 tokens de color, más tipografía, radios y curvas, declarados en un solo archivo.

Nomenclatura en español, igual que en el archivo de diseño.

Corregido en la auditoría

Faltaban 13 tokens semánticos. Sin ellos era posible escribir una clase inexistente sin que nada fallara.

Heurística 8 · Diseño estético y minimalista

El fondo como material, no como ornamento.

Versión anterior

Cinco elementos de 500 px con blur(190px).

Estética genérica de aplicación de bienestar y una de las operaciones más costosas para la GPU.

Versión actual

Guilloché sobre retícula de libro contable, trazado en SVG con elipses concéntricas.

Es el grabado de los títulos valores: existe porque resulta difícil de falsificar.

src/layout/landing/Fondo.tsx// El degradado se detiene donde el texto secundario aún cumple AA.
// Medido en el navegador: 5,05:1 en el punto más oscuro.
'linear-gradient(175deg, #f7f9fb 0%, … 80%, #c9d4e2 100%)'

Diseño responsivo · Los tres tipos de pantalla

La adaptación no es de escala: es de producto.

Portada a 1440 píxeles

1440 · escritorio

Portada a 820 píxeles

820 · tableta

Portada a 390 píxeles

390 · celular

En móvil se retira la fotografía, entran registros reales y aparece una navegación inferior inexistente en escritorio.

Diseño responsivo · Implementación

Los puntos de corte se declaran una vez y se comparten.

src/pantalla/useTipoPantalla.ts// Espejo de los breakpoints de Tailwind (md = 48rem, lg = 64rem).
// La desincronización aquí no se paga como en una media query suelta:
// esto no decide cómo se ve algo, decide QUÉ se renderiza.
const CONSULTAS = {
  celular: '(max-width: 47.999rem)',
  tableta: '(min-width: 48rem) and (max-width: 63.999rem)',
}

En el CSS compilado

Consultas de medios reales en 40, 48, 64 y 80 rem, más hover y prefers-reduced-motion.

Sin parpadeo inicial

La lectura ocurre en el primer render con useSyncExternalStore: nunca hay un fotograma con la versión equivocada.

Diseño responsivo · Decisión de arquitectura

En móvil no se entrega la misma interfaz reordenada.

src/app/App.tsxif (pantalla === 'celular') {
  return (
    <Suspense fallback={null}>
      <AppCelular />   // pantallas propias, navegación inferior
    </Suspense>
  )
}

La tableta conserva la versión de escritorio, que a ese ancho ya resuelve. La bifurcación se aplica solo donde cambia la expectativa de uso.

Accesibilidad implementada

Dos decisiones que solo existen en el código.

src/ui/Boton.tsx// focus-visible y no focus, para que el anillo
// no quede fijado tras un clic con el ratón
'focus-visible:outline-2
 focus-visible:outline-offset-2
 focus-visible:outline-tinta-900'
src/movimiento/Revelar.tsxconst menosMovimiento = useReducedMotion()

if (menosMovimiento) {
  // ya visible: la visibilidad nunca queda
  // condicionada a que una animación corra
  return <div>{children}</div>
}

Además: 5,05:1 de contraste sobre el fondo, 3:1 en controles y áreas táctiles de 44 px en 14 componentes.

Trazabilidad con la investigación

Desviaciones respecto al RDA1 y su justificación.

Definido en la investigaciónImplementadoJustificación
Registro e inicio de sesión en páginas independientes Pantalla única con panel deslizante Heurística 3: corregir la elección no debe exigir abandonar la pantalla
Persistencia en el navegador API propia sobre PostgreSQL El almacenamiento local no ofrece garantías sobre información financiera
Alerta de saldo con umbral configurable Clasificación automática calculada en el servidor Requerir el umbral traslada al usuario la decisión que no está en condiciones de tomar

Estado actual · Hallazgos no resueltos

Lo que la auditoría dejó documentado y sigue pendiente.

Cada punto está localizado en un archivo concreto y medido. No es una lista de intenciones.

Conclusión

El diseño se sostiene en evidencia, no en preferencia.

Gracias.

A0

Sobre la evaluación

Qué se midió, con qué escala y por qué la puntuación es baja.

Concepto · Anti-patrón

Un anti-patrón es una solución habitual que produce un defecto conocido.

El código funciona: no es un error de sintaxis. Es una decisión tan repetida que ya está catalogada, con su consecuencia documentada. Por eso una herramienta puede detectarlos.

Anti-patrón detectadoDóndePor qué está catalogado
dark-glowBoton.tsx:19Sombra de color saturado bajo un botón: simula profundidad que la interfaz no tiene
line-lengthSeccionQueEs.tsx:6087 caracteres por línea. Por encima de ~75 el ojo pierde el renglón al volver
undersized-ui-textGraficoMesAMes ×10Texto de 10 px en las etiquetas del gráfico

De los 19 detectados, uno resultó falso positivo verificado y otro quedó suprimido: la tipografía «repetida» proviene del UI Kit del proyecto.

Método · Las dos mediciones

Miden cosas distintas, y por eso se aplicaron las dos.

Detección de anti-patronesEvaluación heurística
Qué evalúaLa forma del resultado renderizadoSi la persona puede operar el sistema
CómoReglas automáticas sobre el DOM y el códigoRecorrido de tareas contra los 10 principios de Nielsen
Resultado0 en el código · 19 en el DOM18/40 en portada · 17/40 en panel
Qué no vioEl hover inexistente en táctil · el monto multiplicado por cienLongitudes de línea, tamaños de fuente

Ninguna regla mecánica puede detectar que una señal desaparece en un dispositivo sin puntero: eso exige recorrer la tarea.

Escala · Cómo se obtiene un 18 sobre 40

Diez heurísticas, cuatro puntos cada una.

#HeurísticaPuntos
1Visibilidad del estado2
2Mundo real1
3Control y libertad3
4Consistencia3
5Prevención de errores1
6Reconocer antes que recordar1
7Flexibilidad2
8Estético y minimalista2
9Recuperación de errores2
10Ayuda y documentación1

La escala, por heurística

4 — cumple sin reservas
3 — cumple con un defecto menor
2 — cumple a medias
1 — incumple con impacto en la tarea
0 — no aplica o falla por completo

Los cuatro «1» y su causa

El logotipo ilegible, «Nosotros» apuntando a otra sección, una promesa que el registro incumplía y la explicación del producto 700 px más abajo.

Interpretación · Por qué la puntuación es baja

Es una medición de diagnóstico, no una calificación de entrega.

Qué se midió

La portada antes de corregirla, el 26 de agosto.

El criterio es exigente a propósito: un 2 significa «cumple a medias». Con una escala benévola, la evaluación no habría producido ninguna corrección.

Qué se hizo con el resultado

Las láminas 5 a 12 de esta exposición son, una por una, correcciones derivadas de esos hallazgos.

La portada se rehízo a dos columnas, los botones se diferenciaron y las acciones destructivas pasaron a distinguirse en reposo.

La misma evaluación registró tres fortalezas: el fondo como activo de marca, la disciplina de tokens y una rejilla que crece con el texto en vez de recortarlo.

Responsive · El mecanismo completo

Cómo se detecta el tamaño y quién decide qué se renderiza.

src/pantalla/useTipoPantalla.ts// Espejo de los breakpoints de Tailwind (md = 48rem, lg = 64rem).
const CONSULTAS = {
  celular: '(max-width: 47.999rem)',
  tableta: '(min-width: 48rem) and (max-width: 63.999rem)',
}

// Se llama en el PRIMER render: no hay un fotograma con la versión
// equivocada antes de que un efecto lo corrija.
function leer(): TipoPantalla {
  if (window.matchMedia(CONSULTAS.celular).matches) return 'celular'
  if (window.matchMedia(CONSULTAS.tableta).matches) return 'tableta'
  return 'escritorio'
}

// Reacciona al rotar el dispositivo y al redimensionar la ventana.
export function useTipoPantalla(): TipoPantalla {
  return useSyncExternalStore(suscribir, leer, () => 'escritorio')
}

Y en App.tsx, el valor decide: por debajo de 768 px se monta <AppCelular />, un árbol de componentes distinto.

Responsive · Por qué no basta con una media query

Una consulta de medios cambia estilos. Esto cambia el componente.

Qué hace cada mecanismo

CSS: el mismo elemento se ve distinto.

JavaScript: se monta otro elemento. En móvil, la barra de navegación inferior no existe en el DOM de escritorio; no está oculta.

Por eso los valores se declaran una sola vez

Si el CSS dijera 768 px y el JavaScript 800, entre esos dos anchos se aplicarían los estilos de móvil al componente de escritorio.

No se vería como un estilo extraño: se vería como el componente equivocado.

El coste de mantener el espejo es un comentario en cada archivo. El coste de no mantenerlo es un fallo que solo aparece en una franja de 32 píxeles.

Resultado · Estado de los hallazgos

Qué pasó con cada uno, verificado en producción.

Hallazgo de la evaluaciónHeur.Estado hoy
La portada no mostraba el producto8Corregido — dos columnas con movimientos reales
La llamada a la acción se confundía con «Ingresar»7Corregido — variante propia y sombra en tinta
Faltaba escala tipográfica de despliegue4Corregido — tokens de 40 y 64 px
«Estás a un clic» prometía lo que el registro incumplía5Corregido — texto eliminado
Acciones destructivas indistinguibles sin puntero6Corregido — y con prueba automatizada
El logotipo se lee «olsillo»6Abierto
«Nosotros» conduce a «Cómo funciona»2Abierto

Una reevaluación daría una puntuación más alta. No la presento como cifra porque no he vuelto a ejecutar el instrumento completo: lo que presento es el estado de cada hallazgo.

A1

Programación orientada a objetos

Material de respaldo. No forma parte de la exposición.

Problema

Cada ruta traducía sus propios errores.

try:
    servicio.crear_cuenta(...)
except NombreRepetido:
    raise HTTPException(status_code=409, detail="nombre repetido")
except NoEncontrado:
    raise HTTPException(status_code=404, detail="no existe")
# ...y lo mismo en las otras 56 rutas, cada una con su redacción

El mismo error con distinto formato según el punto de entrada. El cliente no puede confiar en la respuesta.

Herencia

Una clase base que ya conoce su código HTTP.

class DomainError(Exception):
    code = "INTERNAL_ERROR"
    status = 500

class NotFound(DomainError):
    code = "NOT_FOUND"
    status = 404

14 clases de error heredan de una. Cada una porta su propio código y su propio estado.

Polimorfismo

Un único manejador atiende a las catorce.

async def domain_handler(request, exc: DomainError):
    return JSONResponse(
        status_code=exc.status,      # lo aporta la excepción
        content=_cuerpo(exc.code, str(exc)),
    )

El router no captura: lanza y delega. Incorporar un error nuevo no exige modificar ninguna ruta.

Encapsulamiento e inyección de dependencias

El servicio decide; no consulta.

class AccountService:
    def __init__(self, repo: AccountRepository, movimientos: TransactionRepository):
        self._repo = repo               # recibido, nunca construido aquí

    def create_account(self, user_id, datos):
        if self._repo.get_by_name(user_id, datos.name):
            raise AccountNameTaken("ya tienes una cuenta con ese nombre")

Sin una línea de SQL. Y como el repositorio se inyecta, la regla se verifica en pruebas sin base de datos.

Arquitectura y estado pendiente

Cinco capas; cada una habla solo con la inferior.

routers/Reciben HTTP, verifican permisos y delegan. No consultan la base
services/Reglas de negocio. Aquí se decide
repositories/Consultas

Pendiente identificado

Falta pool_pre_ping=True en el motor: tras un corte de conexiones inactivas, el primer acceso devuelve 500 y el segundo funciona. Reproducido con psycopg.errors.AdminShutdown.

A2

Base de datos

Material de respaldo. No forma parte de la exposición.

Modelo

El saldo no se almacena: se deriva.

Con columna de saldo

Cada movimiento obliga a actualizarla. Ante un fallo parcial coexisten dos verdades sin criterio para decidir cuál es válida.

Con vista

account_balances calcula el saldo desde los movimientos vigentes en cada consulta. No hay estado que sincronizar.

Restricciones · La más relevante

Una transferencia sin destino elimina dinero del patrimonio.

CONSTRAINT ck_tx_transfer_shape CHECK (
    type <> 'transfer' OR (
        counter_account_id IS NOT NULL      -- tiene destino
        AND category_id IS NULL              -- no es un gasto
        AND account_id <> counter_account_id
    )
);

Sin esta restricción, la operación descuenta de una cuenta sin acreditar en ninguna.

Restricciones · Límite del CHECK

Un CHECK solo evalúa su propia fila.

La regla «un egreso no puede clasificarse con una categoría de ingreso» exige consultar otra tabla, y eso un CHECK no puede hacerlo.

Solución: trigger

Antes de insertar, recupera el tipo de la categoría y lo contrasta con el tipo del movimiento. Si no coinciden, rechaza la operación.

Un segundo trigger limita el árbol de categorías a dos niveles e impone la herencia del tipo.

Restricciones · Unicidad con nulos

En SQL, NULL no equivale a NULL.

Un UNIQUE convencional sobre columnas que admiten nulos permite duplicados: dos categorías del sistema homónimas no colisionan porque sus identificadores de usuario son nulos.

CREATE UNIQUE INDEX uq_categories_root ON categories (
    COALESCE(user_id, '00000000-0000-0000-0000-000000000000'::uuid),
    lower(name), kind
) WHERE parent_id IS NULL;

Modelo relacional

No existen tablas pivote, y responde a una decisión.

CasoSolución adoptadaFundamento
Una transferencia afecta a dos cuentas Dos claves foráneas a la misma tabla en la misma fila Un registro representa un hecho. Con tabla intermedia serían dos filas a conciliar
Jerarquía de categorías Auto-referencia mediante parent_id El árbol tiene dos niveles por trigger; un nivel de expansión cubre todos los casos

No hay ninguna relación de muchos a muchos en el modelo.

Índices y tipos

Índice compuesto y parcial sobre la consulta más frecuente.

CREATE INDEX ix_tx_user_date ON transactions (user_id, occurred_at DESC)
    WHERE deleted_at IS NULL;

Compuesto y parcial

Entrega las filas ya ordenadas y excluye del índice las eliminadas lógicamente.

Tipos

Importes en numeric(14,2), nunca coma flotante: el redondeo binario produce descuadres de centavos.

Búsqueda de texto

Trigramas sobre índice invertido.

Con comodín inicial, un índice B-tree es inservible, y la acentuación impide la coincidencia literal.

Buscando «maria» en «maría guerrero maria.guerrero@ejemplo.com»Similitud
similarity — contra la cadena completa0,222 · no supera
word_similarity — contra la palabra más próxima1,000 · supera

Umbral fijado en 0,6: admite erratas en palabras largas y descarta coincidencias espurias en las cortas.

1 / 15 UI / UX
← → · F pantalla completa · A anexo