Ratios de contraste WCAG

El contraste entre el color del texto y el fondo determina si el texto es legible para personas con baja visión o daltonismo. WCAG 2.1 define dos niveles:

# WCAG AA (mínimo recomendado):
# → Texto normal (< 18pt o < 14pt bold): ratio mínimo 4.5:1
# → Texto grande (≥ 18pt o ≥ 14pt bold): ratio mínimo 3:1
# → Componentes UI e íconos informativos: ratio mínimo 3:1

# WCAG AAA (máximo accesible):
# → Texto normal: ratio mínimo 7:1
# → Texto grande: ratio mínimo 4.5:1

# Ejemplos de ratios:
# Negro (#000000) sobre blanco (#FFFFFF): 21:1 → excelente
# Gris claro (#999999) sobre blanco (#FFFFFF): 2.85:1 → FALLA AA
# Material Blue (#1976D2) sobre blanco (#FFFFFF): 4.78:1 → pasa AA para texto normal
# Gris placeholder (#AAAAAA) sobre blanco (#FFFFFF): 2.32:1 → FALLA

# Herramienta online para verificar:
# webaim.org/resources/contrastchecker/
# También: Android Studio → Layout Inspector → Accessibility

# Material3 usa colores de sistema que pasan AA automáticamente
# El problema aparece con colores custom, placeholders y texto deshabilitado
// En Compose — verificar contraste en código:
// No hay una API para calcularlo en runtime, pero el Accessibility Scanner lo detecta

// Para texto de placeholder/hint — Material3 ya lo maneja:
OutlinedTextField(
    value = valor,
    onValueChange = { valor = it },
    label = { Text("Email") },  // el label tiene contraste correcto automáticamente
    placeholder = { Text("[email protected]") }  // placeholder con contraste correcto
)

// ❌ Color de texto con contraste insuficiente:
Text(
    text = "Texto secundario",
    color = Color(0xFFBBBBBB)  // #BBBBBB sobre blanco = 1.76:1 → falla
)

// ✓ Usar colores del tema que garantizan contraste:
Text(
    text = "Texto secundario",
    color = MaterialTheme.colorScheme.onSurfaceVariant
    // onSurfaceVariant está diseñado para tener contraste correcto sobre surfaceVariant
)

Touch targets — 48dp mínimo

Los elementos interactivos deben tener al menos 48x48dp de área táctil, aunque visualmente sean más pequeños. Esto aplica a botones, checkboxes, íconos clickables, y cualquier área que el usuario deba tocar.

// ❌ Ícono de 24dp sin área táctil extra — difícil de tocar con precisión
Icon(
    imageVector = Icons.Default.Close,
    contentDescription = "Cerrar",
    modifier = Modifier
        .size(24.dp)
        .clickable { onCerrar() }
    // Área táctil: 24x24dp — falla el criterio de 48dp
)

// ✓ Opción 1: usar IconButton que ya tiene 48x48dp de área táctil
IconButton(onClick = onCerrar) {
    Icon(Icons.Default.Close, contentDescription = "Cerrar")
}

// ✓ Opción 2: agrandar el área táctil con minimumInteractiveComponentSize
Icon(
    imageVector = Icons.Default.Close,
    contentDescription = "Cerrar",
    modifier = Modifier
        .size(24.dp)
        .minimumInteractiveComponentSize()  // garantiza 48x48dp táctil
        .clickable { onCerrar() }
)

// ✓ Opción 3: padding para expandir el área táctil
Icon(
    imageVector = Icons.Default.Close,
    contentDescription = "Cerrar",
    modifier = Modifier
        .clickable { onCerrar() }
        .padding(12.dp)  // 24dp visual + 12dp padding en cada lado = 48dp táctil
        .size(24.dp)
)

Texto con sp — siempre para tamaños de fuente

