# 07 — Frontend

---

## 1. Stack

| Capa | Tecnología | Ubicación |
|---|---|---|
| Plantilla | **Metronic 8.0.23 – Demo 1** | `public/metronic_html_v8.0.23_demo1/` |
| CSS framework | Bootstrap 5 (dentro del bundle de Metronic) | `assets/plugins/global/plugins.bundle.css`, `assets/css/style.bundle.css` |
| CSS propio | **Un solo fichero, 7 329 B** | `public/assets/kuorum-metronic-demo1.css` |
| JS framework | jQuery + Bootstrap 5 + KTApp (Metronic) | `assets/plugins/global/plugins.bundle.js`, `assets/js/scripts.bundle.js` |
| Tablas | DataTables (server-side) | `assets/plugins/custom/datatables/datatables.bundle.{css,js}` |
| Selectores | Select2 (`data-control="select2"`) | dentro del bundle |
| Iconos | Bootstrap Icons (`bi bi-*`) | dentro del bundle |
| Gráficas | ApexCharts | dentro del bundle |
| Tipografía | Poppins (Google Fonts) | CDN |
| Traducción DataTables | `https://cdn.datatables.net/plug-ins/1.11.3/i18n/es-mx.json` | CDN |
| Motor de plantillas | **Ninguno.** PHP plano | `app/views/` |
| Build tooling | **Ninguno.** No hay `package.json`, ni bundler, ni transpilación | — |

> Todo el JavaScript de la aplicación se genera **desde PHP**, principalmente en `core/ListaAjax.php`, o se escribe inline en las vistas. Solo hay tres ficheros `.js` propios: `public/js/quick-search.js`, `core/ajax.js` y `core/check_sessions.js`.

---

## 2. Estructura de una página

```mermaid
flowchart TD
    L["app/layouts/metronic.php"] --> H["&lt;head&gt;: meta, favicon,\nQueueCss::getBefore(),\nplugins.bundle.css, style.bundle.css,\ndatatables.bundle.css,\nkuorum-metronic-demo1.css,\nQueueCss::getAfter(),\nplugins.bundle.js, scripts.bundle.js,\nQueueScripts::getBefore()"]
    L --> A["#kt_aside\n→ layouts/menus/metronic.php"]
    L --> HD["#kt_header\n→ layouts/shortcuts/metronic.php\n(quick-search + usuario + filtro de periodo)"]
    L --> M["main#kt_content"]
    M --> F["layouts/flashes/metronic.php"]
    M --> C["include \$content\n(app/views/&lt;carpeta&gt;/&lt;vista&gt;.php)"]
    M --> LG["layouts/logs/metronic.php"]
    L --> FT["#kt_footer"]
    L --> AL["alerts/metronic.php"]
    L --> AI["_asistente_ia_widget.php"]
    L --> S["datatables.bundle.js\nQueueScripts::getAfter()"]
```

El portal del colaborador (`metronic_public.php`) tiene la misma estructura con `menus/metronic_public.php` y `shortcuts/metronic_public.php`.

### Variables que el layout espera

Las inyecta `Controller::loadMetadata()`; el layout las consume tras el `extract()` de `View::render_view()`:

`$meta`, `$branding`, `$application`, `$Menu`, `$Shortcuts`, `$appColor`, `$controllerName`, `$currentAction`, `$parameters`, `$permission`, `$subtituloSection`, `$content`, `$menu_route`, `$shortcut_route`, `$flashes_route`, `$logs_route`.

El layout escapa con un closure local:

```php
$layoutEsc = static function ($value) {
    return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
};
```

---

## 3. Convenciones de vistas

### Contenedor estándar del backoffice

```php
<div id="kt_content_container" class="container-xxl">
    <!-- contenido -->
</div>
```

### Tarjeta de listado

```php
<div id="kt_content_container" class="container-xxl">
    <div class="card">
        <div class="card-header d-flex justify-content-between align-items-center">
            <h3 class="card-title m-0">Competencias</h3>
            <?php if (($permission['create'] ?? 0) >= 1) { ?>
                <a href="<?php echo ROUTER::create_action_url($controllerName, 'create'); ?>" class="btn btn-primary">Nueva</a>
            <?php } ?>
        </div>
        <div class="card-body">
            <?php echo $listaHtml; ?>
        </div>
    </div>
</div>
```

### Formulario

