# Bug: color de texto pegado de Word se pierde al publicar (TipTap)

## Síntoma

El cliente reporta blogs (y potencialmente cualquier contenido TipTap) donde un color de texto
aplicado en el editor (panel admin, Filament) **no aparece en la página pública** — el texto
termina heredando el color del elemento padre en vez de mostrar el suyo propio.

Caso detectado: `https://group-itg.com/blogs/your-stenter-machine-is-burning-margin/detail`, el
subtítulo `<h6>` "How the XY-D-2600 attacks each loss" se ve verde (`#006937`) en el editor y azul
(heredado del `<h6>`, `rgb(46, 116, 181)`) en producción.

## Causa raíz

`vendor/awcodes/filament-tiptap-editor/src/Extensions/Extensions/Color.php` decide si debe
imprimir el atributo semántico `color` de un mark `textStyle` así:

```php
if ((property_exists($attributes, 'style') && str_contains($attributes->style, 'color')) ||
    (! property_exists($attributes, 'color') || ! $attributes->color)) {
    return null;
}
```

La intención es "si el `style` crudo ya trae el color, no lo dupliques". El chequeo usa
`str_contains` sobre todo el string, así que **cualquier propiedad CSS cuyo nombre contenga la
palabra "color"** — típicamente `-webkit-tap-highlight-color`, que el HTML pegado desde
Word/Office trae en casi todos los `<span>` — hace que la condición dé un falso positivo. La
extensión asume que el color ya está en el `style` (no es así) y descarta silenciosamente el
`color` real.

### Por qué en local "andaba" y en producción no

No es una diferencia de código, es de cómo quedó guardado el mark:

- **Paste crudo sin re-tocar el color**: el navegador parsea el HTML pegado y dentro de `style`
  queda el `color: #xxxxxx` real (además de separarse también en el atributo semántico `color`).
  Como el `style` sí tiene un `color:` de verdad, el chequeo bugueado da un falso positivo *que
  por casualidad no rompe nada*, porque el propio `style` ya trae el color correcto.
- **Color (re)aplicado desde el selector de color de la toolbar** (`preset_colors` en
  `config/filament-tiptap-editor.php`, p. ej. `color_two => '#006937'`, el verde de marca): esa
  acción deja el color **solo** en el atributo semántico `color`, sin tocar el `style` crudo (que
  conserva únicamente cosas como `-webkit-tap-highlight-color`). Con este dato, el bug sí se
  dispara: no hay `color:` real en el `style`, pero el `str_contains` se confunde con
  `-webkit-tap-highlight-color` y descarta el color real de todos modos.

Confirmado inspeccionando el `wire:snapshot` real del post en producción (solo lectura, sin
guardar nada): el mark tenía `attrs.color = "#006937"` y `attrs.style` sin ningún `color:` real,
solo `-webkit-tap-highlight-color`, `margin`, `padding`, etc. — exactamente el patrón que dispara
el bug.

**El JSON guardado en la base ya es correcto** (`attrs.color` está bien). El bug está solo en el
paso de conversión a HTML — no hace falta backfill ni tocar contenido existente, arreglar la
conversión alcanza.

## Fix

No se toca el vendor (se perdería en el próximo `composer update`). Se registra una extensión
propia que sobreescribe limpiamente la del paquete:

- `app/Support/Tiptap/Extensions/FixedColor.php` — misma extensión (`types: ['textStyle']`), pero
  el `renderHTML` parsea el `style` propiedad por propiedad (misma regex que usa
  `Tiptap\Utils\InlineStyle::get()`: `/([\w-]+)\s*:\s*([^;]+)\s*;?/`) y solo bloquea el color si
  existe una **clave exacta** `color`, no cualquier substring.
- `config/filament-tiptap-editor.php` → `'extensions' => [['parser' => \App\Support\Tiptap\Extensions\FixedColor::class]]`.

Por qué esto pisa la extensión del vendor sin conflicto: `TiptapConverter::getExtensions()`
(`vendor/awcodes/filament-tiptap-editor/src/TiptapConverter.php:45-99`) instancia primero la
`Color` del vendor (línea 66) y agrega las de `config('filament-tiptap-editor.extensions')` al
final (`...$customExtensions`). `Tiptap\Core\Schema::loadExtensions()`
(`vendor/ueberdosis/tiptap-php/src/Core/Schema.php:56-59`) combina los atributos globales de todas
las extensiones con `array_merge()`, indexado por nombre de atributo (`'color'`) — la que se
registra después gana. No hace falta parchear ni reemplazar el paquete completo.

