1. El reto: una demostración pedagógica sin riesgos de seguridad
En la portada de PonlOnline queríamos mostrar cómo una idea abstracta se transforma en un sitio web mediante HTML, CSS y JavaScript/Vue. Muchas herramientas interactivas de código en la web recurren a eval(), iframes dinámicos o inyecciones de cadenas en etiquetas <style> para reflejar los cambios en pantalla.
Aunque esas técnicas son válidas para entornos de prueba aislados (playgrounds completos), en una página comercial de producción introducen tres problemas críticos:
- Riesgos de seguridad y XSS: Ejecutar código arbitrario del usuario en el contexto principal del navegador expone la sesión y contamina el DOM.
- Sobrecarga de rendimiento: Crear y destruir iframes o recompilar hojas de estilo en cada pulsación de tecla genera retrasos perceptibles (layout thrashing).
- Falta de integración con la accesibilidad: La previsualización debe ser legible para tecnologías de asistencia y manejable íntegramente con teclado.
Decidimos diseñar un sistema cerrado y puramente reactivo compuesto por dos piezas: el almacén de estado en src/stores/experience.ts y el componente interactivo en src/components/CodeLab.vue.
2. Estado compartido con Pinia (src/stores/experience.ts)
El modo inspector no vive únicamente dentro de la caja de código. En PonlOnline, el botón «Inspeccionar» de la cabecera fija, el banner educativo tras la sección de proceso y el propio previsualizador necesitan leer y alternar el mismo estado.
Usar propiedades locales (ref dentro de HomePage.vue) habría obligado a propagar eventos y propiedades a través de múltiples niveles de componentes intermedios. Definimos un almacén centralizado usando la sintaxis de Setup Store de Pinia:
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useExperienceStore = defineStore('experience', () => {
const inspector = ref(false)
const activeTab = ref<'html' | 'css' | 'vue'>('html')
const accent = ref<'green' | 'violet'>('green')
const rounded = ref(true)
const idea = ref('Tu próxima gran idea')
function toggleInspector() { inspector.value = !inspector.value }
function resetDemo() {
accent.value = 'green'
rounded.value = true
idea.value = 'Tu próxima gran idea'
}
return { inspector, activeTab, accent, rounded, idea, toggleInspector, resetDemo }
})Ventaja de diseño:
Al aislar accent, rounded e idea en Pinia, la función resetDemo() permite restablecer el estado inicial en una única operación atómica sin que ningún componente quede desincronizado.
3. Sincronización reactiva sin inyección de código
En la pestaña style.css del editor, el usuario puede alternar la paleta de color entre verde y violeta, así como activar o desactivar los bordes redondeados.
En vez de parsear texto CSS dinámico, vinculamos las propiedades reactivas del almacén directamente con clases semánticas y propiedades personalizadas en la plantilla del componente:
<!-- Sincronización de clases dinámicas según el estado reactivo -->
<div
class="mini-site tu-negocio"
:class="{ violet: experience.accent === 'violet', square: !experience.rounded }"
>
<h3>{{ experience.idea.trim() || 'Tu próxima gran idea' }}<span>.</span></h3>
<p>{{ launched ? 'El primer paso es imaginarlo...' : 'De la idea a lo increíble.' }}</p>
</div>En el código que se muestra en el panel superior, usamos interpolación de texto de solo lectura para ilustrar los valores CSS calculados:
<div>
<span> background: {{ experience.accent === 'green' ? '#20271a' : '#28232f' }};</span>
</div>
<div>
<span> border-radius: <span class="syntax-purple">{{ experience.rounded ? '12px' : '0px' }}</span>;</span>
</div>El usuario percibe que está editando CSS en tiempo real, mientras el motor de renderizado de Vue aplica parches hiperoptimizados sobre el árbol de nodos DOM sin recalcular estilos ajenos ni recompilar hojas de estilo externas.
4. Accesibilidad y patrón WAI-ARIA Tablist
Uno de los fallos más habituales en editores interactivos es atrapar el foco del teclado o impedir la navegación mediante teclas de cursor estándar.
Implementamos la recomendación de la especificación WAI-ARIA para listas de pestañas (Tablist):
- El contenedor padre tiene
role="tablist"y una etiqueta descriptiva (aria-label="Explorar tecnologías"). - Cada botón tiene
role="tab",:aria-selected="experience.activeTab === tab.id"y:aria-controls="`panel-${tab.id}`". - Aplicamos el patrón roving tabindex: únicamente la pestaña activa tiene
tabindex="0"; las pestañas inactivas tienentabindex="-1".
async function moveTab(event: KeyboardEvent, index: number) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
event.preventDefault()
const next = event.key === 'Home'
? 0
: event.key === 'End'
? tabs.length - 1
: (index + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length
experience.activeTab = tabs[next]!.id
await nextTick()
document.getElementById(`tab-${experience.activeTab}`)?.focus()
} Al pulsar las flechas izquierda o derecha, el foco se transfiere de forma cíclica al siguiente control y nextTick() asegura que el elemento del DOM correspondiente haya recibido el atributo tabindex="0" antes de invocar .focus().
5. El modo inspector: descubriendo HTML real con pseudo-elementos
Cuando el usuario activa el modo inspector (desde la cabecera o desde el botón flotante), la clase .inspector-on se añade al contenedor raíz .site-shell.
Aprovechamos las propiedades de los pseudo-elementos CSS y la función attr() para proyectar etiquetas sin alterar el contenido textual accesible de las secciones:
.inspector-on .section-inspect {
outline: 1px dashed #a79bea88;
outline-offset: -2px;
}
.inspector-on .section-inspect::after {
content: attr(data-element);
position: absolute;
top: 0;
left: 16px;
font: 12px var(--font-mono);
color: #d9cff9;
background: #393047;
padding: 5px 10px;
pointer-events: none;
z-index: 3;
} De esta manera, secciones como <section id="servicios" data-element="<section id='servicios'>"> muestran su propia definición técnica flotante de forma no invasiva, cumpliendo una función pedagógica inmediata.
6. Decisiones de diseño y límites intencionales
El desarrollo de software profesional consiste en saber qué incluir y qué omitir. Documentamos aquí los límites conscientes de esta implementación:
- Límite de caracteres en la entrada: El campo
#idea-inputestá acotado conmaxlength="36". En pantallas móviles de 320px a 375px, una cadena arbitrariamente larga desbordaría el contenedor visual y provocaría scroll horizontal no deseado. - Transformación CSS condicional: En escritorio, el editor tiene una sutil rotación (
transform: rotate(1deg)) que aporta dinamismo. En dispositivos móviles de menos de 540px, la media query anula la rotación (transform: none) para evitar pérdida de nitidez en pantallas de baja densidad de píxeles. - Sin persistencia local permanente: El estado de la demostración se reinicia al recargar la página. Para una landing comercial, esto garantiza que cada visitante comience siempre con la experiencia visual limpia y equilibrada.