# Rendimiento del page builder (modales de bloque)

> Estado: **`LazyTabs` aplicado al bloque hero** (2026-08-20). El modal abre con **8 componentes en
> vez de 310** y sin ningún editor TipTap. Se descartaron antes el render por idioma (§5) y la
> edición en página (§6). Queda también el schema compartido `PageBlockForm`. Ver §9.

## 1. Medición del bloque `hero_carousel_main`

Contado sobre el schema real (`PageBlocks::heroCarouselSection()`) y los datos del bloque 37:

```
schema base ................. 118 componentes
por cada slide ...............  52   x 3 slides guardados
por cada feature box .........  52   x 3 guardados
-------------------------------------------------------
render real ................. ~326 componentes de formulario
editores TipTap en pantalla ..  18   (6 idiomas x 3 slides)
FileUpload con imageEditor ...   6
```

## 2. Causa raíz: `LanguageTabs` multiplica por 6

`pixelpeter/filament-language-tabs` **clona cada campo una vez por idioma**
(`config/filament-language-tabs.php → default_locales`: en, es, it, pt, fr, zh_CN) y los mete en
un `Tabs`. Filament **renderiza todas las pestañas** —solo las oculta con CSS/Alpine—, así que los
6 idiomas existen en el DOM y arrancan su JS aunque el editor vea uno. Multiplicado por los items
del repeater, ahí está el 6×.

## 3. Cómo se renderiza un solo idioma SIN perder el contenido de los demás

La duda razonable es: "si solo se renderiza el idioma activo, al cambiar de pestaña ¿trae el
contenido de ese idioma?". **Sí, y sin consultar nada**, porque el estado de los 6 idiomas ya está
cargado en memoria: `fill()` mete el JSON completo del bloque en el `$data` de Livewire al abrir el
modal. Las pestañas nunca "traen" datos; solo deciden **qué se pinta**. Cambiar de idioma es un
re-render, no una carga.

El diseño, con tres piezas:

1. **Selector de idioma en el propio estado del formulario.** Un campo `_locale` (por ejemplo
   `ToggleButtons` con los 6 códigos) marcado `->live()` y `->dehydrated(false)` — es UI, no se
   guarda. Ese campo reemplaza al `Tabs` del paquete.
2. **Un grupo por idioma, visible solo si es el activo.**
   ```php
   Group::make([ TiptapEditor::make("text_content.$code") ])
       ->visible(fn (Get $get) => ($get('_locale') ?? 'en') === $code)
       ->dehydratedWhenHidden()
   ```
   Un componente oculto en Filament **no se renderiza** (no es CSS: queda fuera del árbol que se
   pinta), así que los 5 idiomas no activos dejan de existir en el DOM. 18 editores → 3.
3. **⚠️ La trampa, y su solución oficial.** Por defecto Filament **no deshidrata lo oculto**
   (`isDehydrated()` → `! isHiddenAndNotDehydrated()`), así que sin más los idiomas no visibles se
   **borrarían al guardar**. Lo que lo evita es `->dehydratedWhenHidden()`: el contenedor recorre
   `getComponents(withHidden: true)`, así que el valor sigue guardándose aunque el campo no se
   pinte. **Esto es lo único que hay que probar a conciencia** antes de aplicarlo a todos los
   bloques: guardar con el idioma `es` activo y verificar que en, it, pt, fr y zh_CN siguen ahí.

Alcance recomendado para empezar: aplicarlo **solo donde el campo es un `TiptapEditor`** (donde está
el costo real), dejando los `TextInput` con el `LanguageTabs` actual. Mismo beneficio, riesgo mínimo.

## 4. Lo demás, por valor/esfuerzo

| # | Cambio | Efecto |
|---|---|---|
| 2 | Editar el bloque en una **página**, no en un modal | Los modales secundarios (confirmar borrado, link de TipTap) dejan de destruir y reconstruir el formulario entero. Es la causa del "se cierra y se vuelve a abrir". |
| 3 | Lazy en las pestañas del bloque (`Settings` / `Slides` / `Feature Boxes`) | Misma técnica que §3, para no montar las tres a la vez. |
| 4 | Quitar `imageEditor()` donde no se recorta; `profile` de TipTap más chico; `->collapsed()` en el repeater | Menos JS y menos pintado inicial. |

Orden sugerido: **§3 (solo TipTap) → §4 fila 2 → §4 fila 4**, midiendo antes y después.

---

## 5. `LocaleTabs` (render de un idioma a la vez) — probado y DESCARTADO

Se implementó, se midió y se revirtió. **Funcionaba**: con los datos reales del bloque 37 bajaba de
~326 componentes pintados a **118** y de 18 editores TipTap a **3**. Se descartó porque **cambiar de
idioma queda con retardo**: cada cambio es un roundtrip de Livewire que vuelve a montar el campo, y
para el editor es peor que esperar una vez al abrir. **Cargar los 6 idiomas de entrada es la decisión
tomada.**

Queda documentado porque el diseño y sus dos trampas son la parte valiosa, y por si algún día un
bloque concreto lo justifica:

- Un `ToggleButtons` con la clave `_locale`, `->live()` y `->dehydrated(false)` reemplaza al `Tabs`
  del paquete; cada copia por idioma lleva
  `->visible(fn (Get $get) => ($get('_locale') ?: 'en') === $locale)`. En Filament lo oculto **no se
  pinta** (queda fuera del árbol), de ahí el ahorro.
- ⚠️ **Trampa 1:** hay que poner **`->dehydratedWhenHidden()`** en cada copia. Sin eso,
  `HasState::dehydrateState()` hace `Arr::forget()` del path oculto y **borra las traducciones que el
  editor no estaba viendo**. Comprobado con un control negativo: se guardaba **solo** el idioma
  activo y se perdían los otros 5.
- ⚠️ **Trampa 2:** un campo oculto tampoco se valida, así que el idioma requerido
  (`required_locales`, hoy `en`) tiene que seguir visible mientras esté vacío, o se cuela un inglés
  en blanco.
- El contenido de los otros idiomas **nunca se pierde ni se re-consulta**: `fill()` ya cargó el JSON
  completo en el estado de Livewire al abrir, así que cambiar de idioma es solo un re-render.

---

## 6. Bloques en su propia página — probado y DESCARTADO

Se implementó (`EditPageBlock` + su vista + ruta `admin/pages/{record}/blocks/{block}/edit`),
funcionó —200, con el título del bloque, sus pestañas y el guardado, y el scope por página
verificado— y **se revirtió: el tiempo seguía siendo lento**. La causa está en §7: el problema no era
el modal en sí, era el peso del formulario.

**Lo que se quedó** de ese trabajo, porque vale igual: `App\Filament\Blocks\PageBlockForm`, con el
schema del bloque y el mapeo de estado (`toFormState()` / `toModelState()`) fuera del relation
manager. Un solo sitio donde vive el schema.

Dos gotchas que descubrió y conviene no volver a pisar:

1. Livewire resuelve los parámetros de ruta **contra las propiedades públicas tipadas** del
   componente (`Livewire\Drawer\ImplicitRouteBinding::resolveComponentProps`): si la clase declara
   `public ContentBlockPage $block`, el parámetro `{block}` llega **ya hidratado como modelo**.
2. Y `findOrFail($modelo)` **no falla de forma ruidosa**: un modelo es `Arrayable`, así que `find()`
   degrada a `findMany($modelo->toArray())` y sale un 404 con la fila entera en el mensaje.
   Para diagnosticar un 404 así: pedir la misma URL con `Accept: application/json` y `APP_DEBUG=true`.

---

## 7. Por qué los modales anidados cuestan, y qué se puede hacer de verdad

La pregunta era si se puede evitar que el modal de abajo se cierre y se recargue de cero. Lo
verificado en el código de Filament y Livewire:

- **El modal padre NO se destruye.** Livewire mantiene el componente montado y Filament apila los
  modales; no hay un "cerrar y volver a abrir".
