RF-009 — Color de texto en el contenido¶
| Estado | En exploración — historia de Taiga por asignar |
| Tipo | Extensión del editor + colección propia |
| Análisis del modelo | Gestor propio de colores de texto |
| Contrato | Color de texto para el storefront |
| Depende de | RF-000 · plataforma base — RF-004 · bloque content |
| Relacionado | Invalidación de la caché del storefront, pendiente y con historia aparte |
| Reversibilidad | Media. La lista de colores se puede tirar sin costo; lo que queda marcado dentro del texto cuesta más de sacar. Evaluado en el análisis |
Requisito¶
Poder definir el color de la letra dentro del contenido de una página, y que ese color no se elija a mano cada vez sino de una lista administrada desde el propio CMS, de modo que quien hace marketing pueda pedir "un cian para esta sección" y resolverlo sin tocar código ni desplegar.
Hoy el editor de contenido permite negrita, cursiva, subrayado, encabezados h2–h4, enlaces,
listas, cita y línea divisoria. Color no hay. Se verifica en
src/fields/defaultLexical.ts
y en el bloque content
(src/blocks/Content/config.ts),
donde la lista de funciones del editor está declarada una por una.
El requisito tiene entonces dos mitades, y las dos hacen falta:
- Marcar. Que el editor pueda pintar una selección de texto con un color.
- Administrar. Que los colores disponibles sean datos, no código: se agregan, se apagan y se corrigen desde el panel, y el cambio alcanza a todo el contenido que ya los usa.
Por qué no alcanza con dejar escribir el código hexadecimal¶
Es la solución más corta y la que el equipo descartó por tres razones, todas verificables:
- Rompe la línea gráfica. El sistema de diseño del storefront define una paleta cerrada y
prohíbe el hexadecimal suelto dentro de los componentes
(
storefront/docs/design-system.md). Un color tipeado a mano en el contenido entra por la ventana: no está en la paleta y nadie lo revisa. - Rompe el contraste. El mismo documento fija el umbral de accesibilidad en 4,5:1 para
texto normal y marca cuáles de los colores de la marca no sirven para texto:
#FF4D4Dda 3,27:1 y#9CA3AFda 2,54:1. Quien carga contenido no tiene cómo saberlo. Una lista curada sí: quien agrega un color a la paleta es quien puede mirar el contraste, una vez, en lugar de que lo mire cada quien escribe un párrafo. - Congela el valor. Un hexadecimal escrito dentro del contenido queda ahí para siempre. Cambiar el rojo de la marca obligaría a editar página por página.
La lista administrada resuelve las tres: el contenido guarda el nombre del color, no su valor, y el valor se resuelve cuando se sirve el contenido. Es el mismo criterio que ya se usa en este repo para los destinos de los menús (RF-005) y para los íconos (RF-007): se guarda un descriptor y se resuelve al leer.
Alcance¶
Este requisito es del CMS. Cubre el modelo de datos, la extensión del editor, la administración de la paleta y la forma en que el color viaja en la respuesta de la API.
El render en el storefront queda fuera y lo hace otro desarrollador, como en RF-007. Lo que acá se define es el contrato: qué llega y cómo interpretarlo. Ver la sección "Lo que el storefront hoy no dibuja", porque condiciona el resultado.
Solución adoptada¶
Tres piezas.
1. Una colección de colores¶
Una colección propia, administrable, que es la única fuente de la paleta disponible en el editor. Cada color es un documento con nombre, valor y una marca de si sirve para texto.
2. Una extensión propia del editor¶
Payload trae una función experimental para esto, TextStateFeature, y no se usa. El motivo
es concreto y está documentado en el
análisis del modelo: esa función valida el valor
contra una lista fijada al arrancar el servidor, y descarta en silencio cualquier valor que no
esté en ella. Con una paleta administrable eso significa que un color agregado después se pierde
al reabrir la página. Se construye una extensión propia que acepta la lista como dato.
3. El valor resuelto en la respuesta¶
El contenido guarda el nombre del color. Al servirse por la API, un gancho de lectura agrega el
valor real junto al nombre, sin tocar lo guardado, con el mismo patrón que el enmascarado de los
menús (src/utilities/enmascararNavegacion.ts). El storefront recibe el color ya resuelto y no
necesita conocer la paleta ni consultarla.
De ahí salen las tres propiedades que se buscaban: se administra en un solo lugar, el contenido no queda congelado, y el storefront no se entera de nada.
Modelo de datos¶
Nombres de campo en inglés y etiquetas del panel en español, por ADR-0007.
Colección colors¶
Se llama Colores en el panel, y no "Colores de texto". El nombre largo venía de cuando existía
el campo usage; al sacarlo, un color pasó a ser solo un nombre y un código, sin nada específico
del texto. Y sobre todo: los usos que vienen —el fondo de un banner, una etiqueta— eligen de esta
misma lista, así que ponerle el nombre del primer uso habría envejecido mal.
Lo que sí es de texto, y conserva el nombre, es la función del editor y el atributo del nodo
(textColor): ahí efectivamente se está pintando letra.
| Campo | Tipo | Regla |
|---|---|---|
name |
texto, requerido | Cómo se llama el color en el panel y en el selector del editor: "Cian de sección", no "#0891B2" |
slug |
texto único, requerido | La clave que se guarda dentro del contenido. Se genera de name con slugSinAcentos y queda editable |
value |
texto, requerido | El valor real, en hexadecimal. Es lo único que viaja resuelto al storefront. Al lado del campo hay un selector de color y los colores del sistema de diseño como atajo: quien sabe el código lo escribe y quien no, lo elige |
enabled |
checkbox, por defecto sí | Sacarlo del selector sin borrarlo ni afectar al contenido que ya lo usa |
description |
textarea | Nota interna: para qué campaña es, quién lo pidió |
value no se valida contra la paleta del storefront: son dos repos y la paleta cambia. Lo que
sí corresponde es advertir en el panel cuando el contraste contra fondo blanco queda por debajo de
4,5:1, que es un cálculo que se hace con el propio valor y no necesita consultar nada.
Anotado para después: para qué sirve cada color. Se evaluó agregar un campo
usage(text,background,border) que limitara dónde se puede usar cada color, porque el contraste de una letra y el de un fondo son preguntas distintas y hay colores que sirven para uno y no para el otro:#FF4D4Ddel sistema de diseño da 3,27:1 y no es apto para texto, pero sí para superficies.Queda fuera de esta primera versión por decisión del 2026-09-22: los colores definidos se usan libremente y la regla de uso se estudia cuando haga falta. El momento natural para retomarlo es cuando el color se habilite en un segundo lugar —el fondo de un banner, una etiqueta—, que es cuando la distinción empieza a significar algo. Agregarlo entonces es sumar un campo, no renombrar uno con datos adentro.
Cómo queda marcado el texto¶
El color se guarda dentro del nodo de texto, junto a las demás marcas, con el nombre del color como valor. No se toca la estructura del contenido ni se agregan nodos nuevos: un texto con color sigue siendo un texto.
No hay relación en el sentido de la base de datos. Lo que queda guardado es un nombre corto, texto plano dentro del JSON del contenido: sin tabla intermedia, sin columna propia, sin clave foránea. El vínculo con la colección de colores es por convención de nombre, igual que las etiquetas de producto cruzan contra el tag del catálogo (RF-008).
Así se ve guardado en la base:
{
"type": "text",
"text": "Envío gratis a todo el país",
"format": 1,
"style": "",
"detail": 0,
"mode": "normal",
"version": 1,
"$": { "textColor": "cian-de-seccion" }
}
Y así servido por la API, con el valor ya resuelto:
{
"type": "text",
"text": "Envío gratis a todo el país",
"format": 1,
"style": "",
"detail": 0,
"mode": "normal",
"version": 1,
"$": { "textColor": "cian-de-seccion" },
"textColorValue": "#0891B2"
}
El color mismo es un documento aparte, en su colección:
{ "slug": "cian-de-seccion", "name": "Cian de sección", "value": "#0891B2" }
La lista que el editor ofrece es, entonces, solo el efecto de leer los nombres de esa colección. Lo que se escribe en el contenido es únicamente el nombre corto.
La referencia es el slug, y nada más que el slug. Payload no sabe que ese vínculo existe: no es un campo de relación sino un texto adentro de un JSON. De ahí salen dos consecuencias:
- Borrar un color no avisa que está en uso, porque para Payload no lo está. Por eso la regla es que el texto se dibuje sin color: averiguar quién usa cada color exigiría recorrer el contenido de todas las páginas en cada borrado.
- Cambiar el slug corta el vínculo y el contenido pierde el color sin aviso. Ver la regla 6.
Se usa el slug y no el identificador del documento por dos motivos. El primero decide: en la
primera etapa la paleta es una lista fija en código, donde no hay documentos ni identificadores,
solo nombres — el slug es lo único que funciona igual en las dos etapas. El segundo es que se lee:
abrir el JSON de una página y ver cian-de-seccion dice algo; ver 7 no.
Por qué el valor resuelto va en una clave suelta¶
El lugar donde se inyecta el valor no es indistinto: es lo que decide si el hexadecimal puede o no terminar guardado en la base.
Lexical arma el JSON de un nodo de texto con una lista fija de campos —detail, format, mode,
style, text, más el tipo y el estado del nodo ($)—, y descarta cualquier otra clave.
| Dónde se inyecte el valor | Qué pasa cuando el panel abre esa página y la guarda |
|---|---|
Dentro de $ |
El estado del nodo conserva las claves desconocidas tal cual: el hexadecimal se escribiría en la base |
En style |
style es un campo real del nodo y también vuelve a la base: el color queda congelado |
En una clave suelta al lado, textColorValue |
El editor la descarta: no puede llegar nunca a la base |
De ahí sale la segunda ventaja, que resuelve un problema aparte. La vista previa del storefront lee
Payload autenticada, con un usuario de servicio (storefront/src/lib/payload.ts). Si el gancho
resolviera solo para lecturas anónimas —el criterio que usa el enmascarado de los menús—, la vista
previa mostraría el texto sin color mientras la página publicada sí lo tiene. Y si resolviera para
todos escribiendo en style, el panel recibiría el hexadecimal y lo guardaría.
Con la clave suelta el dilema desaparece: se resuelve en toda lectura, siempre, porque el editor no puede guardarla aunque la reciba.
Queda un camino por el que el valor sí podría llegar a la base, y se cierra con un gancho de guardado que lo saca: quien lea por API y vuelva a guardar lo leído —un guion, una importación, una copia entre ambientes— mandaría el valor de vuelta, y ahí sí quedaría escrito. El editor del panel no cae en eso porque rearma el JSON del nodo, pero un programa que copia tal cual, sí.
Ese gancho no es limpiar configuración del editor, que es lo que costó contenido en RF-007 y está prohibido por la regla 4: no borra nada que alguien haya cargado, saca una clave que el propio CMS agregó al leer. El nombre del color, que es el dato, no se toca.
Una precaución al implementar: el nombre de esa clave no debe coincidir con ningún atributo registrado del nodo. Lexical levanta al estado del nodo las claves sueltas cuyo nombre coincida con un atributo declarado como plano, y en ese caso sí volvería a la base.
Lo que hoy trae cada nodo de texto del contenido, y de dónde sale cada cosa:
| Campo | Qué es | Quién lo escribe |
|---|---|---|
format |
Número. Las marcas combinadas en bits: 1 negrita, 2 cursiva, 4 tachado, 8 subrayado, 16 código, 32 subíndice, 64 superíndice | El editor |
style |
Texto con CSS suelto | Casi siempre vacío; es la vía estándar de Lexical para estilos por nodo |
indent |
Número. Sangría del párrafo | El editor |
format del párrafo |
Texto: left, center, right, justify. Ojo: mismo nombre, otra cosa. En el párrafo es alineación; en el texto es la máscara de bits |
El editor |
El color no entra en format: esa máscara es de Lexical y está llena. Entra como estado del nodo,
que es el mecanismo previsto para agregar atributos propios sin romper el formato del documento.
Cómo se extiende a otras colecciones¶
El texto enriquecido es la excepción, no el modelo a seguir. Adentro de un nodo de Lexical no puede vivir un campo de relación de Payload: ese JSON es el contenido de un campo, no un documento. Por eso ahí se guarda un texto plano y lo resuelve un gancho.
Cualquier otro uso del color —el fondo de un banner, el color de una etiqueta de producto— es un campo normal de un documento, y ahí corresponde una relación de verdad, no copiar este mecanismo.
| Color en el contenido | Color en un campo de documento | |
|---|---|---|
| Cómo se guarda | El slug, texto plano dentro del JSON | Relación de Payload, con su columna |
| Selector en el panel | Se construye a mano | Lo da Payload |
| Integridad | Ninguna: Payload no sabe que el vínculo existe | Real: clave foránea en la base |
| Al borrar el color | Queda un nombre huérfano que el gancho no encuentra | Payload vacía el campo solo, por el ON DELETE set null que genera |
| Cómo llega el valor | Un gancho lo agrega al servir | Payload lo expande, si la consulta pide profundidad |
| Qué recibe el storefront | textColorValue: "#0891B2" |
El documento del color entero |
El criterio es usar el mecanismo más fuerte disponible en cada lugar. Copiar el texto plano a un campo de documento sería cambiar integridad real por una uniformidad que no sirve para nada.
Lo que sí se comparte, y es lo que hace que el modelo escale:
- La paleta es una sola. Todos eligen de la misma colección, y por eso corregir un color se refleja en todos lados a la vez.
- El slug es el nombre estable, acá y allá. La regla 6 vale igual.
- El valor se resuelve al leer, nunca se copia al guardar. Cambia el mecanismo, no el principio.
Dos avisos para cuando llegue el momento:
- La profundidad de la consulta. Con relación, el storefront recibe el color entero solo si pide profundidad suficiente; si no, recibe el identificador pelado, que no sirve. Es el mismo tropiezo que costó entender en RF-007 con las subcategorías.
- El borrado se ve igual pero se comporta distinto. En el campo de documento Payload limpia solo; en el contenido queda un nombre huérfano. El resultado visible es el mismo —se dibuja sin color— pero los síntomas al depurar difieren.
De dónde sale la paleta¶
Un solo lugar decide si los colores vienen de la colección o de una lista fija en código, y todo lo demás —el selector del editor, el gancho que resuelve el valor— pregunta ahí sin enterarse de la diferencia.
La colección manda. La lista fija es solo el respaldo de cuando está vacía, que es el caso de un CMS recién instalado o de alguien que todavía no cargó la paleta: el editor sigue ofreciendo colores y el contenido que ya los usa se sigue resolviendo. Cargar el primer color la reemplaza entera.
El editor no puede recibir la lista como configuración: lo que Payload le pasa a una función del editor se calcula cuando arranca el servidor, así que un color cargado después no aparecería hasta reiniciar, que es justo lo que este requisito evita. Por eso el selector la pide a la API.
Dos plazos de un minuto, por el mismo motivo en los dos lados:
- En el servidor, porque el gancho de lectura corre en cada lectura de página y los colores cambian muy de vez en cuando. Un cambio en la colección limpia el caché en el acto; el plazo cubre el caso de más de un proceso sirviendo a la vez.
- En el navegador, porque el panel navega sin recargar: quien crea un color y va derecho a editar una página no recarga nada, y sin el plazo no lo vería hasta un refresco a mano.
Reglas¶
- La lista manda. El editor no ofrece colores que no estén en la colección y habilitados.
- El contenido guarda el nombre, nunca el valor. Cambiar el valor de un color se refleja en todo el contenido que lo usa, sin editarlo.
- Un color apagado o borrado no rompe nada. El texto que lo usaba se dibuja con el color heredado, como si nunca hubiera tenido color. Nunca se pierde el texto.
- Ninguna opción de presentación borra lo cargado. Regla heredada de RF-007, donde costó contenido real: lo que se guarda no se toca al leer ni al cambiar una opción de vista; si algo no corresponde mostrar, se enmascara en la respuesta.
- El valor se resuelve al servir, no al guardar. Guardar resuelto es congelar. Y lo que se agregó al servir se saca antes de guardar, para que no entre por la puerta de atrás.
- El slug de un color no se cambia después de creado. Es la única referencia entre el color y
el contenido que lo usa.
nameyvaluese pueden corregir libremente; cambiar el slug corta el vínculo y el texto pierde el color, en silencio y sin aviso.
Lo que el storefront hoy no dibuja¶
Hallazgo asociado, relevado el 2026-09-22. Condiciona este requisito: un color que el storefront no pinta es trabajo invisible.
El storefront tiene dos renderizadores de contenido, y el que corre no es el que parece:
src/modules/common/components/RichText/index.tsxinterpreta bien la máscara de bits — negrita, cursiva, tachado, subrayado, código, subíndice, superíndice — pero nadie lo importa. Es código muerto.src/app/[countryCode]/(main)/[slug]/page.tsxdefine su propio renderizador local, y ese es el que se usa. Ignora la alineación del párrafo y la sangría.
De ahí sale lo que se observó: un título guardado con alineación center se ve a la izquierda. La
configuración está bien guardada; no se lee.
Aparte, el ancho de columna sí se traduce bien a clases (full, half, oneThird, twoThirds),
pero el contenedor es una fila flexible con separación entre columnas: dos columnas a la mitad
más la separación pasan del 100% y la segunda cae abajo. Por eso "mitad y mitad" se ve una debajo
de la otra.
Son tres arreglos del storefront, no del CMS, y no son parte de este requisito: unificar en un solo renderizador, hacerle leer alineación y sangría, y corregir el ancho de las columnas. Merecen su propia historia, y conviene hacerlos antes de dar por cerrado el color: son la diferencia entre que el contenido se vea como se configuró o no.
Limitaciones conocidas¶
- Editar un color cambia todas las páginas a la vez. Es la ventaja del modelo y también su riesgo: no hay forma de cambiarlo "solo acá".
- La paleta del CMS y la del storefront pueden separarse. Son dos repos; nada las ata. Mismo modo de falla que las etiquetas de producto (RF-008).
- Modo oscuro fuera de alcance. El sistema de diseño lo declara pero no lo usa. Un color fijo en hexadecimal no se adapta.
- Sin invalidación de caché, un cambio de color tarda hasta un minuto en verse en el storefront. Ver el análisis de invalidación.
Decisiones abiertas¶
| Decisión | Alternativas | Estado | |
|---|---|---|---|
| C1 | Dónde vive la paleta | Colección administrable · variable global del CMS · lista fija en código | Cerrada 2026-09-23: colección colors, con la lista fija como respaldo de cuando está vacía |
| C2 | Cómo llega el valor al storefront | Resuelto en style · resuelto en una clave suelta · que el storefront lea la paleta |
Cerrada 2026-09-22: clave suelta textColorValue, resuelta al leer. style y $ vuelven a la base y congelan el valor |
| C3 | Alcance de la marca | Solo color de letra · también resaltado de fondo | Cerrada 2026-09-23: solo color de letra. El resaltado de fondo trae la pregunta del contraste del par letra-sobre-fondo, que hoy no se calcula, y es lo que volvería a pedir la regla de uso por color que se dejó fuera. Se suma cuando haya un caso que lo pida |
| C4 | Dónde se habilita el color | Solo el bloque content · todos los campos de texto enriquecido del CMS |
Cerrada 2026-09-22: solo el bloque content. Se anticipan otros usos —etiquetas de producto, banners con texto—, pero se suman después, cuando el modelo esté probado |
| C5 | Qué pasa con un color borrado | Se dibuja sin color · se bloquea el borrado si está en uso | Cerrada 2026-09-23: se dibuja sin color. Bloquear el borrado exigiría recorrer el contenido de todas las páginas en cada intento, y lo que está en juego es un color, no una navegación rota. Ya implementado y probado |
| C6 | Advertencia de contraste | Calculada en el panel al cargar el color · sin advertencia | Cerrada 2026-09-23: calculada. Campo de solo lectura que muestra la razón contra blanco y si alcanza para texto. Avisa, no impide guardar |
Verificación¶
| Criterio | Prueba | Comando |
|---|---|---|
| El selector ofrece los colores habilitados y ninguno más | Agregar un color, apagarlo, ver que desaparece del selector | manual en el panel |
| Un color agregado después de arrancar el servidor se puede usar y sobrevive a reabrir la página | Agregar color, aplicarlo, guardar, recargar, verificar que sigue | manual + prueba de integración |
| Cambiar el valor de un color cambia el contenido ya publicado | Editar value, releer la página por API |
curl a /api/pages |
| La respuesta pública trae el color resuelto | Leer una página con texto coloreado sin sesión | curl -4 a /api/pages?where[slug][equals]=... |
| Borrar un color no rompe el contenido | Borrar un color en uso y releer la página | prueba de integración |
| La configuración guardada no se altera al leer | Guardar, leer, volver a guardar y comparar | prueba de integración |