```php
<form method="post" action="<?php echo ROUTER::create_action_url($controllerName, $currentAction); ?>">
    <input type="hidden" name="_csrf_token" value="<?php echo htmlspecialchars($csrfToken); ?>">
    <input type="hidden"
           name="<?php echo get_class($model); ?>[<?php echo $model->Id->getName(); ?>]"
           value="<?php echo $model->Id->getValue(); ?>" />

    <div class="card mb-5 mb-xl-10">
        <div class="card-header border-0 pt-5">
            <h3 class="card-title fw-bolder fs-3">Competencia</h3>
        </div>
        <div class="card-body border-top p-9">
            <div class="row mb-7">
                <label class="col-lg-3 col-form-label required fw-bold fs-6">
                    <?php echo $model->Nombre->getTitle(); ?>
                </label>
                <div class="col-lg-9">
                    <input type="text" class="form-control form-control-solid"
                           name="<?php echo get_class($model); ?>[<?php echo $model->Nombre->getName(); ?>]"
                           value="<?php echo htmlspecialchars((string)$model->Nombre->getValue()); ?>" required>
                </div>
            </div>
        </div>
        <div class="card-footer d-flex justify-content-end py-6 px-9">
            <a href="<?php echo ROUTER::create_action_url($controllerName, 'list'); ?>" class="btn btn-light me-3">Cancelar</a>
            <button type="submit" class="btn btn-primary">Guardar</button>
        </div>
    </div>
</form>
```

**Reglas no negociables del formulario:**

| # | Regla |
|---|---|
| 1 | El `action` se genera con `ROUTER::create_action_url()`, nunca a mano |
| 2 | `<input type="hidden" name="_csrf_token">` siempre |
| 3 | Campo oculto con el `Id` del modelo, aunque esté vacío |
| 4 | El `name` de cada input es `<?= get_class($model) ?>[<?= $model->Campo->getName() ?>]` |
| 5 | La etiqueta sale de `$model->Campo->getTitle()` (definido en `getOptionsAttributes()`) |
| 6 | El valor sale de `$model->Campo->getValue()`, **siempre con `htmlspecialchars()`** |
| 7 | Los checkbox envían `value="1"` y se marcan con `<?php if ((int)$model->Estado->getValue() === 1) echo 'checked'; ?>` |
| 8 | Botón Cancelar → `list`, botón Guardar → `submit` |

### Parciales

```php
<?php View::load_view('competencias/_form', compact('controllerName', 'currentAction', 'model', 'csrfToken')); ?>
```

`View::load_view($viewName, $parameters, $folder = 'app/views/')` hace `extract($parameters)` e incluye el fichero. Si no existe, registra `'Vista no encontrada :: …'` en `LogsConsole` **sin lanzar error**.

Convención: los parciales llevan prefijo `_` (`_form.php`, `_headboard.php`).

### Renderizar un fragmento a string (para AJAX)

```php
echo View::stream_view('home/seleccion_periodo_modal', $parameters, 'clear', 'home');
```

`stream_view()` devuelve el HTML como cadena en lugar de imprimirlo. El layout `clear` no añade envoltorio.

---

## 4. DataTables: el sistema de listados

Es el componente frontend más importante y **se genera íntegramente desde PHP**.

### En el controlador

```php
protected function getListAjaxObject()
{
    $modelName  = 'CompetenciasModel';
    $fields     = array('Id', 'Nombre', 'Categoria', 'Global', 'Nivel');   // columnas consultadas
    $titles     = array('Nombre', 'Categoría', 'Global', 'Nivel');         // cabeceras visibles
    $fieldsShow = array('Nombre', 'Categoria', 'Global', 'Nivel');         // columnas mostradas
    $fieldsType = array('Enlace', 'Texto', 'Texto', 'Texto');              // render por columna

    $table = new ListaAjax($this->Module, $this->CurrentAction);
    $table->setData(NULL, $fields, $titles, $fieldsType, $fieldsShow);
    $table->setCheckbox(false);
    $table->setFiltersSesion(array());     // array() = sin filtros globales de sesión
    $table->setModel($modelName);          // ← nombre de clase, NO instancia
    return $table;
}
```

> ⚠️ `$titles` y `$fieldsShow` deben tener **la misma longitud**; `$fields` puede tener columnas adicionales que se consultan pero no se muestran (típicamente `Id` y `Estado`).
> `setModel()` recibe el **nombre de la clase como cadena**; `ListaAjax::generateDataListAjax()` hace `new $this->Model()`.