- **Lo que cuesta es el render.** Montar y desmontar una acción (la confirmación de borrado de un
  item del repeater, el diálogo de link de TipTap) hace que Livewire **vuelva a renderizar el
  componente completo**, y en este bloque eso son ~326 componentes y **4 MB de HTML** por render
  (medido con una petición autenticada real).
- **No existe render parcial.** Livewire renderiza el componente entero, no un subárbol, y
  `partiallyRender` **no existe en todo `vendor/filament`**. `skipRender()` de Livewire solo sirve si
  la vista no tiene que cambiar, que no es el caso de un borrado.

Es decir: no hay forma de "refrescar solo la parte afectada". Las dos palancas reales son **menos
idas y vueltas** y **un formulario más liviano**:

| Palanca | Detalle |
|---|---|
| **Abaratar los `requiresConfirmation()` de los repeaters** | Hay **27** en `PageBlocks.php`. Cada uno convierte un borrado de 1 render en 3 (montar la acción, confirmar, desmontar) sobre un form de 4 MB. Opciones en §8. |
| `->collapsed()` en los repeaters | El navegador pinta mucho menos al abrir. No baja el costo del render, sí el del pintado. |
| Quitar `imageEditor()` donde no se recorta | 6 instancias solo en este bloque, cada una con su bundle. |
| `profile` de TipTap más chico | Menos botones = menos DOM y menos JS por editor (y hay 18). |
| Menos items por bloque | 3 slides × 52 componentes es la mitad del peso. Partir un hero de 3 slides en 3 bloques distintos lo divide de verdad. |

---

## 8. Confirmar un borrado sin pagar 3 renders

La confirmación **sí hace falta** (un clic por error en un repeater de contenido duele), así que la
pregunta no es quitarla sino abaratarla. El costo actual: el modal de confirmación de Filament es una
acción del servidor → **montar (1 render) + confirmar (1) + desmontar (1)**; si el editor cancela son
2 renders igual. Cada render de este bloque son 4 MB.

Clasificación de los 27 en `PageBlocks.php`:

- **5 están en repeaters `maxItems(1)`** — `slide_button_wrapper`, `box_button_wrapper`,
  `button_wrapper`, `card_link_wrapper`, `redirection_settings`: "borrar" ahí quita un botón opcional
  y volver a agregarlo es un clic. La confirmación protege muy poco.
- **22 están en repeaters de contenido real** — `carousel_slides`, `feature_boxes`, `cards`,
  `gallery_images`, `steps_list`, `timeline_items`… Ahí sí vale.

| Opción | Qué implica | Costo por borrado |
|---|---|---|
| **A. `confirm()` del navegador vía Alpine** | `requiresConfirmation(false)` + `extraAttributes(['x-on:click.capture' => "if (! confirm('…')) { \$event.stopImmediatePropagation(); \$event.preventDefault(); }"])`. Mantiene la protección **sin** ir al servidor. Contras: el diálogo nativo es más feo que el modal de Filament, y depende de que Alpine capture el clic antes de Livewire (funciona, pero es una fibra fina que hay que revisar si se actualiza Filament). | **1 render** |
| **B. Confirmación solo donde protege contenido** | Quitarla en los 5 wrappers de un item, dejarla en los 22 de contenido. Riesgo cero, ganancia chica. | 1 render en esos 5 |
| **C. Borrar y ofrecer "Deshacer"** | Se borra al primer clic y sale una notificación con un botón de deshacer (guardando el item borrado en una propiedad del componente). La mejor UX, la más código, y el deshacer hay que reinsertarlo en la posición correcta del repeater correcto. | 1 render (+1 solo si deshace) |
| **D. Bajar el peso del form** | Es la raíz: si un render no cuesta 4 MB, la confirmación deja de doler. Menos items por bloque, `collapsed()`, menos `imageEditor()`, perfil de TipTap más chico. | — |

Recomendación: **B + A** (B es gratis; A mantiene la protección en todo a un tercio del costo), y **D**
como trabajo de fondo. **C** solo si se quiere UX de primera y hay tiempo.

---

## 9. `App\Filament\Components\LazyTabs` — implementado (la que sí quedó)

Se queda en **modal**, pero el modal ya no monta las tres pestañas de golpe: solo la seleccionada.
Las pestañas son el sitio correcto para ser perezoso —un editor cambia de pestaña mucho menos de lo
que teclea—, al revés que hacerlo por idioma (§5), que se sentía lento.

```php
LazyTabs::make('_hero_tab', [
    Tab::make('Settings')->icon('heroicon-o-cog-6-tooth')->schema([...]),
    Tab::make('Carousel Slides')->icon('heroicon-o-photo')->schema([...]),
    Tab::make('Feature Boxes')->schema([...]),
])
```

Drop-in de `Tabs::make(...)->tabs([...])`: recibe los mismos objetos `Tab` y reutiliza su label y su
icono, así que pasar otro bloque es cambiar una línea. Reutilizable por cualquier bloque o resource.

Cómo funciona: un `ToggleButtons` con la clave que se le pasa (`_hero_tab`), `->live()` y
**`->dehydrated(false)`** (es UI, nunca se guarda), más cada panel en un `Group` con
`->visible(...)` y **`->dehydratedWhenHidden()`**. Lo oculto en Filament **no se pinta**, y el flag es
lo que impide que guardar desde otra pestaña borre lo que hay en las demás:
`HasState::dehydrateState()` recorre el contenedor oculto con `isDehydrated: false` y hace
`Arr::forget()` de cada path que hay debajo. `Group` y `Section` lo soportan porque el flag vive en
`Component`, no solo en `Field`.

### Medición, con los datos reales del bloque 37 (3 slides, 3 feature boxes)

| Pestaña activa | Componentes pintados | Editores TipTap | Se guarda |
|---|---|---|---|
| **Antes (Tabs de Filament)** | 310 | 18 | — |
| Settings (la que abre) | **8** | **0** | slides=3, boxes=3, settings ✔ |
| Carousel Slides | 155 | 18 | slides=3, boxes=3, settings ✔ |
| Feature Boxes | 155 | 0 | slides=3, boxes=3, settings ✔ |

Es decir: **el modal abre con 8 componentes y ningún editor**, y los 18 TipTap solo se montan si el
editor entra a "Carousel Slides".

### Lo que se verificó

- Con las tres pestañas activas, `getState()` devuelve **los 3 slides, los 3 feature boxes y los
  settings intactos** — que era la condición que había que cumplir.
- `_hero_tab` **no aparece** en el JSON guardado (`dehydrated(false)`).
- En un schema sintético equivalente: 11 inputs pintados con `Tabs` → 2/6/3 con `LazyTabs` según la
  pestaña, guardando todo en los tres casos.
- La trampa del `dehydratedWhenHidden()` está probada a nivel de **campo** (§5, control negativo: sin
  el flag se guardaba solo lo visible y se perdía el resto). A nivel de **contenedor** el arnés no se
  deja ejercitar, pero es el mismo camino de código en `HasState::dehydrateState()`.

### Dos detalles de UI que salieron al probarlo en el panel

1. **La pestaña activa no se veía marcada al abrir.** `->default()` solo aplica a un registro nuevo;
   al editar un bloque existente el form se llena desde su JSON, donde esta clave de UI no existe, así
   que el estado llegaba `null` y ningún botón salía seleccionado aunque su panel fuera el que se
   mostraba (el `visible()` cae al primero por defecto). Resuelto con
   `->afterStateHydrated()`, que fuerza el valor al hidratar. Verificado: al abrir el modal del bloque
   37 el estado ya vale `settings`.
