Un botón primario, display: flex; align-items: center, con la etiqueta
REGISTRARSE en las mayúsculas fijas del sistema de diseño y sin una sola
línea de CSS que huela a truco. Y aun así el texto no cae en el centro del
botón: se ve un par de píxeles por debajo. El primer sospechoso que revisa
cualquiera es el line-height, y ahí está el error de diagnóstico que me
costó una tarde: align-items: center no centra la tinta del texto, centra
la caja de línea, y la caja de línea de una fuente no es simétrica respecto
al texto que dibuja.
Por qué el centrado por flexbox no ve lo mismo que el ojo
La caja de línea de un texto sale de tres valores que trae la fuente en sus
tablas hhea y OS/2: el ascent, la distancia reservada por encima de la
línea base, el descent, la distancia reservada por debajo, y el line gap,
el margen extra entre líneas consecutivas. Con line-height: normal, el
navegador suma los tres y ese es el alto que align-items: center reparte en
partes iguales arriba y abajo.
El problema es que esos tres valores están pensados para la fuente completa,
no para el subconjunto de glifos que aparece en un componente concreto. Una
familia que tiene que cubrir mayúsculas acentuadas como Á reserva un
ascent que sube más que cualquier mayúscula sin tilde. Medí la tinta real de
REGISTRARSE con canvas.measureText() y no supera el 71% del em, frente
al ascent que la fuente reserva para cubrir el resto del alfabeto. El
navegador cuenta ese margen de sobra al centrar la caja igual que cuenta
cualquier otro, así que el hueco vacío de arriba empuja la tinta hacia
abajo. El desajuste no es un bug de la fuente ni del componente: son dos
cosas distintas, la caja tipográfica reservada y la tinta realmente
dibujada, y nada garantiza que compartan centro.
Los dos descriptores que corrigen la caja sin tocar la tinta
ascent-override y descent-override son descriptores de @font-face,
igual que size-adjust: solo existen dentro de una cara con src que
resuelva, y cada uno sustituye el valor que la fuente trae en sus tablas
métricas por el porcentaje que se declare, calculado sobre el tamaño del
em. Lo que no tocan es la geometría del glifo. Un carácter dibujado ocupa
exactamente los mismos contornos, en el mismo sitio respecto a la línea base,
antes y después de aplicar cualquiera de los dos. Lo único que cambia es
cuánto espacio reserva el navegador por encima y por debajo de esa línea base
para construir la caja de línea, que es justo el valor que usan
line-height: normal, vertical-align y el centrado por flexbox o grid.
@font-face {
font-family: 'p4-typo-font-family-cta';
src: local('Quicksand');
ascent-override: 72%;
descent-override: 4%;
line-gap-override: 0%;
}
Redeclaro la familia de marca bajo un nombre distinto, solo para la
etiqueta del botón, y reparto el 100% del em de otra manera: menos
reserva arriba, la mínima indispensable abajo. Los tres valores salen de
medir la tinta real del texto con canvas.measureText(), no de una
proporción a ojo: es el mismo método que detallo más abajo, aplicado antes
de dar cualquier cifra por buena.
El token de tamaño no se entera: font-size sigue en el valor que traía el
componente y ningún rem cambia de cómputo. El de interlineado sí se
entera, y de una manera que la primera vez me dio un resultado idéntico al
de partida y me hizo dudar de si el override estaba haciendo algo: si el
botón fijaba line-height: 1 o cualquier valor propio en píxeles, el texto
no se movía ni un pixel al aplicar ascent-override y descent-override,
exactamente el mismo sitio que sin ellos. La razón es
que los dos descriptores sustituyen los valores que consulta el cómputo de
line-height: normal; un line-height fijado a mano no vuelve a consultar
esos valores, así que el override queda sin ningún efecto aunque el
navegador lo haya aceptado sin error. La consecuencia de arquitectura es
que el componente que reciba esta cara tiene que dejar line-height: normal, y cualquier espaciado adicional que antes resolviera un
line-height fijo pasa al padding del contenedor, no a la línea.
El límite: lo que se sale de la caja nueva, se recorta
La reserva de espacio no es decorativa, es la que protege la tinta que se
sale del rango habitual. Calibré el ascent-override al 72% mirando solo
REGISTRARSE, sin una sola tilde. El mismo canvas.measureText() sobre
CONFIRMACIÓN o ENVÍA da un ascent real del 90% del em: la vocal
acentuada sube diecinueve puntos porcentuales más que cualquier letra de
REGISTRARSE. Si esa misma cara se reutiliza sin revisar en un botón
secundario cuya etiqueta lleve tilde, la Í o la Ó se dibujan por encima
del límite que la fuente declara ahora. El glifo se sigue dibujando completo
(el motor de render no descarta contornos por esto) pero cualquier
contenedor que calcule su alto a partir de esa caja, o que lleve
overflow: hidden ajustado a ella, corta la parte de tinta que sobresale.
En un catálogo de botones en español, con tildes en la mitad de los verbos
de acción (ENVÍA, CONFIRMACIÓN, SÍ), el riesgo no es una excepción
rara, es la norma.
La consecuencia de arquitectura es que esta cara no es la familia base del
sistema, es una variante de un solo uso: la reservo para el catálogo de
etiquetas de botón que verifiqué sin tilde y la excluyo de cualquier lugar
donde pueda aparecer una mayúscula acentuada. La alternativa que descarté
fue aplicar el mismo ascent-override/descent-override a la familia de
marca completa, con un valor intermedio que no recortara ni las mayúsculas
acentuadas ni el resto de componentes: la probé y el centrado volvía a
quedar a medias, porque el valor que centra REGISTRARSE no es el que
centra CONFIRMACIÓN. Pagar una cara @font-face adicional, con su propio
nombre y su propio alcance de unicode-range si hiciera falta acotarlo,
sale más barato que un centrado aproximado en todo el sistema.
Cómo verifico que no hay recorte antes de dar el valor por bueno
No hay atajo fiable a ojo, porque un recorte de un par de píxeles en una
tilde pasa desapercibido en una revisión visual rápida y aparece tres
sprints después en un informe de un usuario que vio ENVA donde debía decir
ENVÍA. La comprobación que uso es sencilla y repetible: renderizar, en el
mismo componente y con la misma cara, la etiqueta más exigente del catálogo
de botones aprobado (la que lleve la vocal acentuada más alta, no la más
frecuente) y comparar getBoundingClientRect() del contenedor con el alto
real de tinta que reporta un canvas mediante actualBoundingBoxAscent y
actualBoundingBoxDescent. Si la tinta medida excede la caja del
contenedor en cualquiera de los dos sentidos, el valor de override está
demasiado ajustado y hay que devolverle margen, aunque eso separe un poco
más el centrado óptico del centrado perfecto.
Cuándo no merece la pena tocar estos descriptores
Si el botón admite cualquier texto que escriba un equipo de producto, no un
catálogo de etiquetas aprobado por contenido, el coste de mantener una cara
aparte y de volver a medir cada vez que aparece una etiqueta nueva supera lo
que se gana en centrado. Ahí la corrección razonable sigue siendo un ajuste
de padding-block asimétrico sobre el propio componente, que no toca
ninguna cara de fuente y que un diseñador puede volver a calibrar sin tocar
CSS de @font-face. ascent-override y descent-override valen su coste
cuando el conjunto de etiquetas está cerrado y se puede verificar por
completo, como el de un sistema de botones con contenido gobernado, no como
corrección genérica de cualquier desajuste vertical.