### En la vista

```php
<?php echo $listaHtml; ?>
```

`Controller::listAction()` ya rellena `$parameters['listaHtml'] = $table->getHtml()`.

### Métodos de configuración de `Lista` / `ListaAjax`

| Método | Efecto |
|---|---|
| `setData($data, $fields, $titles, $fieldsType, $fieldsShow)` | Configuración principal |
| `setModel($nombreClase)` | Modelo de origen |
| `setIdTable($id)` | Id HTML de la tabla (por defecto `Tabla1`) |
| `setActions($acciones)` | **Reemplaza** las acciones por defecto |
| `addActions($acciones)` | **Añade** a las existentes |
| `setPermission($permission)` | Permisos que gobiernan las acciones |
| `setCheckbox(bool)` | Columna de selección |
| `setCriteria($criteria)` | Criterio base |
| `setFilters($filtros)` | Filtros externos (inputs `#filtro-<nombre>`) |
| `setFiltersSesion($array)` | `array()` desactiva los filtros globales |
| `setState(bool)` | Muestra la leyenda Activo/Inactivo y consulta `Estado` |
| `setRegistrationField($campo)` | Campo de propiedad (por defecto `UsuarioRegistro`) |
| `disableResponsive()` / `disablePaging()` / `disableOrdering()` | |
| `addAlias($fieldShow, $valores)` | Traduce valores a etiquetas |
| `addTags($fieldShow, $etiquetas)` | Renderiza el valor como badge |
| `addParameter($nombre, $valor)` | Parámetro extra en las URLs de acción |

### Acciones por defecto

`Lista::__construct()` establece:

```php
[
  ['name'=>'Editar','controller'=>$controller,'action'=>'edit',
   'icon'=>'bi bi-pencil-fill fs-4','color'=>'btn-light-primary'],
  ['name'=>'Eliminar','controller'=>$controller,'action'=>'remove',
   'icon'=>'bi bi-trash-fill fs-4','color'=>'btn-light-danger',
   'confirm'=>['title'=>'Eliminar Registro',
               'text'=>'¿Está seguro de que desea eliminar este registro de forma permanente?']]
]
```

**La acción `remove` no se renderiza como enlace, sino como un formulario POST con token CSRF:**

```html
<form method="post" action="…?c=X&a=remove&Id=N" class="d-inline">
  <input type="hidden" name="_csrf_token" value="…">
  <button type="submit" class="btn btn-light-danger fw-bolder">Eliminar Registro</button>
</form>
```

Esto es lo que hace cumplible el guardia de `Controller::process()` que exige POST + CSRF para `remove`. Está cubierto por `tests/MutationSecurityRegressionTest::testLegacyRemoveActionsRequirePostAndCsrfAndListsSubmitThemAsForms`.

### Tipos de campo (`$fieldsType`)

| Valor | Render |
|---|---|
| `'Enlace'` | Enlace a `view`/`ver` del registro (solo si `permission['view'] > 0`), coloreado según `Estado` |
| `'Texto'` | Texto plano |
| `'Estado'` | Indicador de estado |

Otros valores se tratan como texto.

### Semántica de los valores de permiso

`ListaAjax` interpreta los permisos con **tres valores**, no dos:

| Valor | Significado |
|---|---|
| `0` | Sin acceso |
| `1` | Acceso total |
| `2` | **Solo los registros propios** — añade `WHERE {RegistrationField} = {usuarioActual}` al criterio y filtra las acciones fila a fila |

Este tercer nivel **solo lo implementa `ListaAjax`**; `Controller::validateAccess()` únicamente comprueba `> 0`.

### Flujo AJAX

```mermaid
sequenceDiagram
    participant B as Navegador
    participant DT as DataTables
    participant C as XxxController
    participant L as ListaAjax
    participant M as XxxModel

    B->>DT: carga la página, $listaHtml incluye el <script>
    DT->>C: POST ?c=Xxx&a=dataListAjax<br/>{draw, start, length, order, search, filtros}
    C->>L: getListAjaxObject()
    C->>L: generateDataListAjax()
    L->>L: loadCriteria() — traduce la petición a $criteria
    L->>M: getQuantityView(array())         → recordsTotal
    L->>M: getQuantityView($criteriaSinLimit) → recordsFiltered
    L->>M: getAllView($campos, $criteria)   → filas
    L->>L: renderiza cada celda + columna _Acciones
    L-->>DT: JSON {draw, recordsTotal, recordsFiltered, data[]}
    DT->>B: pinta la tabla
```