2. **El primer clic en otra pestaña no hacía nada; había que insistir varias veces.** No era el
   morph (los `->key()` estables no lo arreglaron). Está en la **vista del propio Filament**:

   ```blade
   {{-- vendor/filament/forms/.../toggle-buttons/grouped.blade.php --}}
   <input type="radio" wire:loading.attr="disabled" wire:model.live="{{ $statePath }}" ... />
   ```

   Ese `wire:loading.attr="disabled"` va **sin `wire:target`**, así que el radio queda deshabilitado
   mientras hay **cualquier** petición en vuelo del componente — y este formulario tiene muchas (el
   selector de tipo de bloque es `live()`, TipTap, uploads, repeaters). Y hacer clic en el label
   visible de un radio deshabilitado **no hace absolutamente nada, en silencio**. (El botón de
   Filament sí lo scopea bien: `wire:loading.attr` + `wire:target` en
   `support/.../button/index.blade.php:223`.)

   Resuelto acotando esa directiva al propio campo:

   ```php
   ->extraInputAttributes(fn (ToggleButtons $c) => ['wire:target' => $c->getStatePath()])
   ```

   Ahora los botones solo se bloquean mientras se procesa **su** cambio. Se quitó también el
   `pointer-events-none` que se había puesto en la barra, que empujaba en la misma dirección
   equivocada; queda solo `opacity-60` como feedback.

⚠️ **Falta reconfirmar en el panel** el punto 2 (no se puede probar desde consola: el modal se
renderiza por Livewire). Si aún así fallara, el siguiente paso es cambiar el switch por `Actions`
(botones de formulario), que Filament sí acota correctamente con `wire:target`. Respaldo del contenido en `storage/app/tmp/block-37-backup.json`.

Para extenderlo a los demás bloques: cambiar `Tabs::make('x')->tabs([...])` por
`LazyTabs::make('_x_tab', [...])`. Hay `Tabs` en varios bloques de `PageBlocks.php`,
`ProductSectorBlocks.php` y `FooterBlocks.php`.

---

## 10. Medido en el navegador de verdad (2026-08-20)

Con Chrome sobre el panel real, cronometrando desde el clic hasta que el panel está pintado:

| Acción | ms |
|---|---|
| Abrir el modal del bloque hero (pestaña Settings) | **1 232** |
| Cambiar a **Settings** (un select y dos toggles) | 2 414 |
| Cambiar a **Feature Boxes** (3 uploads, 0 editores) | 356 / 2 297 / 3 614 / 3 048 |
| Cambiar a **Carousel Slides** (18 editores TipTap) | 6 542 / 7 644 |

Tres conclusiones, y la primera cambia el diagnóstico:

1. **Hay un suelo de ~1,2–2,4 s en cualquier interacción**, incluso cambiando a una pestaña casi
   vacía. Eso no es el contenido del panel: es el ida y vuelta de Livewire sobre este componente.
   Y el motivo está medido: **`PageBlocks::register()` mete las 24 secciones de bloque en el schema y
   las esconde con `visible()` según el tipo, así que en cada render se CONSTRUYEN 1 248
   componentes — de los que el bloque hero solo necesita 123 (el 9,9 %)**. La mediana por bloque es
   55: se está construyendo ~20 veces más schema del necesario, siempre.
2. **Lo que hace lenta la pestaña de slides son los 18 editores TipTap** (6 idiomas × 3 slides), no
   los previews de imagen: el panel de Feature Boxes tiene 3 uploads **con** preview y tarda entre 0,4
   y 3,6 s, mientras el de slides tarda 6,5–7,6 s.
3. **`imageEditor()` no resultó ser la palanca.** Aislado en el panel de Feature Boxes: 3 048 ms con
   él, y 356 / 2 297 / 3 614 ms sin él — la varianza se come la diferencia. **Se revirtió**, para no
   perder el recorte de imagen a cambio de nada medible.

### Siguiente palanca (la grande): construir solo la sección del tipo de bloque actual

`PageBlockForm::schema()` debería incluir **una** sección —la del tipo que se está editando— en vez de
las 24. Con un `Group::make()->schema(fn (Get $get) => [PageBlocks::sectionFor($get('type'))])`, o un
mapa `tipo => método` en `PageBlocks`, el schema pasa de 1 248 componentes a ~123 en el bloque hero y
a ~55 en el bloque medio. Eso ataca el suelo de **todas** las interacciones de **todos** los bloques,
no solo de este.

Y si después de eso el render es 10 veces más barato, vale la pena reconsiderar `LocaleTabs` (§5) solo
para los TipTap: 18 editores → 3, que es lo que queda pesando en la pestaña de slides.

---

## 11. Qué cuesta de verdad, y en qué orden atacarlo

Medido en el navegador (§10), con el coeficiente que sale de comparar dos bloques reales:

```
Hero → Carousel Slides   103 inputs + 18 editores  = 7 704 ms
Carousel partner logos   501 inputs +  0 editores  = 6 734 ms
                         ------------------------------------
=> 1 editor TipTap ≈ 22 inputs planos
=> el costo va con lo que se RENDERIZA (editores × idiomas × items),
   no con lo que se escribe en el schema
```

Y los 18 editores del hero no son "los 6 del código": son 1 campo × 6 idiomas × 3 slides. Con 5
slides serían 30.

### Descartado por medición: un solo editor con `statePath` dinámico

La idea de tener **un** editor y cambiarle el `statePath` según el idioma (en vez de 6 escondidos) se
probó: renderiza 1 campo, pero **al guardar solo sobrevive el idioma activo**. Filament construye el
estado a partir de los componentes, así que una clave sin componente **no se conserva**. Habría que
añadir un `Hidden` por idioma no activo… que es exactamente lo que ya hace `LocaleTabs` (los 5
idiomas existen como componentes pero **no se renderizan**). Mismo resultado en pantalla, sin el
riesgo de perder traducciones.

### Orden propuesto

| # | Palanca | Efecto | Toca la UX |
|---|---|---|---|
| 1 | **Construir solo la sección del bloque en edición** | 1 248 → ~123 componentes por render. Es el suelo de 1,2–3 s de **toda** interacción de **todos** los bloques | **no** |
| 2 | **Perfil de TipTap más liviano** en el texto de los slides (`custom_default` = 20 herramientas → `simple` = 8) | menos DOM/JS por editor, ×18 | **no** |
| 3 | Un idioma en el texto rico (`LocaleTabs`) | 18 → 3 editores | sí, selector de idioma |
| 4 | Renderizar solo el item abierto del repeater | 18 → 6 con un slide abierto | sí, un item a la vez |

**Importante para juzgar la 3:** cuando se probó y "se sentía lento al cambiar de idioma", cada
cambio re-renderizaba **el bloque entero** (310 componentes, 18 editores) **y** construía las 24
secciones (1 248 componentes). Con la 1 aplicada, un cambio de idioma solo re-renderiza el panel de
slides. Hay que volver a medirla antes de descartarla.

---

## 12. Dónde se va el tiempo de verdad (servidor vs navegador) e implementado

Interceptando `fetch` en el navegador, cambiar a la pestaña de slides (5 185 ms) se reparte así:

```
servidor + red   2 165 ms   devolviendo 2 515 KB de HTML
navegador        3 020 ms   morph del DOM + arranque de 18 editores TipTap
```

Y el tamaño de la respuesta por panel:

| Panel | respuesta | servidor | total |
|---|---|---|---|
| Settings (un select y dos toggles) | **478 KB** | 915 ms | 2 269 ms |
| Feature Boxes (3 uploads, 0 editores) | 1 013 KB | 1 307 ms | 3 074 ms |
| Carousel Slides (18 editores) | 2 515 KB | 2 165 ms | 5 185 ms |

Dos lecturas:

1. **Cada editor TipTap ≈ 113 KB de HTML** ((2 515 − 478) / 18). Es la unidad de coste dominante.
2. **Hay un piso de 478 KB / ~900 ms** incluso para un panel con dos campos, porque Livewire
   re-renderiza **el componente completo**: el modal *y* la tabla de 17 bloques del relation manager
   viven en el mismo componente. Ese piso no se baja con schema ni con pestañas — se baja sacando el
   editor del componente que tiene la tabla (que es lo que habría hecho la página propia, §6).