Verificado localmente (vía Sail/tinker) contra el patrón exacto de producción (color separado +
`-webkit-tap-highlight-color` en el style) y contra el caso de paste crudo (color embebido en el
style) — ambos casos renderizan bien, sin duplicar el `color:` en el HTML final.

## Alcance

Afecta a **cualquier** contenido TipTap (blogs, páginas del Editor Web, máquinas, etc.) donde se
haya usado el selector de color de la toolbar sobre texto pegado de Word/Office. El fix es
transversal (se aplica en `TiptapConverter`, usado por todo el proyecto), no específico de blogs.

---

## Datos ocultos: lo que se ve en el editor es lo que sale en la web (2026-09-30)

### Síntoma

Hero de la home (slide "Second-hand machines"): el color amarillo elegido en el editor salía bien en
inglés y distinto en español, y el subtítulo aparecía en mayúsculas en la web pero en minúsculas en
el editor.

### Causa

Las extensiones del paquete (`ClassExtension`, `StyleExtension`, `IdExtension`) **conservan cualquier
`class`, `style` e `id` del HTML pegado**. El editor nunca los pinta, la web sí:

- En `es` las marcas `textStyle` traían además un `style: "color: rgb(255, 255, 255)"` viejo (texto
  copiado de otra fuente). `FixedColor` respeta un `color:` real dentro de `style`, así que ganaba el
  color viejo y no el `attrs.color` que muestra el editor.
- El párrafo del subtítulo traía `class="text-uppercase"`; el editor no carga el CSS de Bootstrap.

### Fix

**1. `App\Support\Tiptap\TiptapSanitizer`** deja en el JSON solo lo que el editor puede producir:

- Nodos y marcas de texto (`paragraph`, `heading`, listas, `bold`, `italic`, `textStyle`, …): sin
  `class`, `style` ni `id`. Una marca `textStyle` que se queda sin `color` se elimina.
- `link`: sin `class` ni `style`; conserva `id` (lo pone el diálogo de enlaces) y todo lo demás
  (`as_button`, `button_theme`, `target`, `rel`…).
- No toca imágenes ni nodos `lead`/`small`/`checked-list` (ellos ponen su propia clase al renderizar).
- `tiptapBlock`: su `data` queda igual, salvo los docs TipTap anidados (el bloque grid guarda uno por
  columna). El **bloque HTML / JS / CSS libre** solo guarda un string, así que nunca se altera.
- **No se restringen los colores** a la paleta: cualquier color pegado o elegido se conserva.

Se aplica **al guardar**, no al renderizar:

- `AppServiceProvider::boot()` → `TiptapEditor::configureUsing(... dehydrateStateUsing ...)`: todos
  los editores del proyecto.
- `LocaleSwapEditor`: los `Hidden` por idioma (ahí vive el valor real, el editor es solo scratch).

**2. Migración `2026_09_30_120000_clean_tiptap_hidden_attributes`** limpia lo ya guardado en
`content_block_pages.content`, `shared_sections.content`, `product_sector_blocks.data`,
`blog_translations.description` y `machine_translations.description`. Idempotente (segunda corrida:
0 escrituras). Antes de quitar, reescribe las pocas clases que sí pintaban algo como lo que el editor
sí tiene:

| Clase oculta | Dónde | Se convierte en |
|---|---|---|
| `title color-green2` (heading) | `split_content` | color `#006937` |
| `font-italic` (párrafo) | `cards_with_modal` | marca cursiva |
| `color-green1 font-weight-bold h5 m-0` | `cards_with_modal` | heading nivel 5 + negrita + `#014023` |
| `title` (párrafo) | título de sección de `numbered_steps` | heading nivel 4 |
| `style: text-align: X` sin `textAlign` | cualquiera | atributo `textAlign` |

El resto (`text-uppercase` en los 126 títulos del hero, que ya están escritos en mayúsculas; `h4` en
los subtítulos, que solo cambiaba la altura de línea 28.8 vs 28 px; `ql-align-*`; clases de Word;
`background-color: transparent`; `-webkit-*`) no tenía efecto visual y se descarta.

**3. CSS (`resources/css/page.css`), acotado a cada bloque**