// ❌ Tamaño de texto en dp — no respeta las preferencias del usuario
Text(
    text = "Título",
    fontSize = 20.dp.value.sp  // MAL — dp no escala con accesibilidad
)
// O en XML: android:textSize="20dp"

// ✓ Tamaño de texto en sp — respeta el escalado del sistema
Text(
    text = "Título",
    fontSize = 20.sp  // BIEN — escala cuando el usuario activa texto grande
)
// O en XML: android:textSize="20sp"

// Verificar que nada se corta cuando el usuario pone "Texto más grande" en
// Configuración → Accesibilidad → Tamaño del texto
// Con escala de 1.3x a 1.5x, todos los textos deben seguir siendo legibles

Respetar texto grande del sistema en Compose

// El problema: layouts que cortan texto cuando el usuario agrandó la fuente del sistema
// El Box con height fijo es el culpable más frecuente:

// ❌ Altura fija que corta texto grande
Box(modifier = Modifier.height(48.dp)) {
    Text("Este texto puede ser más largo con accesibilidad activada")
    // Con escala de fuente 1.5x, el texto se corta
}

// ✓ Altura mínima en lugar de fija
Box(modifier = Modifier.heightIn(min = 48.dp)) {
    Text("Este texto puede crecer verticalmente si es necesario")
}

// ✓ wrapContentHeight para contenedores de texto
Card(modifier = Modifier.wrapContentHeight()) {
    Text(
        text = descripcion,
        modifier = Modifier.padding(16.dp)
    )
}

// Para verificar en el emulador:
// adb shell settings put system font_scale 1.3  (escala al 130%)
// adb shell settings put system font_scale 1.0  (volver al default)

// Configuración → Accesibilidad → Tamaño del texto → Muy grande

reduceMotion — respetar preferencias de movimiento

Algunas personas con vestibular disorders o epilepsia fotosensible pueden verse afectadas por animaciones intensas. Android tiene una configuración de "Reducir animaciones" que las apps deben respetar:

// Verificar si el usuario activó "Quitar animaciones" o "Reducir movimiento"
@Composable
fun animacionRespetandoPreferencias(): AnimationSpec<Float> {
    val reducirMovimiento = LocalAccessibilityManager.current
        ?.isReduceMotionEnabled ?: false

    return if (reducirMovimiento) {
        // Sin animación — cambio instantáneo
        snap()
    } else {
        // Animación normal
        spring(dampingRatio = Spring.DampingRatioMediumBouncy)
    }
}

// Para AnimatedVisibility — respetar reduceMotion:
@Composable
fun ContenidoConEntrada(visible: Boolean, content: @Composable () -> Unit) {
    val reducirMovimiento = LocalAccessibilityManager.current
        ?.isReduceMotionEnabled ?: false

    AnimatedVisibility(
        visible = visible,
        enter = if (reducirMovimiento) EnterTransition.None else fadeIn() + slideInVertically(),
        exit = if (reducirMovimiento) ExitTransition.None else fadeOut() + slideOutVertically()
    ) {
        content()
    }
}

// En Views (sin Compose):
// Verificar el valor de "Animator Duration Scale" en Configuración del desarrollador
// Si está en 0x, las animaciones deben ser instantáneas

Dark mode y accesibilidad

// Dark mode no es solo estética — para muchas personas con fotosensibilidad
// o migrañas, la pantalla oscura es una necesidad de accesibilidad

// Material3 maneja dark mode automáticamente con el sistema de colores dinámicos
// Los contrastes WCAG se deben verificar en AMBOS temas:

// Verificar contraste en dark mode:
// → "onSurface" sobre "surface" en dark theme
// → "onPrimary" sobre "primary" en dark theme

// Error común: hardcodear un color que es visible en light pero no en dark:
Text(
    text = "Texto",
    color = Color.Gray  // puede tener contraste insuficiente en dark mode
)

// ✓ Usar siempre colores del tema:
Text(
    text = "Texto",
    color = MaterialTheme.colorScheme.onBackground  // contraste correcto en ambos temas
)