### Implementado en esta ronda

1. **Solo se construye la sección del bloque en edición** (`PageBlocks::map()` + `sectionFor()`,
   usado por `PageBlockForm` y por `SharedSectionResource`): de **1 248 componentes construidos por
   render a 5** en el schema base más los ~123 del bloque abierto. Sin cambios de UX.
   ⚠️ **Ganancia medida: dentro del ruido.** Construir el schema en PHP no era el cuello; lo que
   cuesta es el HTML que se devuelve y lo que el navegador hace con él. Se deja igual porque es
   correcto y quita 20× de trabajo inútil, pero no es la palanca.
2. **`LocaleTabs` en el `text_content` de los slides** (un idioma a la vez), que sí mueve la aguja:

| | antes | ahora |
|---|---|---|
| Editores en pantalla | 18 | **3** |
| Respuesta al abrir la pestaña | 2 515 KB | **1 212 KB** |
| Servidor | 2 165 ms | **1 388 ms** |
| Total del cambio de pestaña | 5 185 ms | **3 880 ms** |
| Cambiar de idioma | — | **≤ 851 ms** |

3. **Perfil `text_only`** en `config/filament-tiptap-editor.php`: nuevo, **sin tocar los que ya
   existían**, con lo indispensable para redactar (títulos, negrita, cursiva, subrayado, tachado,
   lead, small, **color**, resaltado, alineación, listas, hr, enlace = 16 herramientas) y **sin las
   de panel** (tabla, grid-builder, media, oembed, details, source, blocks, checked-list,
   blockquote). Aplicado al `text_content` de los slides.

   **Es la que más movió la aguja, y por un motivo que no era el esperado:** el HTML bajó poco
   (1 212 → 1 131 KB, ~27 KB por editor) pero el **tiempo total se derrumbó de 3 880 a 1 467 ms**.
   O sea que las herramientas de panel no pesan tanto por su HTML como por lo que el **navegador**
   monta de cada una (Alpine, dropdowns, modales). El tiempo de navegador en ese cambio de pestaña
   pasó de ~2 500 ms a ~500 ms.

⚠️ **Pendiente de probar en el panel:** editar texto en varios idiomas del mismo slide, cambiar de
idioma y **guardar**, comprobando que los 6 idiomas siguen ahí. El control negativo del arnés (§5)
demuestra que `dehydratedWhenHidden()` es lo que los salva, pero el caso "TipTap dentro de repeater"
no se puede ejercitar desde consola. Respaldo en `storage/app/tmp/block-37-backup.json`.

### Lo que queda sobre la mesa

- El piso de 478 KB: sacar la edición del bloque a un componente sin la tabla (§6, revertido).
- Los 1 212 KB restantes de la pestaña de slides: repeaters, uploads y los botones de idioma de
  `SectionButtonRedirect` (que sigue con `LanguageTabs` y monta 6 inputs por botón).
- Un cambio de idioma **instantáneo** (sin petición) solo con el swap en cliente: un editor + los
  otros idiomas en `Hidden` y Alpine cambiando el contenido con `setContent()` de TipTap.

### Resumen acumulado del bloque hero (medido en el navegador)

| Cambio a la pestaña "Carousel Slides" | respuesta | servidor | total |
|---|---|---|---|
| Punto de partida (18 editores, `custom_default`) | 2 515 KB | 2 165 ms | **5 185 ms** |
| + `LazyTabs` y `LocaleTabs` (3 editores) | 1 212 KB | 1 388 ms | **3 880 ms** |
| + perfil `text_only` | 1 131 KB | 960 ms | **1 467 ms** |

**−72 % en la interacción más pesada.** Abrir el modal arranca además en la pestaña Settings, con 8
componentes y ningún editor.

Si al perfil `text_only` le falta alguna herramienta para el texto de los slides, se agrega ahí sin
tocar `custom_default`, que sigue intacto para el resto del builder.

---

## 13. Lista de trabajo: dónde aplicar lo mismo

Auditoría hecha recorriendo el **schema real** de cada bloque (no grep) y cruzándola con los **items
guardados** en la DB. El costo estimado usa el coeficiente medido: `componentes × items + editores × 20`
(un editor TipTap ≈ 20 inputs planos).

`editores~` = editores TipTap **en pantalla** = campos × 6 idiomas × items.

### Bloques de página (`app/Filament/Blocks/PageBlocks.php`)

| Prioridad | Bloque | comps | editores~ | items | tabs | Acciones |
|---|---|---|---|---|---|---|
| ✅ 1 | `cards_with_modal` | 65 | 96 → **8** | 8 | 4 | **A + B + C hechos** (perfil `minimal`, ver §14 y §20) |
| ✅ 2 | `carousel_icons_description` | 70 | 0 | 11 | 4 | **A + B hechos** (§16) |
| ✅ 3 | `gallery_icon_cards` | 70 | 0 | 11 | 4 | **A + B hechos** (§16) |
| ✅ 4 | `list_group_images` | 64 | 30 → **5** | 5 | 3 | **hecho** (§16) |
| ✅ 5 | `timeline_content` | 33 | 36 → **6** | 6 | 2 | **hecho** (§16) |
| ✅ 6 | `numbered_steps` | 58 | 36 → **4** | 3 | 3 | **hecho** (§16) |
| 🟠 7 | `accordion_modal_list` | 69 | 18 | 3 | 3 | A + B + C |
| 🟠 8 | `icon_info_cards` | 55 | 18 | 3 | 3 | A + B + C |
| 🟠 9 | `split_content` | 90 | 12 | 2 | 4 | A + B + C |
| 🟠 10 | `grid_image_cards` | 36 | 18 | 3 | 2 | A + B + C |
| 🟠 11 | `side_gallery_rich_text` | 36 | 18 | 3 | 2 | A + B + C |
| 🟠 12 | `carousel_image_title_button` | 63 | 0 | 6 | 3 | A + D |
| 🟡 13 | `carousel_title_description` | 38 | 0 | 9 | 2 | A + D |
| 🟡 14 | `carousel_image_title_desc` | 48 | 0 | 7 | 2 | A + D |
| 🟡 15 | `carousel_button_links` | 39 | 0 | 7 | 2 | A + D |
| 🟡 16 | `image_gallery` | 13 | 0 | 8 | 1 | D |
| 🟡 17 | `freq_asked_questions` | 64 | 6 | 1 | 3 | A + B + C |
| 🟡 18 | `page_header_breadcrumb` | 62 | 0 | 2 | 3 | A |
| 🟡 19 | `icon_feature_cards` | 42 | 0 | 3 | 2 | A |
| ⚪ 20 | `side_image_rich_text` | 35 | 6 | 1 | 2 | A + B + C |
| ⚪ 21 | `image_banner_text` | 32 | 6 | 1 | 2 | A + B + C |
| ⚪ 22 | `simple_rich_text` | 31 | 6 | 1 | 2 | B + C |
| ✅ | `hero_carousel_main` | 119 | 18 → **3** | 3 | 3 | **hecho** (A + B + C) |
| — | `youtube_video` | 12 | 0 | 1 | 1 | nada que hacer |

### Otros sitios