### Opciones de DataTables generadas

`serverSide: true`, `processing: true`, `responsive` configurable, idioma es-mx desde CDN, `lengthMenu` `[10,25,50,100,250,500,1000,5000,10000,50000]`, y botones de exportación: **print, copy, excel, csv, pdf**.

La exportación completa se maneja con `triggerFullExport()`, que pone `page.len(-1)`, dispara el botón y restaura la paginación.

### Filtros externos

```php
$table->setFilters(array('IdArea', 'Estado'));
```

Genera JS que, en cada recarga AJAX, añade al payload el valor de `#filtro-IdArea` y `#filtro-Estado`. La vista debe crear esos inputs, más los botones `#filtros-aplicar` y `#filtros-limpiar`.

### 🔴 Riesgos de seguridad en `ListaAjax::loadCriteria()`

Dos entradas llegan al SQL sin escapar:

```php
// 1) El cliente puede enviar un array de criterio arbitrario
if (isset($_REQUEST['criteriaExt']) && !empty($_REQUEST['criteriaExt'])) {
    $this->Criteria = (array) ($_REQUEST['criteriaExt']);
}

// 2) El término de búsqueda se concatena en un LIKE sin escapar
$CadenaBusqueda .= $this->FieldsShow[$j]." LIKE '%".$SeparadorBusqueda[$z]."%' OR ";
```

Ambas afectan a **todos** los endpoints `dataListAjax`. Ver [18_KNOWN_ISSUES.md](18_KNOWN_ISSUES.md) § HR-SQL-2 y HR-SQL-3.

---

## 5. Comunicación con el backend

### `core/ajax.js` — helper genérico

```javascript
ajax(url, datos, funcionRespuesta);
```

POST vía jQuery, `timeout: 10000`, hace `JSON.parse(data)` y añade `data.estado = "success" | "error"`. Si `navigator.onLine` es falso, invoca el callback con `{estado: "disconnected"}`.

> ⚠️ Vive en `core/`, **fuera de `public/`**. Para servirlo por HTTP el DocumentRoot tiene que estar en la raíz del proyecto, no en `public/` — lo que contradice la recomendación de seguridad del `.htaccess` raíz.

### Patrón `fetch` en las vistas modernas

Los módulos nuevos usan `fetch` inline con `FormData`, enviando siempre `_csrf_token`:

```javascript
const fd = new FormData();
fd.append('_csrf_token', CSRF_TOKEN);
fd.append('question', pregunta);
const r = await fetch('?c=AsistenteIA&a=ask', { method: 'POST', body: fd });
const j = await r.json();
```

### Endpoints que responden JSON

| Endpoint | Contenido |
|---|---|
| `?c=Xxx&a=dataListAjax` | Payload de DataTables |
| `?c=api&a=quickSearch&q=…&limit=…` | Búsqueda rápida |
| `?c=api&a=main` | `["API KLEE"]` |
| `?c=AsistenteIA&a=ask` / `a=clearHistory` | Asistente IA |
| `?c=ajax&a=…` | 6 endpoints AJAX habilitados |
| `?c=ElFinder&a=conector` | Protocolo elFinder |

Ver [08_API.md](08_API.md).

### Heartbeat de sesión

`core/check_sessions.js`:

```javascript
$.ajax({ url: base + '/check_session.php', method: 'GET',
  success: function (data) {
    if (data !== '1') { alert("La sesión ha expirado!…"); window.location.href = 'index.php'; }
  }});
```

---

## 6. Búsqueda rápida (quick-search)

- **JS:** `public/js/quick-search.js` (11 626 B), cargado por `layouts/shortcuts/metronic.php` y `layouts/shortcuts/metronic_public.php`.
- **Endpoint:** `?c=api&a=quickSearch&q=<texto>&limit=<1..20>`
- **Backend:** `QuickSearchModel::search()` sobre el catálogo de `app/config/QuickActionsConfig.php` (15 acciones).
- **Puntuación:** coincidencia de `label`, `keywords` y `commands`.
- **Permisos:** el catálogo se filtra por los permisos del usuario. Si el término coincide exactamente con una acción **bloqueada**, la respuesta incluye `blockedExactMatch: true` y el mensaje `"Sin permisos"` en lugar de ocultarla en silencio.

Estructura de la respuesta:

```json
{
  "query": "roles",
  "results": [
    {"id":"roles-listar","label":"Roles - Listar","module":"Configuración",
     "keywords":["perfiles","roles list"],"path":"…","command":"roles","has_deep_link":false}
  ],
  "blockedExactMatch": false,
  "message": "",
  "suggestions": [ … ]
}
```

### Añadir una acción al catálogo

En `app/config/QuickActionsConfig::getActions()`:

```php
array(
    'id'          => 'mimodulo-listar',
    'label'       => 'Mi Módulo - Listar',
    'keywords'    => array('mi modulo', 'listado'),
    'path'        => array('controller' => 'MiModulo', 'action' => 'list'),
    'module'      => 'Mi Grupo',
    'permissions' => array(array('controller' => 'MiModulo', 'action' => 'list')),
    'commands'    => array('mi modulo', 'mi modulo listar'),
),
```

---

## 7. Mensajes al usuario

### Flashes

```php
UserFlash::setFlash('Success', 'Se creó el registro correctamente.');
UserFlash::setFlash('Error',   'No fue posible guardar.');
UserFlash::setFlash('Warning', 'Datos incompletos.');
```

Se almacenan en sesión con prefijo `flashMessage` y los renderiza `app/layouts/flashes/{layout}.php`, que los consume y limpia. API: `getFlash($delete = true)`, `hasFlash()`, `emptyFlash()`, `getState()`, `setState()`.

Los estados usados en el código son `Success`, `Error` y `Warning`. `app/config/GeneralDataArray::$alertas` define además iconos para `Correcto`, `Error`, `Advertencia`.

### Confirmaciones

Las acciones destructivas de `ListaAjax` llevan `'confirm' => ['title'=>…, 'text'=>…]`, que se renderiza como formulario POST con el texto de confirmación.

### Consola de depuración

Con `APP_DEBUG=true`, `LogsConsole` acumula entradas (cada SQL ejecutado, vistas no encontradas, campos inválidos) y `app/layouts/logs/{layout}.php` las vuelca al `console.log` del navegador.

---

## 8. Modales

No hay un componente propio. Se usan modales de Bootstrap 5 escritos a mano en las vistas. El único patrón reutilizable es cargar el contenido por AJAX:

```php
// HomeController::modalSedeAction()
if (isset($_SERVER['HTTP_X_REQUESTED_WITH'])
    && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') {
    $parameters = $this->loadMetadata();
    $this->Module = 'home';
    $this->CurrentAction = 'index';
    echo View::stream_view('home/seleccion_periodo_modal', $parameters, 'clear', 'home');
    return;
}
ROUTER::redirect_to_action('home', 'index');
```

---

## 9. Menú

El menú **se calcula en el login**, no en cada petición:

```mermaid
flowchart LR
    A["Login correcto"] --> B["UsuariosModel::loadPermissions()\n→ SESSION['Permissions']"]
    B --> C["UsuariosModel::loadMenu(Menu::\$principal)"]
    C --> D["validarMenuItems():\npara cada ítem,\nROUTER::create_action_url(c,a) != '#' ?"]
    D --> E["SESSION['Menu'] = árbol filtrado"]
    E --> F["layouts/menus/metronic.php\n→ Menu::getMetronic(\$Menu, 0)"]
```

- `Menu::$principal` — backoffice.
- `Menu::$public` — portal del colaborador; se renderiza con `Menu::getMetronicPublic()`, que además evalúa `Condicion`.
- Única condición implementada: `'Condicion' => 'colaborador_lider_equipo'` → `ColaboradoresModel::esLiderEquipo($uid)`.
- El ítem activo se marca con `Menu::setActive('nombre')` desde el controlador y se lee con `Menu::getActive()`.

**Consecuencia importante:** cambiar `Menu.php` o los permisos de un rol **no se refleja hasta que el usuario vuelve a iniciar sesión**, porque el menú está congelado en `$_SESSION[APP_ID]['Menu']`.

### Estructura de un ítem

```php
array(
    "Nombre"   => "competencias",                   // clave para Menu::setActive()
    "Titulo"   => "Competencias",
    "Controller" => "Competencias",
    "Action"   => "list",
    "Icono"    => '<i class="bi bi-diagram-3-fill fs-2"></i>',
    // "SubMenus" => array( … )                     // grupo desplegable
    // "Condicion" => 'colaborador_lider_equipo'    // solo en Menu::$public
)
```

