Arquitectura · sistema de diseño p4
Un sistema no se valida estilo a estilo, porque el estilo que rompe todavía no está escrito. Se define una invariante que ningún estilo pueda violar, se hace que las fuentes la abaraten y se comprueba sola.
Todo el problema del texto cortado se reduce a un número por estilo: el cociente entre el interlineado y el tamaño. Ni los píxeles ni el idioma entran en la condición.
Contrato
line-height ÷ font-size ≥ R
R es una constante del producto. Todo lo demás que hay en este documento existe para una sola cosa: bajar R hasta un valor que la escala tipográfica pueda cumplir sin deformarse.
La consecuencia inmediata es que multiplicar por idioma no arregla nada. Multiplicar
font-size y line-height por el mismo factor es una semejanza:
conserva el cociente. Un badge de 12/12 sigue en 1,00 después de multiplicarlo por 1,4, y
se sigue cortando. El multiplicador cambia el tamaño aparente, no el contrato.
La caja de línea reparte su altura entre lo que queda encima y lo que queda debajo de la
baseline, y ese reparto lo decide la asimetría de la cara que posee la línea. Con
Δ = ascent − descent de esa cara:
R_script = max( 2·bboxAsc − Δ , 2·bboxDesc + Δ )
Si Δ se deja al azar, R sube. Si Δ se declara a medida, R baja hasta su mínimo teórico, que es la altura de la tinta. Ese es todo el margen de maniobra que hay, y la capa 2 lo gasta entero.
Qué le hace cada mecanismo a R, en árabe con Noto Sans Arabic sobre Chromium 148:
| Mecanismo | Efecto sobre R | R resultante | ¿Toca el latino incrustado? |
|---|---|---|---|
| Fallback del sistema, sin nada | — | indeterminado | no |
size-adjust: 140% · lo de hoy | multiplica la tinta por 1,4 | 1,86 | no |
Multiplicador [lang] sin compensar | ninguno, conserva el cociente | 1,53 | sí, lo agranda ×1,4 |
Multiplicador + latino a 1/M | saca el 1,4 de la tinta | 1,33 | no |
+ overrides y U+0020 | coloca la baseline en su óptimo | 1,32 | no |
Medido con «الموديل ترتدي مقاس 34 وطولها 180 سم.» sobre Noto Sans Arabic.
Conviene no exagerar la última fila: en árabe los overrides bajan R de 1,33 a 1,32, porque la cara latina compensada ya cae casi en el reparto óptimo. Donde sí pagan es en los scripts de tinta desequilibrada. En tailandés, con Noto Sans Thai, R baja de 1,38 a 1,22. La capa 1 se justifica por el tailandés y el devanagari, no por el árabe.
size-adjust actúa por glifo: escala todo lo que dibuje esa cara, siempre.
ascent-override y descent-override actúan por caja de línea,
y solo llegan a la maquetación por una de dos puertas.
line-height numérico o en longitud, solo cuentan las métricas de la
first available font: la primera cara de la pila con glifo para U+0020.line-height: normal cuentan las de todas las caras que intervienen en
la línea, y la caja crece hasta la mayor.
Como el sistema usa interlineados calculables y no normal, en la práctica rige
la primera puerta. Cuatro medidas sobre una pila «árabe, latina» a 100 px y
line-height: 1.5, con la cara árabe pidiendo 105 % / 45 %:
| Caso | Manda | Baseline |
|---|---|---|
| Árabe sin U+0020, texto con espacios | la latina | 109,5 px |
| Árabe sin U+0020, texto sin un solo espacio | la latina | 109,5 px |
| Árabe con U+0020 en su rango | la árabe | 105,0 px |
| Árabe sin U+0020, más una cara cuyo rango es solo U+0020 | la del espacio | 131,0 px |
La segunda fila descarta la explicación intuitiva: no es que mande la cara del primer carácter, porque ahí la cara árabe dibuja el texto entero y aun así no coloca la baseline. La cuarta lo confirma desde el otro lado: una cara que no dibuja ni un glifo visible se lleva la geometría de la línea por el solo hecho de aportar el espacio.
Un apunte de cálculo: los porcentajes del override se aplican sobre el em ya escalado por
size-adjust, así que van divididos por ese factor.
size-adjust, ascent-override,
descent-override y line-gap-override: 0%, y reclama U+0020
bajo su :lang(). Sin esa reclamación los overrides no llegan a la caja de
línea y la capa entera no hace nada.max() en el propio token, de modo que un valor que lo viole
se corrige solo en vez de romperse.overflow: hidden sobre texto quedan prohibidos por
contrato. Un componente con texto se dimensiona con min-height y
padding-block. Esto no es tipografía y es donde reaparece el recorte.Altura de tinta por em, medida sobre cadenas de ficha de producto. Menos tinta es menos R, y R es global: el peor script fija el contrato de todo el producto, también del latino.
| Script | Familia | bbox ↑ | bbox ↓ | Tinta total |
|---|---|---|---|---|
| Latino | Roboto | 0,896 | 0,210 | 1,107 |
| Árabe | Noto Sans Arabic | 0,730 | 0,421 | 1,151 |
| Árabe | Noto Naskh Arabic | 0,671 | 0,434 | 1,105 |
| Árabe | IBM Plex Sans Arabic | 0,770 | 0,440 | 1,210 |
| Árabe | Cairo | 0,807 | 0,430 | 1,237 |
| Tailandés | Noto Sans Thai | 0,950 | 0,265 | 1,215 |
| Tailandés | Prompt | 0,932 | 0,271 | 1,203 |
| Tailandés | IBM Plex Sans Thai | 1,007 | 0,350 | 1,357 |
| Tailandés | Sarabun | 1,168 | 0,332 | 1,500 |
| Devanagari | Noto Sans Devanagari | 0,896 | 0,274 | 1,170 |
| Devanagari | Mukta | 0,915 | 0,253 | 1,168 |
| Bengalí | Noto Sans Bengali | 0,912 | 0,228 | 1,140 |
| Bengalí | Hind Siliguri | 0,880 | 0,233 | 1,113 |
| Tamil | Noto Sans Tamil | 0,843 | 0,321 | 1,164 |
| Tamil | Mukta Malar | 0,789 | 0,298 | 1,087 |
| Hebreo | Noto Sans Hebrew | 0,760 | 0,191 | 0,951 |
| Hebreo | Rubik | 0,710 | 0,124 | 0,834 |
| Chino | Noto Sans SC | 0,796 | 0,172 | 0,968 |
| Japonés | Noto Sans JP | 0,843 | 0,171 | 1,014 |
| Coreano | Noto Sans KR | 0,833 | 0,123 | 0,956 |
Las marcadas son la elección propuesta, y no siempre es la de menos tinta. Noto Naskh Arabic gasta 0,046 em menos que Noto Sans Arabic, pero es naskh y no palo seco: rompe el criterio de parecido con Roboto. Ese es el precio explícito de la coherencia de estilo, y conviene pagarlo a sabiendas en vez de descubrirlo al medir.
En la otra dirección, elegir Sarabun en vez de Noto Sans Thai sube la tinta tailandesa de 1,215 a 1,500 y con ella el interlineado mínimo de todas las pantallas del producto, en todos los idiomas. Elegir la fuente de un script minoritario es una decisión de arquitectura con factura global.
La cara que posee la línea tiene que cubrir la tinta más alta que pueda aparecer en ella, y en una línea mixta esa tinta puede ser la latina. Por eso R no sale de la tinta del script, sino de la unión de los dos:
R = max(bboxAsc_script, bboxAsc_latino) + max(bboxDesc_script, bboxDesc_latino)
| Idioma | ascent que hay que cubrir | descent que hay que cubrir | R del idioma |
|---|---|---|---|
| Chino · japonés · coreano · hebreo | 0,896 lat | 0,210 lat | 1,106 |
| Bengalí | 0,912 | 0,228 | 1,140 |
| Devanagari | 0,896 lat | 0,274 | 1,170 |
| Tamil | 0,896 lat | 0,321 | 1,217 |
| Tailandés | 0,950 | 0,265 | 1,215 |
| Árabe | 0,896 lat | 0,421 | 1,317 |
Fíjate en qué columna manda: en seis de los nueve idiomas el ascendente que hay que cubrir es el latino, no el del script. La tinta que obliga a subir el interlineado de una pantalla en chino es la de los dígitos y las siglas, no la de los ideogramas.
Valor propuesto
R = 1,35
Cubre los nueve scripts y el latino, con 0,033 em de holgura sobre el peor caso, que es el árabe. Es un número del producto, no del idioma: el mismo badge mide lo mismo en español y en tailandés, y la maqueta no se mueve al cambiar de idioma.
R puede declararse por idioma en vez de global, con la misma mecánica de un token por lengua. Sale más barato en interlineado (el chino se queda en 1,11 en vez de subir a 1,35) y más caro en estabilidad: el badge deja de medir lo mismo en todos los idiomas y la maqueta se mueve al cambiar de lengua. Yo elegiría el global mientras el producto comparta maquetación entre idiomas, y solo bajaría a R por idioma si el interlineado del chino o del japonés se considerase inaceptablemente holgado.
Dos declaraciones por script, idénticas salvo por U+0020: la que lo reclama manda en la caja de línea, la que no lo reclama cede. El ascent y el descent se reparten la altura R según la tinta que hay que cubrir.
/* A = maxAsc + holgura/2 D = maxDesc + holgura/2 A + D = R = 1,35 árabe: maxAsc 0,896 (latino) · maxDesc 0,421 (árabe) · holgura 0,033 */ @font-face { font-family: 'p4-ar-manda'; src: url('/fonts/noto-sans-arabic.woff2') format('woff2'); size-adjust: 100%; /* el tamaño aparente lo da el multiplicador */ ascent-override: 91.25%; descent-override: 43.75%; line-gap-override: 0%; unicode-range: U+0020, U+0600-06FF, U+0750-077F, U+FB50-FDFF, U+FE70-FEFF; } @font-face { font-family: 'p4-ar-cede'; /* misma src y mismos descriptores, sin U+0020 */ unicode-range: U+0600-06FF, U+0750-077F, U+FB50-FDFF, U+FE70-FEFF; } /* Una cara latina por multiplicador: size-adjust no lee var() */ @font-face { font-family: 'p4-latin-140'; src: url('/fonts/roboto.woff2') format('woff2'); size-adjust: 71.43%; /* 1 ÷ 1,40 */ unicode-range: U+0000-024F, U+2000-206F; }
El size-adjust de la cara del script vuelve al 100 %. El tamaño aparente lo pone
el multiplicador del idioma sobre font-size, y la cara latina lo deshace con
1/M. Ese reparto es lo que mantiene R bajo: si la paridad de tamaño se hiciera
con size-adjust en la cara del script, la tinta se multiplicaría por 1,4 y R
volvería a 1,86.
:root {
--p4-font-stack: 'p4-latin-100', 'p4-ar-cede', 'p4-th-cede', 'p4-zh-cede', …;
--p4-typo-scale: 1;
}
:lang(ar) { --p4-font-stack: 'p4-ar-manda', 'p4-latin-140'; --p4-typo-scale: 1.40; }
:lang(th) { --p4-font-stack: 'p4-th-manda', 'p4-latin-130'; --p4-typo-scale: 1.30; }
:lang(zh) { --p4-font-stack: 'p4-zh-manda', 'p4-latin-110'; --p4-typo-scale: 1.10; }
Como :lang() alcanza también a un span incrustado, una palabra
árabe dentro de prosa española recibe su propia cara primaria y su línea crece sola.
:root { --p4-typo-ratio-min: 1.35; }
/* se escribe una vez por token y ya no puede violarse */
--p4-typo-body-m-font-size: calc(var(--p4-typo-font-size-12) * var(--p4-typo-scale));
--p4-typo-body-m-line-height: max(
calc(var(--p4-typo-line-height-18) * var(--p4-typo-scale)),
calc(var(--p4-typo-body-m-font-size) * var(--p4-typo-ratio-min))
);
El max() es lo que salva a los estilos de cociente ajustado. Un badge de 12/12
tiene cociente 1,00 y el multiplicador lo deja igual: es el suelo, no el multiplicador, quien
lo sube a 1,35 y evita el recorte.
Para lo que no pasa por los tokens. Se apoya en que 1em siempre vale el
font-size del propio elemento, sea cual sea:
/* cualquier elemento con texto que declare su propio interlineado */ [class*="p4-typo-"], .p4-escape-typo { line-height: max(var(--p4-lh, normal), calc(1em * var(--p4-typo-ratio-min))); }
No es elegante y es deliberado: la capa 3 existe para que el estilo escrito a mano por alguien que no leyó este documento tampoco pueda romper el contrato.
Enumerar los estilos no cubre los que aún no existen; comprobar la invariante sí. El motor de medición del banco de pruebas se extrae a un script que, en integración continua:
actualBoundingBox de cada cara contra las cadenas reales de contenido, no contra saludos.
El cuarto punto es el que más rinde a medio plazo: convierte «cambiar la fuente tailandesa»
en una decisión con un coste visible en el momento de tomarla, en vez de en un recorte que
aparece meses después en una tarjeta con overflow: hidden.
lang no recibe su cara primaria. La capa 1 depende de que el CMS marque los
idiomas, y eso es una decisión de contenido, no de CSS.