| Sitio | Qué tiene | Acciones |
|---|---|---|
| 🔴 **`MachineResource`** | **8 Tabs, 7 repeaters, 4 LanguageTabs, 2 TipTap** (`custom_default`) | A + B + C — es el resource más cargado del panel |
| 🟠 `BlogResource` | 3 Tabs, 3 repeaters, 1 LanguageTabs, 1 TipTap (`custom_default`) | A + B + C |
| 🟠 `FooterBlocks::contactAndSocialBlock` | 62 comps, 3 Tabs, 2 repeaters, 2 LanguageTabs | A |
| 🟡 `FooterBlocks::logoAndButtonBlock` | 51 comps, 2 Tabs, 2 LanguageTabs | A |
| 🟡 `ProductSectorResource` | 4 Tabs, 1 repeater, 2 LanguageTabs | A |
| 🟡 `ProductSectorBlocks::editorText` | 21 comps, **6 editores**, 1 LanguageTabs | B + C |
| 🟡 `PageResource` | 3 Tabs, 2 LanguageTabs | A |
| ⚪ `HeaderBlockRelationManager`, `NavMenuRelationManager` | 2 repeaters, 2 LanguageTabs cada uno | A si molesta |
| ⚪ `SectionButtonRedirect` | 1 LanguageTabs, **lo usan 10 bloques** | B (6 inputs por cada botón de cada item) |

### Las acciones

- **A — `LazyTabs::make('_x_tab', [...])`** en vez de `Tabs::make()->tabs([...])`. Pinta solo la
  pestaña activa. Cambio de una línea por bloque. Ver §9.
- **B — `LocaleTabs::make([...])`** en vez de `LanguageTabs::make([...])`, **solo para los campos
  caros** (TipTap y, si el bloque tiene muchos items, también los TextInput). Ver §11.
- **C — `->profile('text_only')`** en los TipTap que se multiplican por idioma/item. El perfil ya
  existe en `config/filament-tiptap-editor.php` y **no toca** `custom_default`. Fue la palanca más
  efectiva (−2 400 ms de navegador en el hero).
- **D — Menos items por bloque**: 11 items × 6 idiomas es lo que produce 501 inputs. Es decisión de
  contenido (partir en varios bloques), no de código.

⚠️ Después de cada B: **probar que al cambiar de idioma y guardar no se pierden las traducciones**.
Es el único riesgo real de todo esto.

---

## 14. `cards_with_modal` — A (LazyTabs) + C (perfil), sin B (a propósito)

Este bloque era el peor del panel y tenía **dos niveles de pestañas**: las de arriba
(Settings / Cards) y **unas por cada card** (Card View / Modal Content). Con 8 cards guardadas eso son
16 paneles y **96 editores** montados de una vez. Se aplicó `LazyTabs` **en los dos niveles** y el
perfil `text_only` a sus dos TipTap (`card_text` y `modal_content`). `LocaleTabs` se dejó fuera a
propósito, para ver cuánto da la pestaña por sí sola.

| | antes | ahora |
|---|---|---|
| **Abrir el modal** | **21 200 ms** · 9 566 KB · 96 editores · 326 inputs | **2 000 ms** · 481 KB · **0 editores** · 6 inputs |
| Entrar a la pestaña "Cards" | (venía incluido en los 21 s) | 11 133 ms · 3 958 KB · **48 editores** · 172 inputs |

Lecturas:

- **Abrir el bloque pasó de 21 s a 2 s** (−90 %), porque arranca en Settings y ahí no hay ni un
  editor. Si el editor solo viene a cambiar el ancho o el fondo, ya no paga nada.
- Trabajar con las cards cuesta **11 s**, y la causa está clara: quedan **48 editores** = 8 cards × 6
  idiomas × 1 panel. La pestaña por card ya bajó de 96 a 48; **lo que falta es el ×6 de idiomas**, que
  es exactamente la acción **B** (`LocaleTabs` → 8 editores) o **D** (menos cards por bloque).
- Verificado que la pestaña de cada card es **independiente**: cada botón apunta al `_card_tab` de su
  propio item (`modal_cards.<uuid>._card_tab`), así que abrir el "Modal Content" de una card no cambia
  las otras.
- Tras la petición principal llegan ~9 peticiones más de ~1,3 MB en total: son los editores TipTap
  inicializándose. Otro motivo por el que su número es lo que manda.

---

## 15. `LocaleSwapEditor` — un editor para los 6 idiomas, cambiando en el cliente

**El cambio de idioma dejó de pasar por el servidor: 47 ms y 0 peticiones.**

`App\Filament\Components\LocaleSwapEditor` + la vista
`resources/views/filament/forms/components/locale-swap-bar.blade.php`.

```php
LocaleSwapEditor::make('card_text', 'Card Text')   // en vez de LanguageTabs + TiptapEditor
```

### Lo que lo hace posible

El plugin de TipTap **ya vigila su propio estado**: en su `plugin.js` hace
`this.$watch('state', … updateEditorContent(newState))`, y ese `state` está `$entangle`d con el
`statePath` del campo. Es decir: **si se escribe ese estado en el cliente, el editor se repinta solo**,
sin ir al servidor.

Con eso, la pieza es simple:

- **Un** `TiptapEditor` en un path de trabajo (`<campo>__active`), con `dehydrated(false)`: nunca se
  guarda.
- Los 6 paths reales (`<campo>.en`, `<campo>.es`, …) como `Hidden` **no renderizados pero sí
  deshidratados** (`visible(false)` + `dehydratedWhenHidden()`): el JSON guardado conserva su forma y
  no se pierde ninguna traducción.
- La barra de idiomas es Alpine puro. Al cambiar hace dos `$wire.set(..., false)` —**diferido, sin
  petición**—: guarda lo que hay en el idioma que se deja y carga el idioma que se elige.
- Mientras se escribe, un `$wire.$watch` del path de trabajo copia el contenido al idioma activo,
  también diferido, así que un guardado normal escribe todos los idiomas.

### Medido en el navegador (bloque `cards_with_modal`, 8 cards)

| | antes de todo | LazyTabs + perfil | + LocaleSwapEditor |
|---|---|---|---|
| Abrir el modal | 21 200 ms · 9 566 KB · 96 editores | 2 000 ms · 481 KB · 0 editores | **2 000 ms · 495 KB · 0 editores** |
| Entrar a "Cards" | (incluido arriba) | 11 133 ms · 3 958 KB · 48 editores | **9 398 ms · 1 619 KB · 8 editores** |
| **Cambiar de idioma** | re-render completo | re-render completo | **47 ms · 0 peticiones** |

Y el schema por card pasó de **12 editores definidos a 2** (uno por campo).

### Verificado con el estado real de Livewire

- Escribir con **EN** activo deja el contenido en `card_text.en` y **no** contamina `card_text.es`.
- Un valor puesto a mano en `card_text.it` aparece en el editor al pulsar **IT** (47 ms, 0 peticiones).
- Los **6 idiomas siguen presentes** en el estado después de saltar entre ellos.
- Las pruebas se descartaron recargando: **nada se escribió en la DB**. Respaldo del bloque en
  `storage/app/tmp/block-91-backup.json`.

⚠️ **Falta la única prueba que no puedo hacer yo: un guardado real** desde el panel, editando en dos o
tres idiomas y verificando después en la DB. Los `$wire.set` diferidos viajan en la siguiente
petición (la de guardar), así que debería estar bien, pero es exactamente el punto donde un fallo se
traduce en traducciones perdidas.

Lo que queda en los 9,4 s de la pestaña "Cards" ya no son los editores: son las 8 cards con sus
repeaters, sus uploads y **~1,3 MB de previews de imagen que Filepond descarga** (una petición por
card).

### La barra de idiomas: dos detalles que salieron al verla en pantalla

1. **Los botones salían de 22×24 px.** Alpine **reemplaza** el atributo `style` estático cuando el
   elemento tiene `:style`, así que el padding y el `min-width` se perdían. Todo el estilo se unificó
   dentro del `:style`. Ahora son botones de **60×34 px** (80 el de ZH-CN) en un control segmentado,
   con el activo en el color primario del panel. Los estilos van con las variables de color de
   Filament (`--primary-600`, `--gray-400`) para que funcione igual en claro y en oscuro, y las dos
   reglas de hover/separador viven en el CSS del panel (`AdminPanelProvider`) y no en el blade, porque
   el componente se repite 16 veces en este bloque.