Un ítem con solo `"Titulo"` y sin `Controller` se renderiza como **separador de sección**.

---

## 10. Inyectar CSS/JS específico de una página

```php
// en el controlador, antes de View::render_view()
QueueCss::pushAfter(URL::base_url().'/assets/mi-modulo.css');
QueueScripts::pushAfter(URL::base_url().'/js/mi-modulo.js');
```

`pushBefore` los coloca antes de los bundles de Metronic; `pushAfter`, después.

---

## 11. Cómo construir una pantalla nueva

Siguiendo las convenciones existentes, paso a paso:

### 11.1 Listado

1. Crear `app/views/mi_modulo/list.php` con la tarjeta estándar y `<?php echo $listaHtml; ?>`.
2. En el controlador, implementar `getListAjaxObject()`.
3. En `listAction()`, llamar a `Menu::setActive('mi_modulo')` y luego `parent::listAction()`.
4. Declarar `'list' => '@'` y `'dataListAjax' => '@'` en `loadAccessControl()`.
5. Envolver `dataListAjaxAction()` en `try/catch` que devuelva JSON válido.

### 11.2 Formulario

1. Crear `app/views/mi_modulo/_form.php` con el patrón de §3.
2. Crear `create.php` y `edit.php`, ambos incluyendo el parcial con `View::load_view()`.
3. En el controlador, `createAction()` y `editAction()` según el patrón canónico de [06_BACKEND.md](06_BACKEND.md) §3.
4. Pasar `$parameters['csrfToken'] = Controller::generateCsrfToken()`.

### 11.3 Detalle

1. Crear `app/views/mi_modulo/view.php`.
2. `viewAction()` hace `loadById($_GET['Id'])` y renderiza.
3. Si tiene pestañas, usar `getTabs()` + `getCheckTabs()` para respetar `permisos.Tabs`.

### 11.4 Enlazarlo al menú

1. Añadir el ítem en `app/config/Menu.php` (`$principal` o `$public`).
2. Insertar filas en `permisos` para cada rol que deba verlo (o crear una semilla).
3. **Cerrar sesión y volver a entrar** para que el menú se recalcule.

### 11.5 Añadirlo a la búsqueda rápida (opcional)

Registrar la acción en `app/config/QuickActionsConfig::getActions()`.

---

## 12. Accesibilidad y responsive

- El layout declara `lang="es"` y `<meta name="viewport">`.
- `main#kt_content` tiene `tabindex="-1"` para permitir el salto al contenido.
- `Lista::$StyleResponsiveFields` aplica clases `hide-on-mobile-portrait`, `hide-on-mobile`, `hide-on-tablet-portrait`, `hide-on-tablet` por posición de columna.
- DataTables se configura con `responsive: true` salvo que se llame a `disableResponsive()`.
- `tests/PublicCollaboratorHomeViewTest::testNavigationIconsExistInBundledBootstrapIconsVersion` verifica que todos los iconos del menú existan en la versión de Bootstrap Icons incluida.

---

## 13. Escapado y XSS

**No hay escapado automático.** Cada vista es responsable.

| Contexto | Forma correcta |
|---|---|
| Texto en HTML | `<?php echo htmlspecialchars((string)$valor, ENT_QUOTES, 'UTF-8'); ?>` |
| Atributo HTML | Igual |
| Dentro de `<script>` | `<?php echo json_encode($valor, JSON_HEX_TAG \| JSON_HEX_AMP); ?>` |
| URL | `ROUTER::create_action_url()` (no escapa los valores; castea tú los parámetros) |

El layout usa el closure `$layoutEsc`. Las vistas nuevas usan `htmlspecialchars()`. **Las vistas antiguas no siempre lo hacen** — revisa antes de reutilizar una como plantilla.

La cabecera CSP que emite `core/AutoLoad.php` es:

```
Content-Security-Policy: default-src 'self' 'unsafe-inline' data:;
```

`'unsafe-inline'` es necesario porque `ListaAjax` genera `<script>` inline, pero anula buena parte de la protección de la CSP contra XSS.

---

## Documentos relacionados
- [06_BACKEND.md](06_BACKEND.md)
- [08_API.md](08_API.md)
- [14_DEVELOPMENT_GUIDELINES.md](14_DEVELOPMENT_GUIDELINES.md)
- [18_KNOWN_ISSUES.md](18_KNOWN_ISSUES.md)