- `.cwm-name h5` (margen 0) y `.cwm-name h5 span { font-weight: inherit }`: dentro de las tarjetas el
  `<span>` del color volvía a peso 400 y perdía la negrita.
- `.process-section .sec-title > h4`: espejo de `.sec-title .title` (`global.css`), porque el título
  de sección de `numbered_steps` ya no lleva esa clase.

### Verificado en local

- 527 filas revisadas, 245 cambiadas: **el texto es idéntico antes y después**, el bloque HTML libre
  quedó byte a byte igual y no queda ningún `class/style/id` oculto.
- Enlaces y botones (42): conservan `as_button`, `button_theme`, `target` y `rel`.
- Pegado real en el editor (`class`, `id`, `style`, `font-size`, enlace con `class`): se guarda solo
  el texto, el color y el `href`.
- Aspecto medido en el navegador contra el marcado viejo: títulos de `split_content` y tarjetas
  (`cards_with_modal`) iguales; título de `numbered_steps` igual en tamaño, color, margen, fuente y altura de línea.

### Trampas que aparecieron

- **Medir siempre en contexto.** `.sec-title .title` daba 30 px Anton `#cfd989` dentro de `.sec-title`
  y 16 px fuera: una clase "sin efecto" medida suelta puede tenerlo en su bloque.
- **`span` dentro de `strong` en `.cwm-name` vuelve a peso 400** (regla de origen no localizada; se
  resuelve con `font-weight: inherit` acotado).
- **Sin limpieza al pegar en el navegador** (requeriría un JS compilado con Vite y en el deploy): se
  limpia al guardar, que deja el dato igual de limpio.

---

## Incidente 2026-10-01: `/about`, `/products` y el sector ITG Line daban 500 tras el deploy

### Síntoma

Desde 17 minutos después del deploy del 30-sep (15:35, hora del servidor) esas tres rutas respondían 500. En el log:
`property_exists(): Argument #1 ($object_or_class) must be of type object|string, array given` en
`frontend/content-section/text-block.blade.php`, y en el editor `Attempt to assign property "textAlign" on array`
en `filament/forms/components/custom-grid.blade.php` (el modal del Grid block no abría).

### Causa

`TiptapSanitizer` (y la migración `clean_tiptap_hidden_attributes`) quitan `class`, `style` e `id` de `attrs`.
Cuando un nodo no tenía nada más, `attrs` quedaba como **array vacío de PHP**, y `json_encode` escribe un array
vacío como la lista `[]`, **no** como el objeto `{}`. El conversor de Tiptap PHP lee `attrs` como objeto: con `[]`
recibe un array y `property_exists()` / la asignación de `textAlign` fallan, y la página entera cae.

Afectó a `content_block_pages` 57 y 59 (página `/about`, 24 y 30 nodos) y a `product_sector_blocks` 4 (ITG Line, 42).
Blogs, máquinas y secciones compartidas no tenían ninguno.

### Fix

- `TiptapSanitizer::stripAttrs()`: si `attrs` queda vacío, se **elimina la clave** `attrs` (un nodo sin atributos no
  lleva `attrs`).
- Migración `2026_10_01_100000_repair_tiptap_empty_attrs` (en `module_editor`): vuelve a pasar el sanitizer por todos
  los docs de las 5 fuentes y repara los `attrs: []` ya guardados. Idempotente (segunda corrida: 0 escrituras).
  En una instalación limpia la primera migración ya no los genera, y esta no hace nada.

### Qué faltó en la verificación del 30-sep (y qué se hace ahora)

Se comprobó que el texto no cambiara y que no quedaran atributos ocultos, pero **no se renderizó cada documento
después de migrar**, y por eso el fallo llegó a producción. Ahora cualquier migración o cambio que reescriba
contenido TipTap se da por bueno solo si:

1. se renderizan **todos** los docs de las 5 fuentes con `tiptap_converter()->asHTML()` (en local: 1393 docs, 0 fallos);
2. se comparan las filas contra un respaldo previo (solo deben cambiar lo esperado: aquí, solo `attrs` vacíos);
3. se pide cada ruta afectada (`/`, `/about`, `/products`, el sector, `/contact`, `/partners`, `/blogs`).

### Trampa para el futuro

Un array PHP vacío guardado en JSON es `[]`, no `{}`. Si un nodo TipTap necesita `attrs`, que lleve al menos un
valor; si no, que no lo lleve. Nunca guardar `"attrs": []`.