2. **`wire:key` en la barra, y no es cosmético.** Sin él, el morph de Livewire **reutilizaba el nodo
   para otra card** y el `x-data` sobrevivía con los paths y el idioma de la anterior: se veía "PT"
   activo mientras el editor mostraba el idioma por defecto y, al escribir, se habría guardado **en la
   card equivocada**. Con `wire:key="locale-swap-bar-{{ $getStatePath() }}"` (incluye el uuid del item)
   el nodo nunca se reutiliza. Verificado: las 8 barras abren en `en` y cambiar el idioma en una **no
   toca** las otras.

Sin etiqueta "Language": los botones se explican solos.

### Aplicado también al bloque hero (reciclando el componente tal cual)

No hubo que escribir nada nuevo: `LocaleSwapEditor` es **una línea por campo**.

```php
LocaleSwapEditor::make('text_content', 'Main Content (Title & Description)')
```

El schema del hero pasó de **6 editores definidos a 1** (112 componentes), lo que con 3 slides son
3 editores en pantalla, y el cambio de idioma dejó de ir al servidor.

| Bloque hero | inicio | LazyTabs + LocaleTabs + perfil | + LocaleSwapEditor |
|---|---|---|---|
| Editores en pantalla | 18 | 3 | 3 |
| Respuesta al entrar a "Carousel Slides" | 2 515 KB | 1 131 KB | **1 092 KB** |
| Tiempo de ese cambio de pestaña | 5 185 ms | 1 467 ms | 867–2 997 ms (varianza alta) |
| **Cambiar de idioma** | re-render completo | re-render completo (~850 ms) | **0 peticiones** |

Verificado con un valor distintivo puesto a mano en `text_content.it`: al pulsar IT el editor lo
muestra, **sin peticiones**, y las otras dos barras se quedan en `en` (cada slide tiene su propio
idioma activo). Las pruebas se descartaron recargando; comprobado con un SELECT que **ningún texto de
prueba llegó a la DB**.

### Qué usar en cada caso

| Situación | Componente |
|---|---|
| Campo **rich text** (TipTap) traducible | **`LocaleSwapEditor`** — un editor, cambio instantáneo |
| Campos **planos** traducibles (TextInput, Textarea) que se multiplican por idioma | **`LocaleTabs`** — un idioma renderizado (el cambio sí va al servidor) |
| Pestañas de un bloque | **`LazyTabs`** |

`LocaleTabs` quedó sin uso al pasar los dos bloques a `LocaleSwapEditor`, pero **se conserva a
propósito**: es la herramienta para los campos planos (p. ej. `carousel_icons_description`, con 11
items × 6 idiomas de TextInput = 501 inputs), donde no hay editor TipTap del que colgarse.
`LocaleSwapEditor` **solo sirve para TipTap**, porque lo que lo hace instantáneo es el
`$watch('state')` del propio plugin.

---

## 16. Los 5 bloques restantes de la cabecera de la lista

Aplicado en `PageBlocks.php`, reciclando los mismos tres componentes:

| Bloque | Qué se le puso | Editores definidos | Componentes del schema |
|---|---|---|---|
| `carousel_icons_description` | `LazyTabs` ×2 (arriba + las dos caras de cada card) + `LocaleTabs` ×2 | 0 → 0 | 70 → 70 |
| `gallery_icon_cards` | igual que el anterior | 0 → 0 | 70 → 70 |
| `list_group_images` | `LazyTabs` + `LocaleSwapEditor` | 6 → **1** | 64 → 57 |
| `timeline_content` | `LazyTabs` + `LocaleSwapEditor` (conserva perfil `minimal`) | 6 → **1** | 33 → 26 |
| `numbered_steps` | `LazyTabs` + `LocaleSwapEditor` ×2 | 12 → **2** | 58 → 40 |

En los dos primeros el schema no baja de tamaño porque `LocaleTabs` también crea las 6 copias como
componentes: **la ganancia está en lo que se renderiza**, no en lo que se construye. Y ahí los campos
son planos (TextInput / Textarea), así que **no** aplica `LocaleSwapEditor` —el truco depende del
`$watch` del plugin de TipTap—, y el cambio de idioma sí va al servidor.

### Medido en el navegador: `carousel_icons_description` (11 items)

| | antes | ahora |
|---|---|---|
| Abrir el modal | **6 734 ms** · 501 inputs | **1 999 ms** · 476 KB · **6 inputs** |
| Entrar a "Carousel Items" | (incluido arriba) | 2 297 ms · 2 293 KB · **213 inputs** |

De **6,7 s de golpe** a **2 s para abrir** y otros 2,3 s solo si entras a los items — que además ahora
tienen sus dos caras separadas (`Front Side` / `Back Side`), así que cada card monta la mitad.

⚠️ Igual que en los otros: **falta probar un guardado real** en cada bloque, sobre todo en los dos que
usan `LocaleTabs` (los idiomas ocultos dependen de `dehydratedWhenHidden()`).

---

## 17. Dos bugs de `LocaleSwapEditor` encontrados probándolo de verdad

Reportado desde el panel: *"escribí texto en inglés, cambié a español y me seguía mostrando el
inglés, cuando debería estar vacío"*. Eran **dos** fallos, y el segundo era el peligroso:

1. **Un idioma vacío no limpiaba el editor.** El plugin hace `editor.commands.setContent(content, true)`
   y **TipTap ignora `setContent(null)`**, así que el editor se quedaba con el texto anterior. Ahora
   un idioma vacío se carga como **documento vacío** (`{type:'doc',content:[{type:'paragraph'}]}`), y
   el propio plugin lo normaliza de vuelta a `null` en su `onUpdate` (`editor.isEmpty`), así que **no
   se guarda un doc falso** que rompería el fallback de `translate_data()` en el frontend.
2. **El espejo escribía en el idioma equivocado.** `this.locale = locale` estaba **después** de tocar
   el editor, así que el `$watch` del path activo se disparaba con el idioma viejo todavía puesto y
   metía el contenido nuevo ahí — **borrando lo que se acababa de guardar**. Ahora el idioma se cambia
   ANTES de escribir el editor.

Verificado en el navegador con el bloque `list_group_images`, y con **0 peticiones** en todo el flujo:

| Paso | Editor | estado `en` | estado `fr` |
|---|---|---|---|
| Escribo "TEXTO-EN-ONLY" con EN activo | TEXTO-EN-ONLY | con el texto | — |
| Cambio a ES (que sí tenía contenido) | "Nuestros productos" | **intacto** | — |
| Vuelvo a EN | TEXTO-EN-ONLY | intacto | — |
| Cambio a FR (vacío a propósito) | **vacío** | intacto | sigue `null` |
| Vuelvo a EN | TEXTO-EN-ONLY | intacto | `null` |

Las pruebas se descartaron recargando; comprobado con un SELECT que no llegó nada a la DB.

---

## 18. El bug de verdad: el formulario de **CREAR** no tiene estado todavía

El reporte era en `list_group_images` → "Main Section Title", **en el modal de crear**, no de editar. Ahí
las claves del estado (`section_title__active`, `section_title.en`, …) **no existen**: la sección del
bloque se construye por tipo, *después* del `fill()`, así que nunca se hidrataron. Con eso:

- `$wire.get(activePath)` devolvía `undefined` → al cambiar de idioma se guardaba **null** en el
  idioma que se dejaba: **se perdía lo escrito**.
- `$wire.$watch(activePath, …)` sobre una clave inexistente **no disparaba**, así que teclear y
  guardar **sin** cambiar de idioma también perdía el texto.

Reescrito para **hablar con el editor y no con el estado de Livewire**:

- `editor()` busca el componente Alpine del TipTap (`[x-data^="tiptap("]`) subiendo desde la barra.
  Su `state` es la verdad de lo que hay en pantalla, exista o no la clave en Livewire, y escribirlo
  dispara su propio `$watch` (repinta) y su `$entangle` (sincroniza).
