Style y tokens
Cómo pinta un nodo: el conjunto de estilos, resuelto por nodo, sin cascada y sin herencia — más los estados de ejecución y el orden normativo en que se mezclan.
El estilo es cómo pinta un nodo: su relleno, el radio de sus esquinas, su borde, el tamaño y el color de su texto. El botón de comprar de la tienda es una caja con fondo morado y 6 px de radio, y se aclara un tono bajo el puntero: eso son dos declaraciones de estilo, una para el botón y otra para el estado en el que está. Los colores y los tamaños casi nunca se escriben a mano; apuntan a tokens —valores con nombre— y eso es lo que permite que un solo diccionario re-tematice un juego entero.
Dos reglas dan forma a todo lo que viene debajo.
El estilo se resuelve por nodo. No hay cascada ni herencia: el style de un
nodo es la respuesta entera a qué aspecto tiene, y el SDK nunca sube por el
árbol para calcularlo. Nada de lo que declara un padre llega a un hijo. La única
excepción es opacity, que se multiplica hacia abajo por el subárbol — y es una
excepción a propósito.
El pintado es implícito: en v1 no hay una capa de comandos de dibujo. No describes formas, describes un nodo, y su estilo implica lo que se dibuja: un rectángulo redondeado, un trazo hacia dentro y el contenido del propio nodo encima. Una capa de pintado explícita, con trazados y arcos, es una extensión futura compatible.
El conjunto de estilos
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
| background | ColorValue | none | Relleno del rect del nodo. Ausente = no se pinta nada. |
| radius | Dim | 0 | Radio de las esquinas del relleno, el borde y el recorte. |
| borderWidth | Dim | 0 | Grosor del trazo, hacia dentro. |
| borderColor | ColorValue | ausente | Color del trazo. |
| color | ColorValue | white | Color del contenido del nodo — glifos, tinte de imagen, cursor y selección. |
| fontSize | Dim | 16 | Tamaño del texto en px, redondeado y acotado a 1..512. |
| opacity | number | 1 | 0..1, acotado. Se multiplica hacia abajo por el subárbol. |
| textAlign | "start" | "center" | "end" | "start" | Alineación horizontal de cada línea dentro del rect. |
| textAlignY | "start" | "center" | "end" | "start" | Alineación vertical del bloque de texto entero. |
| lineHeight | Dim | la métrica de la fuente | Distancia entre las cimas de dos líneas consecutivas. |
| wrap | boolean | true | Parte el texto por palabras al ancho disponible. |
| overflow | "clip" | "ellipsis" | "clip" | Cómo se corta el texto que no cabe. |
| maxLines | number | sin límite | Tope de número de líneas. |
Las propiedades de texto viven en style, al lado de fontSize y color, para
que se puedan tematizar con tokens y sobrescribir por estado como cualquier otra
entrada visual. Se leen de un nodo
Text y se ignoran en el
resto; su semántica exacta, incluido el algoritmo normativo de wrap, está en esa
página.
El tamaño del texto
fontSize se resuelve por frame —a través de tokens, de estados, de una
transición— y después se redondea y se acota a 1..512 antes de pedir un
glifo. El techo es normativo, no un detalle de implementación de un renderer
concreto: el coste de rasterizar crece con el cuadrado del cuerpo, así que un
fontSize sin acotar —un token animado que se pasa, un valor que llega de los
datos— es la diferencia entre un titular grande y un bitmap de glifos de cientos
de megabytes. Acotar en silencio es deliberado: el valor se vuelve a resolver en
cada frame, así que no hay un momento concreto en el que reportarlo.
El borde
borderWidth crece hacia dentro. El trazo se pinta dentro del rect de
layout, al estilo border-box, así que un nodo con borde ocupa exactamente el
mismo sitio que uno sin él. Esto es lo que mantiene el invariante de que
nada pinta fuera de su propio rect,
y es la razón de que un anillo de foco hecho con un borde no desplace nunca el
layout al aparecer.
El relleno y el borde comparten una misma parametrización del perímetro redondeado, así que se encuentran sin costura con cualquier radio.
El color del contenido
color es «el color del contenido de este nodo», y cada tipo de nodo tiene
contenido propio: los glifos de un Text, el tinte de una Image (multiplicado
por canal; ausente = los píxeles tal cual), los glifos de un TextInput más su
cursor y el resaltado de la selección. Un Container no tiene contenido propio,
así que color no le hace nada.
La opacidad
opacity es multiplicativa hacia abajo por el subárbol: un nodo a 0.5
dentro de un padre a 0.5 pinta a 0.25. Se aplica como alfa por vértice, no
como opacidad de grupo a través de un render target: los hijos que se solapan
dentro de un subárbol atenuado se transparentan entre sí en vez de componer como
una sola capa aplanada.
Los estados
Un control tiene un aspecto distinto según lo que le esté pasando: el botón de comprar es de un color en reposo, de otro bajo el puntero y de otro mientras hay un dedo encima. Esos son sus estados, y un nodo declara una sobrescritura de estilo para cada uno que le importe. El SDK es dueño de los estados, indexados por identidad de componente: la IR declara qué aspecto tiene un estado, nunca cuándo ocurre.
{
"type": "Button",
"style": { "background": "{color.primary}" },
"states": {
"hover": { "style": { "background": "{color.primary.hover}" } },
"focused": { "style": { "borderWidth": "{border.focus}" } },
"pressed": { "style": { "background": "{color.primary.pressed}" } }
}
}
Hay siete estados y no hay forma de inventarse un octavo, porque quien los levanta es el SDK. Qué tipos de nodo pueden estar en cuál es fijo, y es fijo igual en todos los targets — así que esta es la lista de nombres contra los que puede indexar una sobrescritura de estilo:
normativeLos estados
| Estado | Lo llevan | Significado |
|---|---|---|
| empty | TextInput | El campo no tiene texto. Esto es lo que da estilo a un placeholder. |
| selected | Un botón de un grupo "exclusive-select" | Es la pestaña elegida. |
| checked | Toggle | Está activado — por su propio valor, o por el que deriva su grupo "exclusive-check". |
| hover | Nodos enfocables | El puntero está encima. |
| focused | Nodos enfocables | Tiene el foco. |
| pressed | Button, Toggle, Slider, la cabecera de un Collapse | Hay un dedo o un botón pulsando encima. Un Slider lo lleva mientras el puntero lo arrastra; la cabecera de un Collapse, solo desde el teclado o el mando, que es la única forma en que se pulsa. |
| disabled | Todos los nodos | Él —o un ancestro— declara disabled, así que está fuera del modelo de interacción. |
hover enciende exactamente el conjunto de nodos enfocables —lo que acepta
entrada es lo que puede verse distinto bajo el puntero— así que un Container a
secas nunca está en hover.
disabled es el único estado en el que puede estar un nodo que no es
enfocable, porque es el único que se hereda: una sección deshabilitada se lo
pasa a todo lo que lleva dentro, y las etiquetas de esa sección tienen que poder
atenuarse con los controles, o apagar la sección solo alcanzaría a la mitad de
lo que ve el jugador.
El orden de mezcla
Un control está a menudo en varios estados a la vez, y esos estados no se pondrán de acuerdo sobre el mismo color. Esta es la regla que decide cuál ves — y todos los SDK la resuelven idénticamente, que es por lo que es normativa.
normativeLos estados se solapan: un botón pulsado suele estar además en hover y con el foco. Se mezclan en un único orden fijo, de menos a más específico, y el posterior gana campo a campo:
base → empty → selected → checked → hover → focused → pressed → disabled
Ejemplo resuelto — pulsar Comprar en la tienda. El jugador lleva el puntero
sobre el botón y mantiene el ratón pulsado, así que hover, focused y
pressed son ciertos a la vez. Toma el nodo de arriba: background lo declaran
la base, hover y pressed, y pressed es el último de los tres en el orden,
así que el botón pinta {color.primary.pressed}. borderWidth solo lo declara
focused, y nada posterior menciona ese campo, así que el anillo de foco se
queda encima del relleno pulsado. Los estados se mezclan campo a campo, no uno
en lugar de otro.
Los estados de valor van primero —lo que el control es es la línea base— y
los estados transitorios de interacción pintan encima. empty abre la lista
porque es lo más débil que un control dice sobre su valor: el color de un
placeholder tiene que perder contra cualquier cosa que el autor diga de un campo
seleccionado o con el foco. hover va debajo de focused para que un ratón que
pasa por encima no esconda nunca un anillo de foco, y pressed gana a esos dos
porque dura exactamente lo que dura el dedo apoyado.
disabled cierra la lista, y su sitio ahí solo importa frente a los estados de
valor: un nodo deshabilitado no acepta entrada ninguna, así que hover,
focused y pressed no pueden estar activos junto a él, mientras que un
Toggle deshabilitado sigue estando checked y un campo deshabilitado sigue
estando empty. Ir el último es lo que permite que una sola sobrescritura hable
por el control entero, sea cual sea el valor que tenga en ese momento.
La mezcla es por campo, y solo dentro de style. Una sobrescritura de
estado nunca reemplaza el estilo base al completo, y no puede cambiar el layout,
los hijos ni el comportamiento; un estado que no declara nada sobre un campo
deja lo que hubieran resuelto las capas de debajo.
Las variantes son solo de autoría
variant es un concepto de @zabloo/react y nunca llega a la IR. Un tema
define conjuntos de estilo con nombre por componente:
export const theme = {
variants: {
Button: {
primary: {
style: { background: "{color.primary}", radius: "{radius.md}" },
states: { hover: { style: { background: "{color.primary.hover}" } } },
},
},
},
};<ThemeProvider theme={theme}>
<Button variant="primary" onClick="buy">Buy</Button>
</ThemeProvider>Al exportar, el style y los states de la variante se mezclan por debajo
de las props explícitas del nodo —lo explícito siempre gana— y el envelope
recibe el nodo ya resuelto del todo. Una variante desconocida falla a gritos
durante la autoría, que es el momento adecuado para ello.
El tema lleva además el motion por defecto de cada
componente (transitions), resuelto de la misma forma: prop del nodo, luego
variante, luego el valor por defecto del tema — y el más específico gana entero,
no campo a campo.