elm-ui: sustituir el CSS por un lenguaje de layout tipado
Las dos lecciones anteriores buscaban formas de convivir mejor con el CSS: primero acotando la costura de texto, después trasladándola al interior del compilador. `elm-ui` propone algo distinto y mucho más radical: no usar CSS en absoluto como modelo mental, ni siquiera traducido, sino sustituirlo por un vocabulario propio de disposición construido desde cero alrededor de tres ideas —un elemento, una fila y una columna— y de un conjunto pequeño de atributos que describen tamaño, espaciado y alineación. Esta lección construye ese vocabulario desde la base: qué es un `Element` y por qué no es un nodo del documento, cómo se leen `row`, `column` y `el` como un álgebra de composición, por qué el espaciado pertenece al contenedor y no al hijo, cómo funcionan las reglas de tamaño y alineación cuando cada elemento declara lo que ocupa, y qué hace exactamente la función que convierte todo eso en una página real. El objetivo no es memorizar una interfaz sino aprender a pensar la disposición en términos de relaciones declaradas en lugar de propiedades heredadas.
Aprender elm-ui produce en casi todo el mundo la misma secuencia de reacciones, y merece la pena anticiparla porque explica tanto su atractivo como su rechazo. La primera hora es de desconcierto: uno busca la manera de poner un margen y descubre que no hay margen; busca cómo centrar algo verticalmente y descubre que no hay que hacer nada especial; busca la propiedad que controla el desbordamiento y descubre que la pregunta está mal planteada. La segunda hora es de sospecha, porque todo parece demasiado fácil y uno da por hecho que la simplicidad se acabará en cuanto el diseño se complique. Y entonces llega el momento en que se entiende lo que realmente ocurrió: no se ha aprendido una biblioteca de utilidades sobre el CSS, se ha cambiado de modelo. En el nuevo modelo no existe el concepto de propiedad que se hereda hacia abajo, ni el de caja cuyo tamaño depende de la suma de siete valores, ni el de regla que gana a otra por ser más específica. Existe un elemento, existen dos maneras de agrupar elementos y existe un puñado de atributos que solo pueden significar una cosa. La pregunta interesante no es si eso basta para construir interfaces reales —basta— sino qué se descubre sobre el CSS al comprobar que basta.
- Construir árboles de interfaz con
el,rowycolumny leerlos como una descomposición explícita de la disposición. - Aplicar
spacingypaddingsabiendo a quién pertenece cada uno y por qué esa asignación elimina una clase entera de fallos. - Controlar el tamaño con
widthyheightmediantefill,shrinky valores fijos, y razonar cómo se reparte el espacio disponible. - Alinear contenido en ambos ejes con los atributos de alineación y conectar el árbol resultante con el programa mediante
layout.
Tres constructores y un árbol que se lee en voz alta
Todo en elm-ui es un valor de tipo Element msg, y ese tipo no describe un nodo del documento sino una pieza de interfaz con un tamaño y unas reglas de colocación. Hay tres formas de producirlo. La función el envuelve un único hijo y sirve para colgarle atributos o para crear una unidad de alineación. La función row coloca a sus hijos uno junto a otro en horizontal. La función column los coloca uno debajo de otro en vertical. No hay más constructores estructurales, y esa pobreza deliberada es el rasgo central del diseño: cualquier disposición, por compleja que sea, se expresa anidando filas dentro de columnas dentro de filas, de manera que la estructura visual y la estructura del código son literalmente la misma cosa y se pueden leer en voz alta sin traducir.
import Element exposing (Element, column, el, fill, padding, row, spacing, text, width)
verFicha : Persona -> Element msg
verFicha persona =
row
[ width fill
, spacing 16
, padding 12
]
[ el [] (text persona.nombre)
, column
[ spacing 4
, width fill
]
[ text persona.cargo
, text persona.correo
]
]
Los atributos siguen la misma economía. Un Attribute msg describe una propiedad del elemento al que se aplica, y la lista de atributos posibles es corta y ortogonal: tamaño, relleno, espaciado, alineación, fondo, borde, tipografía y poco más. Lo notable es que ninguno de ellos puede afectar a un elemento que no sea aquel al que se aplicó, con una única excepción explícita que veremos enseguida. No hay atributo que cambie cómo se dibujan los hijos, no hay atributo cuyo efecto dependa de qué otros atributos haya presentes, y no hay atributo que gane a otro por haber sido declarado en otro sitio. Un elemento es lo que dicen sus atributos, y nada más.
Este es el primer punto donde el modelo se separa del CSS de forma visible, y es el que más rápido convence. En elm-ui no existe el margen: la separación entre hermanos se declara una sola vez en el padre, con spacing, y se aplica exactamente entre ellos, nunca en los extremos. El relleno interior se declara también en el padre, con padding, y es el hueco entre el borde del contenedor y su contenido. La consecuencia práctica es que la pregunta que todos hemos hecho alguna vez —si el hueco lo pone el de arriba o el de abajo, y qué pasa cuando ambos lo ponen— deja de tener sentido, porque solo hay un sitio donde el hueco puede declararse y ese sitio es el único que conoce a los dos hermanos.
Tamaño y alineación como declaraciones del propio elemento
Cada elemento declara cuánto espacio ocupa mediante width y height, y los valores posibles forman un vocabulario minúsculo que cubre casi todos los casos. El valor shrink significa ocupar lo justo que necesite el contenido, y es el comportamiento por defecto. El valor fill significa repartirse el espacio disponible con los demás hermanos que también lo pidan, y admite una variante con peso para que un hermano reciba el doble o el triple que otro. El valor px fija una medida exacta. A esos se añaden límites mínimos y máximos que se aplican encima de cualquiera de los anteriores. Con eso está dicho todo lo que hay que decir sobre el tamaño, y lo importante es que el resultado es determinista: dado un contenedor y las declaraciones de sus hijos, el reparto del espacio se calcula sin ambigüedad y sin que ninguna propiedad heredada de un ancestro lejano pueda alterarlo.
import Element exposing (fill, fillPortion, height, maximum, px, width)
-- Barra lateral fija y contenido elastico
verPagina : Element msg -> Element msg -> Element msg
verPagina lateral principal =
row
[ width fill
, height fill
, spacing 24
]
[ el [ width (px 240), height fill ] lateral
, el [ width (fill |> maximum 900), height fill ] principal
]
-- Reparto proporcional entre tres columnas
verTercios : List (Element msg) -> Element msg
verTercios partes =
row [ width fill, spacing 12 ]
(List.map (\p -> el [ width (fillPortion 1) ] p) partes)
La alineación completa el cuadro y aquí aparece la única forma de influencia entre padre e hijo del sistema, que es explícita y acotada. Un elemento puede declarar centerX, alignRight, alignTop y sus compañeros, y esos atributos indican dónde debe colocarse dentro del espacio que su padre le concede. Al ir en el hijo y no en el padre, cada hermano puede alinearse de manera distinta sin que exista una propiedad de contenedor que gobierne a todos y contra la que haya que luchar en los casos particulares. El centrado vertical, que en CSS ha sido durante dos décadas el ejemplo canónico de dificultad injustificada, aquí es un atributo llamado centerY que hace exactamente lo que su nombre dice, en cualquier contexto, sin condiciones previas sobre el padre.
el
Un contenedor de un solo hijo. Sirve para colgar atributos, fijar un tamaño o crear una unidad de alineación dentro de una fila o columna.
row
Coloca a los hijos en horizontal. Con spacing define la separación entre ellos y con width fill reparte el espacio sobrante.
column
Coloca a los hijos en vertical. Es la estructura por defecto de casi cualquier pantalla y se anida con filas sin límite.
layout
Convierte el árbol de elementos en el nodo estándar que el runtime espera. Se invoca una vez, en la raíz de la vista.
De la descripción a la página
El árbol de elementos no es todavía una página: hace falta una función que lo traduzca al tipo que el runtime de Elm sabe dibujar, y esa función es Element.layout. Recibe una lista de atributos que se aplican al lienzo entero —tipografía base, color de fondo, relleno global— y el elemento raíz, y devuelve el nodo estándar. Se llama una sola vez, en la vista principal, y todo lo que cuelga por debajo vive íntegramente en el vocabulario de la biblioteca. Ese punto único de conversión es la contrapartida directa de la decisión de tener un tipo propio, y es el mismo patrón que ya vimos en la lección anterior con otra biblioteca: la frontera existe, pero se ha concentrado en un lugar visible en vez de estar repartida.
import Element exposing (layout)
import Element.Background as Background
import Element.Font as Font
view : Model -> Html Msg
view model =
layout
[ Background.color (rgb255 30 30 46)
, Font.color (rgb255 205 214 244)
, Font.size 16
, padding 24
]
(verPagina (verLateral model) (verPrincipal model))
Queda una observación de fondo que conviene hacer ahora, porque cambia el modo de trabajar y no solo el modo de escribir. Como toda la disposición está en el árbol y no en un archivo aparte, leer una pantalla desconocida consiste en leer una sola función, y modificarla no exige averiguar qué reglas la afectan desde fuera, porque no puede haber ninguna. La consecuencia más sorprendente para quien llega del CSS es que desaparece el ciclo de abrir el inspector, buscar qué regla ganó y por qué, y volver al editor. Si el elemento está donde no debe, la razón está escrita en sus atributos o en los de su padre inmediato, y no hay ningún tercer sitio donde mirar.
flowchart TD A[layout con atributos globales] --> B[column raiz] B --> C[row cabecera] B --> D[row cuerpo] D --> E[el lateral width px 240] D --> F[column principal width fill] F --> G[el titulo] F --> H[column lista con spacing] style A fill:#f9e2af,color:#11111b style B fill:#89b4fa,color:#11111b style F fill:#a6e3a1,color:#11111b
El error más común al adoptar la biblioteca es intentar traducir el CSS existente declaración por declaración, y produce árboles enormes llenos de elementos envolventes innecesarios. El camino que funciona es el contrario: mirar la pantalla terminada y describir en voz alta qué hay dentro de qué, escribir esa descomposición con filas y columnas sin ningún atributo de aspecto, comprobar que la disposición ya es correcta y solo entonces añadir colores, tipografías y bordes. La disposición y la decoración son problemas separados en este modelo, y tratarlos por separado es lo que hace que el resultado quede limpio.
Hay una intuición muy extendida en el oficio, y casi siempre equivocada, según la cual la potencia de una herramienta se mide por la cantidad de cosas que permite expresar. El CSS es probablemente el argumento más contundente en contra que existe, y elm-ui es el experimento que lo demuestra. El estándar acumula centenares de propiedades, varios sistemas de disposición coexistentes, tres o cuatro modelos de caja según el contexto, reglas de herencia por propiedad, un algoritmo de especificidad con desempates y un orden de cascada que depende del origen del archivo. Cada una de esas piezas se añadió para resolver un caso real, ninguna fue caprichosa, y sin embargo el resultado agregado es un sistema donde la pregunta más elemental que se puede formular —por qué este elemento está aquí y no allí— exige a menudo abrir una herramienta de inspección, porque la respuesta no está en ningún sitio del código sino en la interacción de reglas escritas por personas distintas en momentos distintos. elm-ui responde reduciendo el vocabulario en un orden de magnitud y aceptando que algunas cosas se vuelvan difíciles o imposibles a cambio de que todas las demás se vuelvan deterministas. La lección general, que vale mucho más allá de las interfaces, es que expresividad y predecibilidad son magnitudes que se compran una con otra, y que la elección entre ellas debería ser consciente y no heredada. Un lenguaje que permite escribirlo todo no permite razonar sobre nada; un lenguaje que solo permite escribir lo previsto convierte cada programa en una afirmación que se puede verificar leyéndola. La mayoría de los sistemas que consideramos difíciles no lo son por lo que hacen, sino por la cantidad de cosas distintas que habrían podido hacer y que hay que descartar mentalmente antes de entender lo que hacen.
- Elige una pantalla existente de tu aplicación y descríbela en voz alta como un árbol de filas y columnas, antes de escribir nada.
- Escribe esa descomposición usando solo
el,row,column,spacingypadding, sin ningún atributo de color ni tipografía. - Sustituye todos los márgenes que tenías por espaciado del contenedor y localiza los casos donde el diseño original dependía de un margen exterior.
- Haz que una zona ocupe el espacio sobrante con
filly otra tenga anchura fija; después reparte tres columnas en proporción dos, uno y uno. - Centra un bloque en ambos ejes y compara la cantidad de decisiones que exigió frente a la solución equivalente en CSS.
- Añade ahora la decoración con los módulos de fondo, borde y tipografía, y comprueba que ninguna de esas adiciones movió nada de sitio.
- Documenta qué parte de tu diseño original no supiste expresar y decide si es una limitación real de la biblioteca o un requisito heredado que nadie había cuestionado.