- El espejo va por **sondeo del `updatedAt`** que el plugin toca en cada cambio (400 ms, comparando un
  entero), y no por `Alpine.effect` sobre `state`: la reactividad entre componentes no disparaba de
  forma fiable. `destroy()` limpia el intervalo.

Verificado en el **modal de crear**, con 0 peticiones:

| Paso | Editor | `en` | `es` |
|---|---|---|---|
| Teclear "MIRROR-OK" en EN **sin cambiar de idioma** | MIRROR-OK | **guardado** | — |
| Cambiar a ES | **vacío** | intacto | `null` |
| Teclear "TEXTO-ES-99" en ES | TEXTO-ES-99 | intacto | guardado |
| Volver a EN | MIRROR-OK | intacto | intacto |

El bloque de prueba se descartó recargando; comprobado con un SELECT que no quedó nada en la DB.

---

## 19. Conversión completa del panel

Para no reescribir 30 sitios a mano, primero se hizo `LazyTabs` **fluido como `Tabs`** (extiende
`Group`, y `tabs()` acepta array, `Collection` o closure). Con eso la conversión fue un renombrado que
respeta modificadores (`->columnSpanFull()`) y los tabs por idioma que los resources construyen con
`collect(...)->map(...)`:

```php
Tabs::make('Config')->columnSpanFull()->tabs([...])        // antes
LazyTabs::make('Config')->columnSpanFull()->tabs([...])    // ahora
```

La clave del switch se deriva del título (`_tab_split_content_configuration`), y la forma posicional
antigua (`LazyTabs::make('_hero_tab', [...])`) sigue funcionando.

| Paso | Resultado |
|---|---|
| `LanguageTabs::make` → `LocaleTabs::make` | **35 sitios en 9 archivos**; no queda ninguno |
| `Tabs::make` → `LazyTabs::make` | **34 sitios en 9 archivos**; **0 `Tabs` de Filament** en los 24 bloques |
| `LocaleTabs` + un solo TipTap → `LocaleSwapEditor` | **7 editores** más (6 en `PageBlocks`, 1 en `ProductSectorBlocks`), con su `helperText` y su `disk` preservados |

Quedan **3 bloques** con el editor mezclado con TextInputs dentro del mismo `LocaleTabs`
(`accordion_modal_list`, `freq_asked_questions`, `icon_info_cards`): ahí solo se renderiza un idioma
igual, pero el cambio de idioma pasa por el servidor. Separarlos pondría **dos selectores de idioma**
en la misma card, así que se deja como decisión de UX.

### Regresión de la migración de slugs encontrada de paso

`/admin/machines/{id}/edit` y `/admin/blogs/{id}/edit` daban **500**:
`select name, id from machine_categories` — la columna `name` de las categorías ya no existe (vive en
`slugs`), y `Select::relationship('machineCategory', 'name')` / `pluck('name','id')` la consultan por
SQL. Resuelto pasando el nombre por PHP (`getOptionLabelFromRecordUsing` + `options()` con
`slugLabel()`), que además respeta el idioma. Nada que ver con el rendimiento; llevaba roto desde la
migración porque esas pantallas no se habían abierto.

### Humo autenticado tras todo el cambio

`/admin`, listados y **crear/editar** de pages, machines, blogs, product sectors, blog categories,
machine categories, divisions y shared sections: **200**. Sitio público (`/`, `/about`,
`/machinery/water-looms/detail`, `/products/itg-line`): **200**.

---

## 14. Regresión de `LazyTabs`: el panel oculto se salta las mutaciones de estado del repeater

Detectada en producción de contenido el 2026-08-21, unos 30 minutos después de desplegar §9.
**Síntoma:** en `Slide Button Configuration` el texto del botón volvía vacío, y aparecían **dos
inputs de idioma a la vez** (EN y ES). En el sitio público el botón simplemente **desaparecía**.

### La suposición que estaba mal

§9 cerraba con esto:

> La trampa del `dehydratedWhenHidden()` está probada a nivel de **campo** […]. A nivel de
> **contenedor** el arnés no se deja ejercitar, pero es el mismo camino de código.

**No es el mismo camino.** El flag sí conserva el estado del panel oculto, pero al deshidratarse como
oculto **no se aplican las mutaciones de estado propias de los componentes de dentro**. Y seis
repeaters de `PageBlocks` dependían justamente de eso:

```php
Repeater::make('slide_button_wrapper')->maxItems(1)
    ->mutateDehydratedStateUsing(fn ($state) => empty($state) ? null : array_values($state)[0])
    ->formatStateUsing(fn ($state) => empty($state) ? [] : [$state])
```

Ese par es lo que traduce entre **la lista que necesita un repeater** y **el objeto único que lee el
blade** (`hero-01.blade.php`: `$slider['slide_button_wrapper']['button_slide']`). Guardando desde una
pestaña que no fuera la del repeater, el desempaquetado no corría y se escribía el **array crudo**.

### Por qué empeoraba con cada guardado

El formulario esperaba objeto y encontraba lista, así que pintaba el botón vacío; al guardar volvía a
envolver, dejando lo anterior un nivel más abajo. En el bloque 37 se encontraron **tres generaciones**:

| | Forma guardada |
|---|---|
| gen 0 (correcta) | `button_slide: {locale, redirect, button_text}` |
| gen 1 | `button_slide: {…, button_slide: {_locale:"en"}}` — se auto-anidó |
| gen 2 | `[ { 0: <gen 1>, button_slide: {todo null} } ]` — ya como lista |

Los dos inputs de idioma eran **consecuencia**, no otro bug: `LocaleTabs` mantiene en pantalla un
idioma de `required_locales` mientras esté vacío (§5), así que con `button_text.en` en blanco se
pintaban el EN obligatorio **y** el activo.

### El arreglo

La conversión ya no vive en el repeater, porque ahí la puede saltar la deshidratación. Vive en
**`App\Support\PageBlocks\SingleItemWrapper`** (`expand()` / `collapse()`, las dos idempotentes) y
se llama desde los hooks de datos del formulario, que reciben el array completo y **no dependen de la
visibilidad de ningún componente**:

- `PageBlockForm::toFormState()` / `toModelState()` — bloques de página.
- `EditSharedSection::mutateFormDataBeforeFill/BeforeSave` y
  `CreateSharedSection::mutateFormDataBeforeCreate` — las secciones compartidas montan los mismos
  schemas pero por un `Group` con `statePath('content')`, así que necesitan sus propios hooks.

Las 6 llaves afectadas: `slide_button_wrapper`, `box_button_wrapper`, `button_wrapper` (×2),
`card_link_wrapper`, `redirection_settings`.

### Reparación de lo ya guardado

`php artisan page-blocks:repair-wrappers [--dry-run]` recorre `content_block_pages`,
`shared_sections` y `footer_blocks`, recoge los candidatos enterrados en el anidamiento, se queda con
**el que tiene contenido de verdad** (descarta el hermano vacío que escribió el guardado roto) y
limpia los restos de solo-UI (`{"_locale":"en"}` colgando bajo su propia llave). Es idempotente y
**no toca `updated_at`**, porque es una reparación de forma, no una edición.

### Verificado

- Dry-run: 62 filas, **solo el bloque 37**, 6 wrappers. Aplicado y segunda pasada: **0 cambios**
  (idempotente); `updated_at` sigue en `12:10:15`.
- Forma resultante: `slide_button_wrapper.button_slide.{locale,_locale,redirect,button_text}`, con
  `button_text.en = "WHO WE ARE"` y `redirect = {"type":"internal","value":"slug:70"}`.
- Ida y vuelta sobre los datos reales: `collapse(expand($stored)) === $stored`, y las dos idempotentes.
- Sitio público `/`: los 3 botones de slide (`→ /about`) y los 3 de feature box (`→ /products`,
  `/machinery/new-machinery`) vuelven a salir.
- Panel: 3 inputs, **uno por slide y solo del idioma activo**, con valor `WHO WE ARE` y su label.

⚠️ **Regla para el futuro:** dentro de un panel de `LazyTabs` (o de cualquier contenedor con
`dehydratedWhenHidden()`), **no confiar en `mutateDehydratedStateUsing` ni en `formatStateUsing`**.
Si un campo necesita transformar su valor al guardar, hacerlo en los hooks de datos del formulario.

---

## 20. `LocaleTabs` sin round-trip (2026-09-24)

Motivado por un caso concreto: "Slide Button Configuration" (un `TextInput` simple) se sentía
igual de lento que un `TiptapEditor`, aunque el bloque hero ya estuviera optimizado. Causa: el
`LocaleTabs` de entonces (§3) seguía usando el mecanismo de `Action` + Hidden `_locale` +
`visible()` — servidor de por medio en cada cambio de idioma, igual que el `LanguageTabs`
original, solo que renderizando 1 copia en vez de 6.

**La idea:** para un campo **plano** (no TipTap), duplicar 6 copias es casi gratis — a diferencia
de un editor, no arrastra su propio plugin JS. Así que en vez de que Filament oculte 5 copias
(`visible()`+`dehydratedWhenHidden()`, servidor), las 6 quedan **siempre reales para Filament**
(se validan y deshidratan como si todas estuvieran a la vista) y solo se pinta una con `x-show` de
Alpine — 0 peticiones, sin la trampa de `dehydratedWhenHidden()` que ya rompió datos una vez
(§14 de más arriba).

### Dos bugs encontrados al aplicarlo, ninguno relacionado con el mecanismo en sí

1. **Dos `LocaleTabs` en el mismo `Section` compartían la clave `_locale`** (el nombre por defecto
   del viejo Hidden), así que cambiar el idioma en uno saltaba el otro también
   (`PageResource::form()`, "Page Information" + "SEO Information"). Con el diseño nuevo esto **no
   puede pasar**: no hay ningún campo compartido, cada instancia tiene su propio `x-data` aislado
   en su propio nodo del DOM.
2. **El campo visible aparecía en un lugar distinto según el idioma.** Filament envuelve cada
   entrada del schema en su propia fila de grid (`<x-filament::grid.column>`,
   `component-container.blade.php`), y el `gap-6` de ese grid se aplica entre **todas** las filas
   sin importar que 5 midan 0px de contenido — con 8 filas (barra + 6 idiomas + Hidden de
   compatibilidad) eso son 7 gaps fijos, y el idioma visible "empuja" según cuántos de los
   anteriores sean 0-alto-pero-igual-de-fila. Medido: la altura del grid pasaba de 246px (variable
   según idioma) a 102px constante al resolverlo.

   Se intentó forzar `grid-row:1;grid-column:1` en el `extraAttributes()` del propio componente, y
   **no hizo nada**: ese estilo cae en un `<div>` interno del componente, no en el
   `<x-filament::grid.column>` real que genera Filament (un nivel más arriba), que es el que de
   verdad participa del grid — no hay forma pública de tocarlo desde un componente hijo.

   **Lo que sí funcionó:** CSS puntual en `AdminPanelProvider`, `gap: 0` acotado por selector al
   `x-data` exacto que escribe este componente (`[x-data^="{ locale:"] > .fi-fo-component-ctn`), y
   un `margin-top` en **cada copia** (no en la barra) para que la separación visual no se pierda ni
   se duplique cuando `LocaleTabs::make()` lleva más de un campo (ej. seo_title + seo_description):
   un campo oculto (`display:none`) nunca aplica su margen, así que solo separa lo que
   efectivamente está en pantalla de lo que tiene justo arriba, sea la barra o el campo anterior.

### La trampa que sí importaba: TipTap mezclado con texto plano en el mismo `LocaleTabs`

3 bloques (`icon_info_cards` ×2, `accordion_modal_list`, `freq_asked_questions` en
`PageBlocks.php`, y `complementaryAccessories` en `MachineResource.php`) metían un
`TiptapEditor` **dentro** del mismo `LocaleTabs::make([...])` junto a `TextInput`s. Con el
mecanismo nuevo (todo real, nunca Filament-hidden) eso monta **los 6 editores TipTap reales a la
vez** — exactamente el problema original, ×6. Se separó el TipTap a `LocaleSwapEditor` en los 5
sitios, dejando el texto plano en `LocaleTabs`. Costo: esos campos pierden el `required()` que
tenían (mismo límite ya aceptado en el resto de los TipTap del proyecto que usan
`LocaleSwapEditor`, que no expone esa opción).

Probado con guardado real (no solo en pantalla, contra la DB): `PageResource` Page
Information/SEO, hero (Slide/Box Button Configuration) e `icon_info_cards` (caso mixto
`LocaleTabs` + `LocaleSwapEditor` en la misma card) — los 6 idiomas sobreviven.

**Pendiente, mismo alcance:** `MachineResource:591` (representantes por división) y 3 sitios de
`ProductSectorBlocks` (`officialRepresentative`, `imagesGrid`, `Carousel`) reusan `LazyTabs` a mano
para simular pestañas de idioma (`Tab::make(strtoupper($locale))` por cada uno) — mismo
round-trip lento del `LocaleTabs` viejo, pero sin TipTap de por medio (sin el riesgo de los 6
editores). No se tocó.

### Perfil `minimal` ampliado, para repeaters con muchos TipTap

Con `LocaleTabs` ya resuelto, quedó pendiente `cards_with_modal`: `LocaleSwapEditor` ya estaba
aplicado (B, §14/§15 — la tabla de §13 estaba desactualizada, decía "falta B"), pero cambiar a
"Modal Content" de una card seguía sintiéndose lento. Medido: **1.65 MB, 1282 ms** solo por ese
cambio de pestaña.

Se confirmó contra los **96 documentos reales** guardados en `card_text`/`modal_content` de todos
los bloques `cards_with_modal` existentes que ninguno usa heading, listas, blockquote, tabla,
imagen ni ninguna herramienta de panel — solo texto simple (a veces con una clase CSS fija de
estilo, no un mark del editor). Cambiar el campo a un `Textarea` habría sido más rápido todavía,
pero se descartó: esa clase CSS vive en la estructura `<p class="...">` del documento TipTap, y un
`Textarea` no puede representarla — habría que migrar los 96 documentos.

En su lugar, se amplió el perfil `minimal` (`config/filament-tiptap-editor.php`, ya existía y solo
lo usaba `content` en el bloque de eventos) agregándole `underline` y `strike`
(`bold, italic, underline, strike, link, bullet-list, ordered-list`), y se aplicó a `card_text` y
`modal_content` de `cards_with_modal`. Medido después: **1.4 MB, 1500 ms** — una mejora real pero
**modesta** (~15% menos datos), no la caída dramática que este mismo perfil dio en el hero
(§12: −2400ms de navegador).

**Por qué la ganancia es chica acá y grande en el hero:** en el hero, el editor pesado era casi
todo el costo de esa pestaña. Acá el "piso" documentado en §12 domina — el modal completo *y* la
tabla de 17 bloques del relation manager se re-renderizan juntos en cada cambio, y ningún recorte
de un editor puntual baja ese piso. Sigue siendo un cambio gratis y sin riesgo (menos JS por
editor, verificado contra los datos reales), así que se deja aplicado — pero no resuelve el fondo.
Bajar el piso de verdad requeriría la arquitectura de §6/§7 (sacar el editor del componente que
tiene la tabla), ya probada una vez sin éxito suficiente.

**Regla para repeaters con muchos `TiptapEditor` (varios items × varios campos de texto rico):**
usar `minimal` (o `text_only` si hace falta más formato) en vez de `custom_default`, y verificar
primero contra los datos reales guardados qué herramientas hacen falta de verdad — no asumirlo.
