Saltar al contenido
motionframe

Buscar piezas

Categorías

La pieza

Qué hace y dónde encaja

Una sección FAQ editorial en papel cálido y tinta marina. Cada respuesta se despliega con un muelle integrado en JS y reproducido con la Web Animations API: la altura sobrepasa un poco y se asienta, el + gira hasta ser − con un rebote más vivo y, si interrumpes la animación, sigue desde la velocidad que llevaba. Con ratón, las filas cerradas se estiran unos píxeles al pasar por encima, y un cabo dibujado en SVG vibra como una cuerda cada vez que abres o cierras algo. Incluye buscador insensible a tildes con resaltado, abrir y cerrar todas, navegación con flechas, anclas por pregunta y el marcado JSON-LD FAQPage construido a partir del mismo texto visible. Encaja en webs de servicios, alquileres, clínicas, asesorías o reservas; cambia papel, tinta y acento para adaptarla a tu marca.

Prompt
≈15.566 tokens · con Lite
Librerías
ninguna
Demo
36,6 KB · ligero
Prueba en frío
9,8/10 · 30-09-2026

Antes de copiar

Tu marca, dentro del prompt

Cambia lo que quieras y copia: el prompt sale ya con el nombre, los colores y los textos de tu cliente. Lo que dejes en blanco se queda como en la demo.

Colores

Sin cambios: se copia con los valores de la demo.

Gratis y entero

El prompt, entero y a la vista

Qué trae el prompt, por apartados

  1. 01Qué es
  2. 02Librerías y esqueleto
  3. 03Tokens de diseño
  4. 04Estructura y contenido
  5. 05Movimiento
  6. 06Código clave
  7. 07Interacción
  8. 08Adaptación a móvil y tablet
  9. 09Accesibilidad y movimiento reducido
  10. 10Rendimiento
  11. 11Personalización
  12. 12Comprobación final
Completo
6.305 palabras · ≈15.566 tokens
Lite
1.600 palabras · ≈2.653 tokens
Librerías
ninguna
Peso de la demo
36,6 KB (ligero)
Resolución máxima (DPR)
2
Se pausa fuera de pantalla
sí
Respeta «menos movimiento»
sí

Tokens estimados para español (≈3,6 caracteres por token): sirven para orientarte, no son exactos.

prompt.md
# Recrea en un único archivo HTML: FAQ con acordeón de muelle

Una sección de preguntas frecuentes editorial, en papel cálido y tinta marina, cuyas respuestas se abren con un muelle físico real (rebota un poco y se asienta), con buscador sin tildes, resaltado de coincidencias y el JSON-LD `FAQPage` generado a partir del mismo contenido.

Eres un desarrollador front-end creativo senior. Genera un único `index.html` autocontenido que
reproduzca exactamente la pieza descrita: mismo layout, textos, colores, movimiento e interacción.
HTML, CSS y JS en un solo archivo, sin build ni frameworks. Librerías solo por CDN y en las versiones
indicadas. Todos los valores numéricos de este documento son definitivos: úsalos tal cual.
Responde solo con el archivo completo.

## Qué es

Una sección FAQ lista para pegar en la web de un negocio. La marca de ejemplo es **Noray**, un chárter de veleros en el Puerto Deportivo de Brenmar (el juego de palabras es deliberado: *muelle* es a la vez el resorte y el sitio donde amarran los barcos).

Composición en escritorio (≥ 981 px):

- Una **barra de metadatos** fina arriba, en monoespaciada y mayúsculas: marca a la izquierda, coordenadas en el centro, temporada a la derecha, con una línea finísima debajo.
- **Columna izquierda (5/12), pegajosa** (`position: sticky; top: 40px`): antetítulo con un punto de color acento, titular enorme en serif («Antes de soltar *amarras*.», con «amarras» en cursiva y color acento), un **cabo decorativo en SVG** tendido entre dos norays que **vibra como una cuerda pulsada** cada vez que se abre o se cierra una pregunta, un párrafo de entrada, el **buscador** (campo alto con lupa, atajo `/` y botón de borrar) y cinco **sugerencias** en forma de píldora.
- **Columna derecha (7/12)**: cabecera con el estado del buscador («10 preguntas», «1 resultado para «fianza»»…) y el botón «Abrir todas»; una **lista ordenada de 10 preguntas** separadas por filetes, cada una con su número en monoespaciada (01…10), la pregunta en serif grande y un **icono circular + que gira hasta ser −** con un muelle más vivo. Debajo, un bloque de **contacto** («¿No está tu duda?») y una tarjeta plegable de **datos estructurados** que enseña el JSON-LD generado, coloreado, con botón «Copiar».

Detalles que definen la pieza:

1. **Altura animada por un muelle** (rigidez 170, amortiguación 15, masa 1) integrado en JS y reproducido con la Web Animations API como fotogramas clave lineales a 60 fps. El cierre usa un muelle casi crítico (260 / 31) para no rebotar. Si se interrumpe a mitad, arranca desde la altura y la **velocidad** actuales: nunca salta.
2. **Tensión al pasar el ratón**: una fila cerrada se estira 8 px como si alguien tirara del muelle (320 / 11) y vuelve al soltarla; al hacer clic, esa tensión se transfiere a la altura del panel sin salto.
3. La fila abierta pinta su filete superior en color acento (2 px, crece de izquierda a derecha), el número pasa a color acento y el círculo del icono se rellena de tinta.
4. **Buscador** insensible a tildes y mayúsculas («ninos» encuentra «niños»), con varias palabras en modo Y, `<mark>` en las coincidencias de pregunta y respuesta, etiqueta «En la respuesta» cuando la coincidencia solo está en la respuesta, estado vacío con contacto y región `aria-live`.
5. **JSON-LD `FAQPage`** construido en tiempo de ejecución desde el DOM (el mismo texto que ve el usuario) e insertado en el `<head>`.

Es accesible por completo: `button` con `aria-expanded` y `aria-controls`, encabezados `h3`, flechas/Inicio/Fin entre preguntas, foco visible, `prefers-reduced-motion`. Sin JS, todas las respuestas se ven abiertas.

## Librerías y esqueleto

Sin librerías de JS. Solo tres familias de Google Fonts (todas con ñ, tildes, ¿ y ¡):

```html
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Preguntas frecuentes · Noray, chárter de veleros en Brenmar</title>
<meta name="description" content="Preguntas frecuentes sobre el alquiler de veleros con y sin patrón en el Puerto Deportivo de Brenmar.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400&family=Instrument+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>/* CSS completo del apartado «Tokens de diseño» */</style>
</head>
<body>
<main><!-- HTML del apartado «Estructura y contenido» --></main>
<script>/* JS completo del apartado «Código clave» */</script>
</body>
</html>
```

- **Newsreader** (serif display con eje óptico `opsz` 6–72): titular, preguntas, cifras, títulos de bloques.
- **Instrument Sans**: texto corrido y botones.
- **IBM Plex Mono**: números 01–10, etiquetas en mayúsculas, estado, código.

El `<script>` va al final del `<body>` (no en `<head>`), sin `defer` ni módulos: tiene que ejecutarse antes del primer pintado para ocultar los paneles sin parpadeo.

## Tokens de diseño

Paleta: papel `#f3eee4` (fondo), papel claro `#fbf8f2` (campo, tarjetas, celdas de datos), tinta `#11242f` (texto, icono abierto, bloque de código), tinta suave `#46545d` (texto secundario, contraste 6,8:1 sobre el papel), acento `#b8431b` (boya: «amarras», números abiertos, filete activo, foco; 4,7:1 sobre el papel). Los filetes y el fondo del resaltado se derivan con `color-mix` de la tinta y el acento, así que cambian solos con la marca. Sobre el fondo hay un grano de papel procedural (SVG `feTurbulence` en `data:` URI, opacidad .55), fijo y sin eventos de puntero.

Este es el CSS **completo y definitivo**. Cópialo literal dentro de `<style>`:

```css
:root{
  --papel:#f3eee4;
  --papel-2:#fbf8f2;
  --tinta:#11242f;
  --tinta-2:#46545d;
  --linea:color-mix(in srgb,var(--tinta) 16%,transparent);
  --linea-2:color-mix(in srgb,var(--tinta) 28%,transparent);
  --acento:#b8431b;
  --marca:color-mix(in srgb,var(--acento) 16%,transparent);
  --codigo-k:#f2b38d;
  --codigo-s:#a9d6cc;
  --f-display:"Newsreader",Georgia,"Times New Roman",serif;
  --f-texto:"Instrument Sans",system-ui,-apple-system,"Segoe UI",sans-serif;
  --f-mono:"IBM Plex Mono",ui-monospace,Menlo,Consolas,monospace;
  --e-salida:cubic-bezier(.16,1,.3,1);
  --e-rebote:cubic-bezier(.34,1.56,.64,1);
  --lado:clamp(20px,5vw,72px);
}
*,*::before,*::after{box-sizing:border-box}
html{-webkit-text-size-adjust:100%;text-size-adjust:100%}
body{margin:0;min-height:100svh;background:var(--papel);color:var(--tinta);font:400 16px/1.6 var(--f-texto);-webkit-font-smoothing:antialiased;overflow-x:hidden}
body::before{content:"";position:fixed;inset:0;pointer-events:none;z-index:0;opacity:.55;background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 .07 0 0 0 0 .14 0 0 0 0 .18 0 0 0 .09 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>")}
[hidden]{display:none!important}
a{color:inherit}
button,input{font:inherit;color:inherit}
:focus-visible{outline:2px solid var(--acento);outline-offset:3px;border-radius:6px}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}

.faq{position:relative;z-index:1;max-width:1360px;margin:0 auto;padding:0 var(--lado) clamp(64px,9vw,128px)}
.meta{display:flex;justify-content:space-between;gap:24px;padding:22px 0;border-bottom:1px solid var(--linea);font:500 11.5px/1.4 var(--f-mono);letter-spacing:.08em;text-transform:uppercase;color:var(--tinta-2)}
.meta b{font-weight:500;color:var(--tinta)}
.rejilla{display:grid;grid-template-columns:minmax(0,5fr) minmax(0,7fr);gap:clamp(40px,7vw,120px);padding-top:clamp(48px,7vw,104px)}

/* Columna de introducción */
.intro{position:sticky;top:40px;align-self:start}
.eyebrow{display:flex;align-items:center;gap:10px;margin:0 0 28px;font:500 12px/1 var(--f-mono);letter-spacing:.1em;text-transform:uppercase;color:var(--tinta-2)}
.eyebrow::before{content:"";width:8px;height:8px;border-radius:50%;background:var(--acento)}
h2{margin:0;font:400 clamp(46px,6.1vw,92px)/.94 var(--f-display);letter-spacing:-.025em;font-variation-settings:"opsz" 72}
h2 em{font-style:italic;color:var(--acento)}
.cabo{display:block;width:100%;max-width:440px;height:40px;margin:26px 0 22px;overflow:visible}
.cabo__norai{fill:var(--tinta)}
.cabo__linea{fill:none;stroke:var(--tinta);stroke-width:2.6;stroke-linecap:round}
.cabo__torsion{fill:none;stroke:var(--papel);stroke-width:1.1;stroke-dasharray:1.6 3.4;stroke-linecap:round}
.lead{margin:0 0 36px;max-width:40ch;font-size:clamp(16px,1.25vw,18px);color:var(--tinta-2)}

.buscador label{display:block;margin-bottom:10px;font:500 12px/1 var(--f-mono);letter-spacing:.08em;text-transform:uppercase;color:var(--tinta-2)}
.campo{position:relative;display:flex;align-items:center;height:58px;border:1px solid var(--linea-2);border-radius:14px;background:var(--papel-2);transition:border-color .25s,box-shadow .25s}
.campo:focus-within{border-color:var(--tinta);box-shadow:0 0 0 4px color-mix(in srgb,var(--tinta) 8%,transparent)}
.campo svg{flex:none;width:18px;height:18px;margin:0 12px 0 18px;stroke:var(--tinta);fill:none;stroke-width:1.8}
.campo input{flex:1;min-width:0;height:100%;border:0;background:none;outline:none;font-size:17px;padding:0 8px 0 0}
.campo input::placeholder{color:#6d787f}
.campo input::-webkit-search-cancel-button{display:none}
.campo input:not(:placeholder-shown)~kbd{display:none}
.campo kbd{margin-right:14px;padding:3px 8px;border:1px solid var(--linea-2);border-radius:6px;font:500 12px/1.2 var(--f-mono);color:var(--tinta-2)}
.borrar{display:grid;place-items:center;width:36px;height:36px;margin-right:10px;border:0;border-radius:50%;background:var(--tinta);color:var(--papel);cursor:pointer}
.borrar svg{margin:0;width:12px;height:12px;stroke:currentColor;stroke-width:2.2}
.sugerencias{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:14px;font-size:14px;color:var(--tinta-2)}
.sugerencias span{margin-right:4px}
.chip{padding:6px 13px;border:1px solid var(--linea-2);border-radius:999px;background:none;font-size:14px;cursor:pointer;transition:background .3s,color .3s,border-color .3s,transform .5s var(--e-rebote)}
.chip:active{transform:scale(.94)}

.contacto{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:20px 40px;align-items:start;margin-top:48px;padding:28px 0 0 64px}
.contacto h3{margin:0 0 8px;font:400 26px/1.2 var(--f-display)}
.contacto p{margin:0;max-width:40ch;color:var(--tinta-2)}
.enlaces{display:flex;flex-direction:column;align-items:flex-start;gap:12px}
.enlaces a{display:inline-flex;align-items:center;gap:8px;font-weight:500;text-decoration:none;border-bottom:1px solid var(--linea-2);padding-bottom:3px;transition:border-color .3s}
.enlaces a span{display:inline-block;transition:transform .5s var(--e-rebote)}
.contacto .horario{margin-top:6px;font:500 11px/1.6 var(--f-mono);letter-spacing:.06em;text-transform:uppercase;color:var(--tinta-2)}

/* Lista */
.cabecera{display:flex;justify-content:space-between;align-items:center;gap:16px;padding-bottom:18px;font:500 12px/1 var(--f-mono);letter-spacing:.08em;text-transform:uppercase;color:var(--tinta-2)}
.estado{margin:0}
.todas{display:inline-flex;align-items:center;gap:8px;padding:8px 0;border:0;background:none;font:inherit;letter-spacing:inherit;text-transform:inherit;color:var(--tinta);cursor:pointer}
.todas::after{content:"";width:18px;height:1px;background:currentColor;transition:width .5s var(--e-rebote)}
.acordeon{list-style:none;margin:0;padding:0;border-bottom:1px solid var(--linea)}
.item{position:relative;border-top:1px solid var(--linea)}
.item::before{content:"";position:absolute;left:0;right:0;top:-1px;height:2px;background:var(--acento);transform:scaleX(0);transform-origin:left;transition:transform .8s var(--e-salida)}
.item.abierto::before{transform:scaleX(1)}
.item h3{margin:0;font:inherit}
.boton{display:grid;grid-template-columns:52px minmax(0,1fr) auto;align-items:center;gap:0 12px;width:100%;padding:26px 0;border:0;background:none;text-align:left;cursor:pointer;border-radius:0}
.num{align-self:start;padding-top:.55em;font:500 12px/1 var(--f-mono);letter-spacing:.08em;color:var(--tinta-2);transition:color .4s}
.abierto .num{color:var(--acento)}
.pregunta{display:block;font:400 clamp(21px,1.95vw,28px)/1.22 var(--f-display);letter-spacing:-.01em;transition:transform .6s var(--e-rebote)}
.etiqueta{display:inline-block;margin-top:8px;padding:3px 9px;border-radius:999px;background:var(--marca);font:500 11px/1.3 var(--f-mono);letter-spacing:.06em;text-transform:uppercase;color:var(--acento)}
.icono{position:relative;display:grid;place-items:center;width:44px;height:44px;border:1px solid var(--linea-2);border-radius:50%;transition:background .35s,border-color .35s,transform .6s var(--e-rebote)}
.icono i{position:absolute;width:15px;height:1.6px;border-radius:2px;background:var(--tinta);transition:background .35s}
.icono i+i{transform:rotate(90deg)}
.abierto .icono{background:var(--tinta);border-color:var(--tinta)}
.abierto .icono i{background:var(--papel)}
.panel{overflow:hidden}
.tension{height:0}
.cuerpo{padding:0 56px 32px 64px;color:var(--tinta-2);font-size:16.5px}
.cuerpo p{margin:0 0 12px;max-width:60ch}
.cuerpo p:last-child{margin-bottom:0}
.cuerpo strong{font-weight:600;color:var(--tinta)}
.cuerpo a{color:var(--tinta);text-underline-offset:3px}
mark{background:var(--marca);color:var(--tinta);border-radius:3px;padding:0 1px;box-shadow:0 1.5px 0 var(--acento)}
.datos{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1px;max-width:560px;margin:18px 0 4px;border:1px solid var(--linea);border-radius:12px;overflow:hidden;background:var(--linea)}
.datos div{padding:14px 16px;background:var(--papel-2)}
.datos dt{font:500 11px/1.3 var(--f-mono);letter-spacing:.06em;text-transform:uppercase;color:var(--tinta-2)}
.datos dd{margin:6px 0 0;font:400 22px/1.1 var(--f-display);color:var(--tinta)}
.datos dd small{display:block;margin-top:4px;font:400 12.5px/1.3 var(--f-texto);color:var(--tinta-2)}

.vacio{padding:34px 0 30px 64px;border-top:1px solid var(--linea)}
.vacio p{margin:0 0 14px;font:400 26px/1.25 var(--f-display)}
.vacio p+p{font:400 16px/1.6 var(--f-texto);color:var(--tinta-2)}
.vacio button{padding:10px 18px;border:1px solid var(--tinta);border-radius:999px;background:none;cursor:pointer;font-weight:500}

/* JSON-LD */
.ld{margin-top:40px;border:1px solid var(--linea);border-radius:16px;background:var(--papel-2)}
.ld h3{margin:0;font:inherit}
.ld .boton{grid-template-columns:minmax(0,1fr) auto;padding:18px 18px 18px 22px}
.ld__tit{display:block;font:500 12px/1 var(--f-mono);letter-spacing:.08em;text-transform:uppercase;color:var(--tinta-2)}
.ld__sub{display:block;margin-top:8px;font:400 19px/1.25 var(--f-display)}
.ld .icono{width:38px;height:38px}
.ld .cuerpo{padding:0 18px 18px}
.codigo{position:relative;border-radius:12px;background:var(--tinta);color:#e9e3d6}
.codigo pre{margin:0;max-height:340px;overflow:auto;padding:22px 20px;font:400 12.5px/1.65 var(--f-mono);white-space:pre-wrap;word-break:break-word;border-radius:12px}
.codigo .k{color:var(--codigo-k)}
.codigo .s{color:var(--codigo-s)}
.copiar{position:absolute;top:10px;right:10px;padding:7px 12px;border:1px solid rgba(233,227,214,.35);border-radius:999px;background:var(--tinta);color:#e9e3d6;font:500 11px/1 var(--f-mono);letter-spacing:.06em;text-transform:uppercase;cursor:pointer}
.nota{margin:12px 2px 0;font-size:14px;color:var(--tinta-2)}

@media (hover:hover) and (pointer:fine){
  .boton:hover .pregunta{transform:translateX(8px)}
  .item .boton:hover .icono{transform:scale(1.08)}
  .item:not(.abierto) .boton:hover .icono{border-color:var(--tinta)}
  .chip:hover{background:var(--tinta);border-color:var(--tinta);color:var(--papel)}
  .enlaces a:hover{border-color:var(--tinta)}
  .enlaces a:hover span{transform:translateX(4px)}
  .todas:hover::after{width:30px}
}
@media (hover:none){.campo kbd{display:none}}

@media (max-width:980px){
  .rejilla{grid-template-columns:minmax(0,1fr);gap:56px}
  .intro{position:static}
  .meta .coord{display:none}
}
@media (max-width:600px){
  .meta{padding:18px 0;font-size:10.5px}
  .meta .meses,.campo kbd{display:none}
  .contacto{grid-template-columns:minmax(0,1fr);padding-left:0;margin-top:40px}
  .vacio{padding-left:0}
  .rejilla{padding-top:40px;gap:48px}
  h2{font-size:clamp(42px,12.5vw,60px)}
  .cabo{margin:20px 0 18px}
  .lead{margin-bottom:28px}
  .boton{grid-template-columns:34px minmax(0,1fr) auto;padding:22px 0;gap:0 10px}
  .num{font-size:11px}
  .icono{width:40px;height:40px}
  .cuerpo{padding:0 0 26px 44px;font-size:16px}
  .datos{grid-template-columns:minmax(0,1fr)}
  .datos div{display:flex;justify-content:space-between;align-items:baseline;gap:12px}
  .datos dd{margin:0;text-align:right}
  .ld .cuerpo{padding:0 10px 10px}
}
@media (prefers-reduced-motion:reduce){
  *,*::before,*::after{transition-duration:.01ms!important;animation-duration:.01ms!important}
  .boton:hover .pregunta{transform:none}
}
```

Medidas clave, por si hay que razonar sobre ellas:

| Elemento | Valor |
|---|---|
| Contenedor | `max-width: 1360px`, lateral `clamp(20px, 5vw, 72px)`, abajo `clamp(64px, 9vw, 128px)` |
| Rejilla | `5fr / 7fr`, hueco `clamp(40px, 7vw, 120px)`, arriba `clamp(48px, 7vw, 104px)` |
| Titular | Newsreader 400, `clamp(46px, 6.1vw, 92px)`, interlineado .94, tracking −.025em, `opsz` 72 |
| Pregunta | Newsreader 400, `clamp(21px, 1.95vw, 28px)`, interlineado 1.22 |
| Fila | rejilla `52px · 1fr · auto`, hueco 12 px, padding vertical 26 px |
| Icono | círculo 44 px, borde 1 px `--linea-2`; barras 15 × 1,6 px |
| Respuesta | 16,5 px, padding `0 56px 32px 64px`, párrafos a 60ch |
| Campo de búsqueda | alto 58 px, radio 14 px, texto 17 px |
| Tarjeta JSON-LD | radio 16 px; código Plex Mono 12,5/1,65 sobre tinta, `max-height: 340px` |

## Estructura y contenido

Árbol de la sección (todo el texto es literal; respeta los `id`, clases y atributos ARIA porque el JS depende de ellos):

- `section.faq[aria-labelledby=faq-titulo]`
  - `div.meta`: «**Noray** — Chárter de veleros» · coordenadas «36°30′31″N · 4°53′07″O» (`aria-hidden`, clase `coord`, se oculta ≤ 980 px) · «Temporada 2026 · abril–octubre» (el trozo « · abril–octubre» en `span.meses`, se oculta ≤ 600 px).
  - `div.rejilla`
    - `div.intro`: antetítulo, `h2#faq-titulo`, `svg.cabo`, `p.lead`, `div.buscador[role=search]`.
    - `div.lista`: `div.cabecera` (estado + «Abrir todas»), `ol.acordeon` con 10 `li.item[data-item]`, `div.vacio[hidden]`, `aside.contacto`, `div.ld`.

Cada pregunta sigue exactamente este patrón (el `id` del `li` sirve de ancla `#fianza`, etc.):

```html
<li class="item" id="SLUG" data-item>
  <h3><button type="button" class="boton" id="q-SLUG" aria-expanded="false" aria-controls="r-SLUG"><span class="num" aria-hidden="true">NN</span><span><span class="pregunta">PREGUNTA</span><span class="etiqueta" hidden>En la respuesta</span></span><span class="icono" aria-hidden="true"><i></i><i></i></span></button></h3>
  <div class="panel" id="r-SLUG"><div class="cuerpo">
    <p>RESPUESTA</p>
  </div></div>
</li>
```

Los paneles **no** llevan `hidden` en el HTML (sin JS se ven todos); el JS los oculta al arrancar. Tampoco llevan `role="region"`: con 10 paneles sería ruido para el lector de pantalla.

HTML completo y definitivo del `<main>`:

```html
<main>
<section class="faq" aria-labelledby="faq-titulo">
  <div class="meta">
    <span><b>Noray</b> — Chárter de veleros</span>
    <span class="coord" aria-hidden="true">36°30′31″N · 4°53′07″O</span>
    <span>Temporada 2026<span class="meses"> · abril–octubre</span></span>
  </div>

  <div class="rejilla">
    <div class="intro">
      <p class="eyebrow">Preguntas frecuentes · Brenmar</p>
      <h2 id="faq-titulo">Antes de soltar <em>amarras</em>.</h2>
      <svg class="cabo" viewBox="0 0 440 40" aria-hidden="true" focusable="false">
        <path class="cabo__linea" d="M16 20 L424 20"/>
        <path class="cabo__torsion" d="M16 20 L424 20"/>
        <g class="cabo__norai"><rect x="4" y="12" width="10" height="18" rx="2"/><rect x="1" y="9" width="16" height="5" rx="2.5"/><rect x="426" y="12" width="10" height="18" rx="2"/><rect x="423" y="9" width="16" height="5" rx="2.5"/></g>
      </svg>
      <p class="lead">Todo lo que nos preguntan en el pantalán, resuelto antes de que embarques. Y si falta algo, el patrón está al otro lado del teléfono.</p>

      <div class="buscador" role="search">
        <label for="faq-q">Buscar en las preguntas</label>
        <div class="campo">
          <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m15.5 15.5 5 5" stroke-linecap="round"/></svg>
          <input id="faq-q" type="search" placeholder="Fianza, patrón, perro…" autocomplete="off" spellcheck="false" aria-describedby="faq-estado">
          <kbd aria-hidden="true">/</kbd>
          <button type="button" class="borrar" aria-label="Borrar la búsqueda" hidden><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M2 2l8 8M10 2l-8 8" stroke-linecap="round"/></svg></button>
        </div>
        <div class="sugerencias"><span>Prueba con</span>
          <button type="button" class="chip" data-q="fianza">fianza</button>
          <button type="button" class="chip" data-q="patrón">patrón</button>
          <button type="button" class="chip" data-q="perro">perro</button>
          <button type="button" class="chip" data-q="cancelar">cancelar</button>
          <button type="button" class="chip" data-q="niños">niños</button>
        </div>
      </div>

    </div>

    <div class="lista">
      <div class="cabecera">
        <p class="estado" id="faq-estado" aria-live="polite">10 preguntas</p>
        <button type="button" class="todas">Abrir todas</button>
      </div>

      <ol class="acordeon">
        <li class="item" id="titulacion" data-item>
          <h3><button type="button" class="boton" id="q-titulacion" aria-expanded="false" aria-controls="r-titulacion"><span class="num" aria-hidden="true">01</span><span><span class="pregunta">¿Necesito titulación para alquilar un velero?</span><span class="etiqueta" hidden>En la respuesta</span></span><span class="icono" aria-hidden="true"><i></i><i></i></span></button></h3>
          <div class="panel" id="r-titulacion"><div class="cuerpo">
            <p>No, si sales con patrón: todos nuestros veleros pueden salir con un <strong>patrón profesional</strong> incluido, que se encarga de la navegación y de elegir la mejor cala según el viento.</p>
            <p>Para alquilar sin patrón necesitas como mínimo el <strong>PER</strong> (Patrón de Embarcaciones de Recreo) o una titulación equivalente de tu país, y te pediremos un breve currículum náutico.</p>
          </div></div>
        </li>
        <li class="item" id="precio" data-item>
          <h3><button type="button" class="boton" id="q-precio" aria-expanded="false" aria-controls="r-precio"><span class="num" aria-hidden="true">02</span><span><span class="pregunta">¿Qué incluye el precio?</span><span class="etiqueta" hidden>En la respuesta</span></span><span class="icono" aria-hidden="true"><i></i><i></i></span></button></h3>
          <div class="panel" id="r-precio"><div class="cuerpo">
            <p>El barco con patrón, el seguro a todo riesgo, el combustible de las dos primeras horas de motor, la limpieza final, toallas, chalecos y equipo de snorkel. Solo pagas aparte lo que quieras añadir, como el catering.</p>
            <dl class="datos">
              <div><dt>Medio día</dt><dd>390 €<small>4 h · IVA incl.</small></dd></div>
              <div><dt>Día completo</dt><dd>690 €<small>8 h · IVA incl.</small></dd></div>
              <div><dt>Puesta de sol</dt><dd>290 €<small>2,5 h · IVA incl.</small></dd></div>
            </dl>
          </div></div>
        </li>
        <li class="item" id="fianza" data-item>
          <h3><button type="button" class="boton" id="q-fianza" aria-expanded="false" aria-controls="r-fianza"><span class="num" aria-hidden="true">03</span><span><span class="pregunta">¿Cuánto es la fianza y cuándo la recupero?</span><span class="etiqueta" hidden>En la respuesta</span></span><span class="icono" aria-hidden="true"><i></i><i></i></span></button></h3>
          <div class="panel" id="r-fianza"><div class="cuerpo">
            <p>La fianza es de <strong>1.500 €</strong> en veleros de hasta 12 metros y de <strong>2.500 €</strong> en los de 12 a 15 metros. Solo se aplica si alquilas sin patrón.</p>
            <p>Se bloquea en tu tarjeta al embarcar (es una preautorización, no un cargo) y se libera al desembarcar si el barco vuelve como salió. Con la reducción de fianza, por 60 € al día IVA incl., se queda en 300 €.</p>
          </div></div>
        </li>
        <li class="item" id="tiempo" data-item>
          <h3><button type="button" class="boton" id="q-tiempo" aria-expanded="false" aria-controls="r-tiempo"><span class="num" aria-hidden="true">04</span><span><span class="pregunta">¿Qué pasa si hace mal tiempo?</span><span class="etiqueta" hidden>En la respuesta</span></span><span class="icono" aria-hidden="true"><i></i><i></i></span></button></h3>
          <div class="panel" id="r-tiempo"><div class="cuerpo">
            <p>El patrón decide la misma mañana con el parte de AEMET. Si no se puede salir por viento o mar (a partir de fuerza 6 de levante o poniente), <strong>te cambiamos la fecha sin coste o te devolvemos el 100 %</strong>, lo que prefieras.</p>
          </div></div>
        </li>
        <li class="item" id="cancelacion" data-item>
          <h3><button type="button" class="boton" id="q-cancelacion" aria-expanded="false" aria-controls="r-cancelacion"><span class="num" aria-hidden="true">05</span><span><span class="pregunta">¿Puedo cancelar la reserva?</span><span class="etiqueta" hidden>En la respuesta</span></span><span class="icono" aria-hidden="true"><i></i><i></i></span></button></h3>
          <div class="panel" id="r-cancelacion"><div class="cuerpo">
            <p>Sí. Hasta 15 días antes te devolvemos el importe íntegro; entre 14 y 7 días, el 50 %. Con menos de 7 días no hay reembolso, pero puedes mover la fecha una vez dentro de la misma temporada.</p>
          </div></div>
        </li>
        <li class="item" id="embarque" data-item>
          <h3><button type="button" class="boton" id="q-embarque" aria-expanded="false" aria-controls="r-embarque"><span class="num" aria-hidden="true">06</span><span><span class="pregunta">¿Dónde embarcamos y dónde aparco?</span><span class="etiqueta" hidden>En la respuesta</span></span><span class="icono" aria-hidden="true"><i></i><i></i></span></button></h3>
          <div class="panel" id="r-embarque"><div class="cuerpo">
            <p>En el <strong>pantalán C del Puerto Deportivo de Brenmar</strong>. Te esperamos 15 minutos antes de la salida con el barco listo. El aparcamiento público del paseo marítimo está a tres minutos a pie y te sellamos el tique.</p>
          </div></div>
        </li>
        <li class="item" id="a-bordo" data-item>
          <h3><button type="button" class="boton" id="q-a-bordo" aria-expanded="false" aria-controls="r-a-bordo"><span class="num" aria-hidden="true">07</span><span><span class="pregunta">¿Podemos llevar comida, bebida y a nuestro perro?</span><span class="etiqueta" hidden>En la respuesta</span></span><span class="icono" aria-hidden="true"><i></i><i></i></span></button></h3>
          <div class="panel" id="r-a-bordo"><div class="cuerpo">
            <p>Sí a todo, con cabeza. A bordo hay nevera, hielo y vasos; solo te pedimos que no subas vidrio. Los perros son bienvenidos con su chaleco: si no tenéis, os prestamos uno (tallas S a L).</p>
          </div></div>
        </li>
        <li class="item" id="plazas" data-item>
          <h3><button type="button" class="boton" id="q-plazas" aria-expanded="false" aria-controls="r-plazas"><span class="num" aria-hidden="true">08</span><span><span class="pregunta">¿Cuántas personas caben en cada barco?</span><span class="etiqueta" hidden>En la respuesta</span></span><span class="icono" aria-hidden="true"><i></i><i></i></span></button></h3>
          <div class="panel" id="r-plazas"><div class="cuerpo">
            <p>Es el máximo que marca el certificado de navegabilidad, así que no se negocia. El patrón no cuenta.</p>
            <dl class="datos">
              <div><dt>Alcaraván</dt><dd>6<small>9,9 m de eslora</small></dd></div>
              <div><dt>Vendaval</dt><dd>8<small>11,5 m de eslora</small></dd></div>
              <div><dt>Marejada</dt><dd>11<small>14 m de eslora</small></dd></div>
            </dl>
          </div></div>
        </li>
        <li class="item" id="ninos" data-item>
          <h3><button type="button" class="boton" id="q-ninos" aria-expanded="false" aria-controls="r-ninos"><span class="num" aria-hidden="true">09</span><span><span class="pregunta">¿Es buena idea con niños o si me mareo?</span><span class="etiqueta" hidden>En la respuesta</span></span><span class="icono" aria-hidden="true"><i></i><i></i></span></button></h3>
          <div class="panel" id="r-ninos"><div class="cuerpo">
            <p>Sí. Llevamos chalecos infantiles desde 10 kg y una red en los guardamancebos. Si te mareas, reserva la salida de la mañana: el mar suele estar más plano antes de que entre el viento de la tarde.</p>
          </div></div>
        </li>
        <li class="item" id="pago" data-item>
          <h3><button type="button" class="boton" id="q-pago" aria-expanded="false" aria-controls="r-pago"><span class="num" aria-hidden="true">10</span><span><span class="pregunta">¿Cómo se paga? ¿Hacéis factura a empresas?</span><span class="etiqueta" hidden>En la respuesta</span></span><span class="icono" aria-hidden="true"><i></i><i></i></span></button></h3>
          <div class="panel" id="r-pago"><div class="cuerpo">
            <p>Pagas un 30 % al reservar y el resto hasta 48 horas antes. Aceptamos tarjeta, Bizum y transferencia, y emitimos factura completa a nombre de empresa para eventos e incentivos.</p>
          </div></div>
        </li>
      </ol>

      <div class="vacio" hidden>
        <p>Nada sobre «<span class="vacio__q"></span>».</p>
        <p>Pregúntanoslo directamente en <a href="mailto:hola@noray.com">hola@noray.com</a> o al <a href="tel:+00 000 000 000">+00 000 000 000</a> y te contestamos en el día.</p>
        <button type="button" class="ver-todas">Ver todas las preguntas</button>
      </div>

      <aside class="contacto" aria-labelledby="contacto-tit">
        <div>
          <h3 id="contacto-tit">¿No está tu duda?</h3>
          <p>Escríbenos o llama: te responde alguien que sale a navegar cada día, no un formulario.</p>
        </div>
        <div class="enlaces">
          <a href="tel:+00 000 000 000">+00 000 000 000 <span aria-hidden="true">→</span></a>
          <a href="mailto:hola@noray.com">hola@noray.com <span aria-hidden="true">→</span></a>
          <p class="horario">Puerto Deportivo de Brenmar<br>Pantalán C · 9:00–21:00</p>
        </div>
      </aside>

      <div class="ld">
        <h3><button type="button" class="boton" id="q-ld" aria-expanded="false" aria-controls="r-ld"><span><span class="ld__tit">Datos estructurados · FAQPage</span><span class="ld__sub">JSON-LD generado a partir de estas mismas preguntas</span></span><span class="icono" aria-hidden="true"><i></i><i></i></span></button></h3>
        <div class="panel" id="r-ld"><div class="cuerpo">
          <div class="codigo"><pre tabindex="0" aria-label="Código JSON-LD"><code></code></pre><button type="button" class="copiar">Copiar</button></div>
          <p class="nota">Se inserta en el &lt;head&gt; al cargar: si cambias una respuesta, el marcado para buscadores cambia con ella.</p>
        </div></div>
      </div>
    </div>
  </div>
</section>
</main>
```

Notas de contenido:
- En los `href="tel:…"` usa el teléfono +00 000 000 000 **sin espacios** (p. ej. `tel:+000000000000`).
- Los precios llevan siempre «IVA incl.».
- Las dos tablas `dl.datos` (precios y plazas por barco) son tres celdas separadas por 1 px de filete (rejilla con `gap: 1px` sobre fondo `--linea`).

## Movimiento

| Elemento | Propiedad | De → a | Duración | Easing | Retardo | Disparador |
|---|---|---|---|---|---|---|
| `.intro > *`, `.cabecera`, cada `li`, `.ld` | opacity, translateY | 0, 18px → 1, 0 | 900 ms | `cubic-bezier(.16,1,.3,1)` | 80 ms + 45 ms × índice | carga |
| Primera pregunta (o la del `#ancla`) | se abre | cerrada → abierta | muelle | — | 750 ms | carga |
| `.panel` al abrir | height | actual → alto de `.cuerpo` | lo que dure el muelle (≈ 0,8 s) | muelle k 170 · c 15 (fotogramas lineales) | 0 | clic / Enter / Espacio |
| `.cuerpo` al abrir | opacity, translateY | 0, −10px → 1, 0 | 420 ms | `cubic-bezier(.2,.8,.2,1)` | 60 ms | con la apertura |
| `.panel` al cerrar | height | actual → 0 | ≈ 0,4 s | muelle k 260 · c 31 | 0 | clic |
| `.cuerpo` al cerrar | opacity | 1 → 0 (fill forwards) | 200 ms | `ease-out` | 0 | con el cierre |
| Segunda barra del icono | rotate | 90° (+) → 180° (−) y vuelta | ≈ 0,7 s | muelle k 230 · c 13 | 0 | con abrir/cerrar |
| Fila cerrada (`div.tension` bajo el `h3`) | height | 0 → 8px → 0 | ≈ 0,5 s | muelle k 320 · c 11 | 0 | `pointerenter`/`pointerleave` con ratón |
| `.item::before` (filete superior) | scaleX | 0 → 1 (origen izquierda) | 800 ms | `--e-salida` | 0 | clase `.abierto` |
| `.num`, fondo de `.icono`, barras | color / fondo | tinta suave → acento; transparente → tinta | 400 / 350 ms | lineal CSS | 0 | clase `.abierto` |
| Cabo SVG | desplazamiento vertical | amplitud A·e^(−3,2 t)·cos(2π·3,4·t) | 2,2 s | — | 0 | cada abrir (fuerza 1) o cerrar (fuerza 0,6) |
| `.pregunta` (hover) | translateX | 0 → 8px | 600 ms | `--e-rebote` | 0 | hover con puntero fino |
| `.icono` (hover) | scale | 1 → 1.08 | 600 ms | `--e-rebote` | 0 | hover con puntero fino |
| Fila que reaparece al filtrar | opacity, translateY | 0, 8px → 1, 0 | 320 ms | `cubic-bezier(.16,1,.3,1)` | 0 | búsqueda |
| «Abrir todas» | cascada | — | — | — | 45 ms × índice | clic |
| Línea tras «Abrir todas» | width | 18px → 30px | 500 ms | `--e-rebote` | 0 | hover |

## Código clave

Este es el JS **completo y definitivo**. Cópialo literal dentro del `<script>` final:

```js
(() => {
  const reducido = matchMedia("(prefers-reduced-motion: reduce)");
  const FPS = 60;
  // Muelles: rigidez k, amortiguación c, masa 1
  const MUELLE = {
    abrir: { k: 170, c: 15 },
    cerrar: { k: 260, c: 31 },
    icono: { k: 230, c: 13 },
    tension: { k: 320, c: 11 }
  };

  // Integra un muelle amortiguado y devuelve posiciones y velocidades a 60 fps
  function muelle(desde, hasta, v0, { k, c }) {
    const h = 1 / (FPS * 4);
    const eps = Math.max(Math.abs(hasta - desde), 1) * 0.0025;
    let x = desde, v = v0;
    const pos = [x], vel = [v];
    for (let n = 0; n < FPS * 3; n++) {
      for (let s = 0; s < 4; s++) { const a = -k * (x - hasta) - c * v; v += a * h; x += v * h; }
      pos.push(x); vel.push(v);
      if (Math.abs(x - hasta) < eps && Math.abs(v) < eps * 6) break;
    }
    pos[pos.length - 1] = hasta; vel[vel.length - 1] = 0;
    return { pos, vel, dur: (pos.length - 1) * 1000 / FPS };
  }

  // Un valor animado con WAAPI que se puede interrumpir conservando la velocidad
  class Resorte {
    constructor(el, pinta, valor) { this.el = el; this.pinta = pinta; this.valor = valor; this.anim = null; this.s = null; }
    actual() {
      if (!this.anim) return { x: this.valor, v: 0 };
      const s = this.s, ult = s.pos.length - 1;
      const f = Math.min((this.anim.currentTime || 0) / (1000 / FPS), ult);
      const i = Math.floor(f), j = Math.min(i + 1, ult);
      return { x: s.pos[i] + (s.pos[j] - s.pos[i]) * (f - i), v: s.vel[i] };
    }
    ir(hasta, cfg, fin) {
      const { x, v } = this.actual();
      if (this.anim) { this.anim.onfinish = null; this.anim.cancel(); this.anim = null; }
      this.valor = hasta;
      Object.assign(this.el.style, this.pinta(hasta));
      if (reducido.matches || (Math.abs(x - hasta) < 0.5 && Math.abs(v) < 1)) { if (fin) fin(); return; }
      this.s = muelle(x, hasta, v, cfg);
      const a = this.el.animate(this.s.pos.map(this.pinta), { duration: this.s.dur, easing: "linear" });
      this.anim = a;
      a.onfinish = () => { if (this.anim === a) this.anim = null; if (fin) fin(); };
    }
    fijar(v) {
      if (this.anim) { this.anim.onfinish = null; this.anim.cancel(); this.anim = null; }
      this.valor = v; Object.assign(this.el.style, this.pinta(v));
    }
  }
  const pintaAlto = (h) => ({ height: Math.max(0, h).toFixed(2) + "px" });
  const pintaGiro = (g) => ({ transform: "rotate(" + g.toFixed(2) + "deg)" });

  // Cabo decorativo: vibra como una cuerda pulsada cada vez que algo se abre o cierra
  const caboLineas = document.querySelectorAll(".cabo__linea, .cabo__torsion");
  let amp = 0, t0 = 0, raf = 0;
  function dibujarCabo(a) {
    let d = "";
    for (let i = 0; i <= 32; i++) {
      const u = i / 32, x = 16 + u * 408;
      const y = 20 + a * Math.sin(Math.PI * u) + a * 0.28 * Math.sin(2 * Math.PI * u);
      d += (i ? " L" : "M") + x.toFixed(1) + " " + y.toFixed(2);
    }
    caboLineas.forEach((p) => p.setAttribute("d", d));
  }
  function vibrar(ahora) {
    const t = (ahora - t0) / 1000;
    dibujarCabo(amp * Math.exp(-t * 3.2) * Math.cos(t * Math.PI * 2 * 3.4));
    if (t < 2.2) raf = requestAnimationFrame(vibrar);
    else { raf = 0; amp = 0; dibujarCabo(0); }
  }
  function pulsar(fuerza) {
    if (reducido.matches) return;
    amp = Math.min(10, amp * 0.5 + 7 * fuerza);
    t0 = performance.now();
    if (!raf) raf = requestAnimationFrame(vibrar);
  }

  // Plegables: preguntas y bloque de JSON-LD
  function plegable(raiz, btn) {
    const panel = document.getElementById(btn.getAttribute("aria-controls"));
    const cuerpo = panel.querySelector(".cuerpo");
    const barra = btn.querySelector(".icono i + i");
    return { raiz, btn, panel, cuerpo, abierto: false, fundido: null,
      alto: new Resorte(panel, pintaAlto, 0), cruz: new Resorte(barra, pintaGiro, 90) };
  }
  function alternar(it, abrir) {
    if (it.abierto === abrir) return;
    it.abierto = abrir;
    it.btn.setAttribute("aria-expanded", String(abrir));
    it.raiz.classList.toggle("abierto", abrir);
    const p = it.panel;
    if (it.fundido) { it.fundido.cancel(); it.fundido = null; }
    if (abrir) {
      if (!it.alto.anim) it.alto.valor = it.tension ? it.tension.actual().x : 0;
      if (it.tension) it.tension.fijar(0);
      p.hidden = false;
      it.alto.ir(it.cuerpo.offsetHeight, MUELLE.abrir, () => { p.style.height = ""; });
      if (!reducido.matches) it.cuerpo.animate(
        [{ opacity: 0, transform: "translateY(-10px)" }, { opacity: 1, transform: "none" }],
        { duration: 420, delay: 60, easing: "cubic-bezier(.2,.8,.2,1)", fill: "backwards" });
    } else {
      if (!it.alto.anim) it.alto.valor = p.offsetHeight;
      it.alto.ir(0, MUELLE.cerrar, () => { p.hidden = true; p.style.height = ""; });
      if (!reducido.matches) it.fundido = it.cuerpo.animate([{ opacity: 1 }, { opacity: 0 }],
        { duration: 200, easing: "ease-out", fill: "forwards" });
    }
    it.cruz.ir(abrir ? 180 : 90, MUELLE.icono);
    pulsar(abrir ? 1 : 0.6);
    actualizarTodas();
  }

  const lista = document.querySelector(".acordeon");
  const items = [...lista.querySelectorAll("[data-item]")].map((li) => {
    const it = plegable(li, li.querySelector(".boton"));
    it.q = li.querySelector(".pregunta");
    it.tag = li.querySelector(".etiqueta");
    it.htmlQ = it.q.innerHTML;
    it.htmlA = it.cuerpo.innerHTML;
    it.textoQ = it.q.textContent.trim();
    it.textoA = it.cuerpo.textContent.replace(/\s+/g, " ").trim();
    // Tensión: con ratón, la fila cerrada se estira unos píxeles como un muelle al pasar por encima
    const t = document.createElement("div");
    t.className = "tension";
    t.setAttribute("aria-hidden", "true");
    li.querySelector("h3").after(t);
    it.tension = new Resorte(t, pintaAlto, 0);
    it.btn.addEventListener("pointerenter", (e) => { if (e.pointerType === "mouse" && !it.abierto) it.tension.ir(8, MUELLE.tension); });
    it.btn.addEventListener("pointerleave", () => { if (!it.abierto) it.tension.ir(0, MUELLE.tension); });
    return it;
  });
  const ld = plegable(document.querySelector(".ld"), document.getElementById("q-ld"));
  [...items, ld].forEach((it) => {
    it.panel.hidden = true;
    it.btn.addEventListener("click", () => alternar(it, !it.abierto));
  });

  // Abrir o cerrar todas las visibles, en cascada
  const btnTodas = document.querySelector(".todas");
  const visibles = () => items.filter((it) => !it.raiz.hidden);
  function actualizarTodas() {
    const v = visibles();
    btnTodas.textContent = v.length && v.every((it) => it.abierto) ? "Cerrar todas" : "Abrir todas";
    btnTodas.hidden = !v.length;
  }
  btnTodas.addEventListener("click", () => {
    const v = visibles(), abrir = !v.every((it) => it.abierto);
    v.forEach((it, i) => setTimeout(() => alternar(it, abrir), reducido.matches ? 0 : i * 45));
  });

  // Teclado: flechas, Inicio y Fin entre preguntas
  lista.addEventListener("keydown", (e) => {
    const bts = visibles().map((it) => it.btn), i = bts.indexOf(document.activeElement);
    if (i < 0) return;
    const destino = { ArrowDown: bts[(i + 1) % bts.length], ArrowUp: bts[(i - 1 + bts.length) % bts.length], Home: bts[0], End: bts[bts.length - 1] }[e.key];
    if (destino) { e.preventDefault(); destino.focus(); }
  });

  // Buscador sin tildes ni mayúsculas, con resaltado
  const quitar = (s) => s.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
  const normaliza = (s) => s.split("").map((ch) => quitar(ch).charAt(0) || ch).join("");
  items.forEach((it) => { it.nq = normaliza(it.textoQ); it.na = normaliza(it.textoA); });

  function marcar(el, terms) {
    const nodos = [], w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
    while (w.nextNode()) nodos.push(w.currentNode);
    nodos.forEach((nodo) => {
      const t = nodo.nodeValue, n = normaliza(t), tramos = [];
      terms.forEach((term) => { let i = n.indexOf(term); while (i > -1) { tramos.push([i, i + term.length]); i = n.indexOf(term, i + term.length); } });
      if (!tramos.length) return;
      tramos.sort((a, b) => a[0] - b[0]);
      const frag = document.createDocumentFragment();
      let cursor = 0;
      tramos.forEach(([a, b]) => {
        if (a < cursor) a = cursor;
        if (b <= a) return;
        frag.append(t.slice(cursor, a));
        const m = document.createElement("mark"); m.textContent = t.slice(a, b); frag.append(m);
        cursor = b;
      });
      frag.append(t.slice(cursor));
      nodo.replaceWith(frag);
    });
  }

  const input = document.getElementById("faq-q");
  const borrar = document.querySelector(".borrar");
  const estado = document.getElementById("faq-estado");
  const vacio = document.querySelector(".vacio");
  let espera = 0;
  function filtrar() {
    const bruto = input.value.trim();
    const terms = normaliza(bruto.toLowerCase()).split(/\s+/).filter(Boolean);
    borrar.hidden = !input.value;
    let n = 0;
    items.forEach((it) => {
      const antes = it.raiz.hidden;
      it.q.innerHTML = it.htmlQ;
      it.cuerpo.innerHTML = it.htmlA;
      const todo = it.nq + " " + it.na;
      const ok = terms.every((t) => todo.includes(t));
      it.raiz.hidden = !ok;
      it.tag.hidden = !terms.length || !ok || terms.some((t) => it.nq.includes(t));
      if (!ok) return;
      n++;
      if (terms.length) { marcar(it.q, terms); marcar(it.cuerpo, terms); }
      if (antes && !reducido.matches) it.raiz.animate([{ opacity: 0, transform: "translateY(8px)" }, { opacity: 1, transform: "none" }], { duration: 320, easing: "cubic-bezier(.16,1,.3,1)" });
    });
    vacio.hidden = n > 0;
    vacio.querySelector(".vacio__q").textContent = bruto;
    estado.textContent = !terms.length ? items.length + " preguntas"
      : n ? n + (n === 1 ? " resultado" : " resultados") + " para «" + bruto + "»"
      : "Sin resultados para «" + bruto + "»";
    actualizarTodas();
  }
  input.addEventListener("input", () => { clearTimeout(espera); espera = setTimeout(filtrar, 120); });
  input.addEventListener("keydown", (e) => { if (e.key === "Escape" && input.value) { e.preventDefault(); input.value = ""; filtrar(); } });
  const limpiar = () => { input.value = ""; filtrar(); input.focus(); };
  borrar.addEventListener("click", limpiar);
  document.querySelector(".ver-todas").addEventListener("click", limpiar);
  document.querySelectorAll(".chip").forEach((c) => c.addEventListener("click", () => { input.value = c.dataset.q; filtrar(); input.focus(); }));
  document.addEventListener("keydown", (e) => {
    const escribiendo = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement.tagName);
    if (e.key === "/" && !escribiendo && !e.ctrlKey && !e.metaKey) { e.preventDefault(); input.focus(); }
  });

  // JSON-LD FAQPage generado desde el mismo contenido
  const datos = {
    "@context": "https://schema.org",
    "@type": "FAQPage",
    mainEntity: items.map((it) => ({
      "@type": "Question",
      name: it.textoQ,
      acceptedAnswer: { "@type": "Answer", text: it.textoA }
    }))
  };
  const script = document.createElement("script");
  script.type = "application/ld+json";
  script.textContent = JSON.stringify(datos);
  document.head.append(script);
  const json = JSON.stringify(datos, null, 2);
  document.querySelector(".codigo code").innerHTML = json
    .replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]))
    .replace(/("(?:[^"\\]|\\.)*")(\s*:)?/g, (m, s, dos) => dos ? '<span class="k">' + s + "</span>" + dos : '<span class="s">' + s + "</span>");
  const copiar = document.querySelector(".copiar");
  copiar.addEventListener("click", async () => {
    try { await navigator.clipboard.writeText(json); copiar.textContent = "Copiado"; }
    catch (e) { getSelection().selectAllChildren(document.querySelector(".codigo code")); copiar.textContent = "Selecciona y copia"; }
    setTimeout(() => { copiar.textContent = "Copiar"; }, 1800);
  });

  // Entrada y primera pregunta abierta (o la del enlace #ancla)
  const porAncla = () => items.find((it) => "#" + it.raiz.id === location.hash);
  addEventListener("hashchange", () => { const it = porAncla(); if (it) alternar(it, true); });
  const primera = porAncla() || items[0];
  if (reducido.matches) { alternar(primera, true); return; }
  const entrada = [...document.querySelectorAll(".intro > *, .cabecera, .acordeon > li, .ld")];
  entrada.forEach((el, i) => el.animate(
    [{ opacity: 0, transform: "translateY(18px)" }, { opacity: 1, transform: "none" }],
    { duration: 900, delay: 80 + i * 45, easing: "cubic-bezier(.16,1,.3,1)", fill: "backwards" }));
  setTimeout(() => alternar(primera, true), 750);
})();
```

Por qué está hecho así (no lo cambies):

- **`muelle()`** integra con Euler semiimplícito a 240 Hz (4 subpasos por fotograma) y guarda una muestra por fotograma a 60 fps. Para cuando la distancia al destino y la velocidad caen por debajo de un umbral relativo (`0.25 %` del recorrido), con tope de 3 s. La última muestra se fuerza al valor exacto de destino.
- **`Resorte`** convierte esas muestras en fotogramas clave para `el.animate(…, { easing: "linear" })`. Antes de animar deja **escrito el estado final** en el estilo en línea, así que al terminar o cancelar no hay parpadeo. `actual()` interpola la posición y lee la velocidad de la muestra en curso a partir de `anim.currentTime`: con eso, cualquier interrupción arranca un muelle nuevo con la velocidad heredada.
- Al **abrir**: se quita `hidden`, se mide `.cuerpo.offsetHeight` (el hijo tiene su altura natural aunque el panel mida 0) y se anima la altura; al acabar se borra `height` para que vuelva a `auto` y acompañe a cualquier cambio de ancho. Al **cerrar**: si no había animación, se parte de `offsetHeight` real; al acabar se pone `hidden`.
- La **tensión** vive en un `div.tension` que el JS inserta tras cada `h3`. Al abrir, su altura actual se pasa como punto de partida del panel y la tensión se fija a 0: el estirón se convierte en apertura sin escalón.
- **Buscador**: `normaliza()` pasa carácter a carácter por NFD y quita las marcas diacríticas, de forma que la cadena normalizada tiene **la misma longitud** que la original y los índices sirven para cortar el texto real al envolver `<mark>`. Antes de cada búsqueda se restaura el HTML original de pregunta y respuesta (guardado al arrancar). Espera de 120 ms tras teclear.
- **JSON-LD**: se construye una sola vez con los textos originales (sin `<mark>`), se inserta en el `<head>` y se muestra formateado con `JSON.stringify(…, null, 2)`, coloreando claves (`.k`) y cadenas (`.s`) con una expresión regular tras escapar `& < >`.

## Interacción

- **Clic / Enter / Espacio** en una pregunta: abre o cierra (varias pueden estar abiertas a la vez). Actualiza `aria-expanded`, la clase `.abierto` del `li`, el icono y el cabo.
- **Teclado en la lista**: ↓ y ↑ mueven el foco a la pregunta visible siguiente/anterior (en bucle), Inicio y Fin a la primera y la última.
- **«Abrir todas» / «Cerrar todas»**: actúa solo sobre las preguntas visibles (respeta el filtro), en cascada de 45 ms; su texto cambia según estén todas abiertas o no, y se oculta si no hay resultados.
- **Buscador**: escribe y filtra; `/` enfoca el campo desde cualquier sitio (salvo si ya se escribe en un campo); `Esc` lo vacía; el botón redondo de tinta con × aparece cuando hay texto y lo vacía; el atajo `kbd /` se oculta cuando hay texto. Las píldoras «fianza», «patrón», «perro», «cancelar», «niños» rellenan el campo y filtran.
- **Estado** (`#faq-estado`, `aria-live="polite"`): «10 preguntas» · «N resultado(s) para «texto»» · «Sin resultados para «texto»».
- **Estado vacío**: «Nada sobre «texto».» + contacto + botón «Ver todas las preguntas» que limpia la búsqueda y devuelve el foco al campo.
- **Anclas**: si la URL trae `#fianza` (o cualquier `id` de `li`), esa pregunta es la que se abre al cargar; `hashchange` abre la nueva.
- **Hover** (solo `hover: hover` y `pointer: fine`): la pregunta se desplaza 8 px, el círculo crece a 1.08 y marca su borde en tinta, la fila se estira 8 px; las píldoras se rellenan de tinta; las flechas de los enlaces de contacto avanzan 4 px.
- **Táctil**: todo funciona con toque; no hay nada que dependa del hover (la tensión solo responde a `pointerType === "mouse"`).
- **Copiar** el JSON-LD: `navigator.clipboard.writeText`; si el iframe lo bloquea, selecciona el código y el botón dice «Selecciona y copia». Vuelve a «Copiar» a los 1,8 s.

## Adaptación a móvil y tablet

- **≤ 980 px**: una sola columna; la introducción deja de ser pegajosa; se ocultan las coordenadas; hueco entre bloques 56 px.
- **≤ 600 px**: barra de metadatos a 10,5 px y sin « · abril–octubre»; titular `clamp(42px, 12.5vw, 60px)`; filas con rejilla `34px · 1fr · auto` y padding 22 px; icono de 40 px; respuesta con padding `0 0 26px 44px` y 16 px; las tablas `dl.datos` pasan a una columna con etiqueta a la izquierda y cifra a la derecha; contacto en una columna sin sangría; estado vacío sin sangría; se oculta el `kbd`.
- El `kbd /` también se oculta en dispositivos sin hover.
- Sin scroll horizontal de 360 a 1920 px (`overflow-x: hidden` en `body` y `minmax(0, …)` en todas las columnas). Usa `100svh`, nunca `100vh`.

## Accesibilidad y movimiento reducido

- Cada pregunta es un `button` dentro de un `h3`, con `aria-expanded` y `aria-controls` apuntando a su panel. Número e icono llevan `aria-hidden`. Los paneles ocultos usan el atributo `hidden` (fuera del árbol de accesibilidad y del orden de tabulación).
- El buscador tiene `label` visible, `role="search"` en el contenedor, `aria-describedby="faq-estado"` y un botón de borrar con `aria-label="Borrar la búsqueda"`.
- Foco visible: `outline: 2px solid var(--acento)` con `outline-offset: 3px`.
- Contraste: tinta sobre papel 13,8:1; tinta suave 6,8:1; acento 4,7:1.
- El cabo SVG y la `div.tension` son decorativos (`aria-hidden`). El `pre` del código es enfocable (`tabindex="0"`) para poder desplazarlo con teclado.
- **`prefers-reduced-motion: reduce`**: no hay entrada ni cascadas; `Resorte.ir` aplica el valor final al instante; el cabo no vibra; la tensión no se mueve; las transiciones CSS duran .01 ms y la pregunta no se desplaza en hover. La primera pregunta se abre al cargar sin animación.

## Rendimiento

- Sin WebGL ni bucles permanentes: `rendimiento` = DPR no aplica, sin fallback necesario.
- Las alturas se animan con WAAPI (fotogramas precalculados); el único `requestAnimationFrame` es el del cabo y **se detiene solo** a los 2,2 s de cada pulso.
- Los muelles se integran una vez por interacción (≤ 180 muestras) y se descartan.
- La búsqueda espera 120 ms tras la última tecla antes de filtrar.
- Peso total del HTML ≈ 38 KB.

## Personalización

- **Marca y datos**: Noray, Brenmar, +00 000 000 000 y hola@noray.com aparecen en la barra superior, el `<title>`, la descripción, el contacto y el estado vacío. Cambia también las coordenadas de la barra por las de tu local.
- **Colores**: `--papel` (#f3eee4), `--tinta` (#11242f) y `--acento` (#b8431b) en `:root`. Filetes, resaltado y anillo de foco se derivan solos con `color-mix`. Si el acento es claro, oscurécelo hasta 4,5:1 sobre el fondo: se usa en texto pequeño.
- **Preguntas**: añade, quita o reordena `li.item` siguiendo el patrón (ids `SLUG`, `q-SLUG`, `r-SLUG` únicos). El contador, el buscador, «Abrir todas» y el JSON-LD se actualizan solos porque leen el DOM. Puedes usar `strong`, enlaces y `dl.datos` dentro de las respuestas.
- **Sensación del muelle**: en `MUELLE`, sube `c` para menos rebote (≈ 26 es casi crítico con k 170) o baja `k` para una apertura más lenta.
- **Sugerencias**: cambia los `data-q` y el texto de las píldoras por las dudas más buscadas de tu negocio.
- **Cabo**: si el sector no es náutico, puede sustituirse por un filete recto; basta con borrar el `svg.cabo` (el JS no falla si no existe, porque `querySelectorAll` devuelve una lista vacía).

## Comprobación final

1. Al cargar, los bloques entran en cascada y a los 750 ms la pregunta 01 se abre con un rebote visible y se asienta; el cabo vibra y se detiene.
2. Clic en «¿Qué incluye el precio?»: la altura sobrepasa y vuelve; aparece la tabla de tres celdas con 390 €, 690 € y 290 € (IVA incl.).
3. Doble clic rápido en una pregunta: la animación se invierte desde donde estaba, sin saltos.
4. Con ratón, pasar por encima de una fila cerrada la estira 8 px con rebote; al hacer clic se abre sin escalón.
5. El + se convierte en − girando con muelle y el círculo se rellena de tinta; el filete superior de la fila abierta se pinta de acento.
6. Buscar «ninos» (sin tilde) muestra solo la pregunta 09 con «niños» resaltado; buscar «perro» muestra la 07; «xyzzy» enseña el estado vacío y «Sin resultados para «xyzzy»».
7. Una coincidencia solo en la respuesta (p. ej. «Bizum») muestra la etiqueta «En la respuesta».
8. «Abrir todas» abre en cascada solo las visibles y pasa a «Cerrar todas».
9. ↓/↑/Inicio/Fin mueven el foco entre preguntas; `/` enfoca el buscador; `Esc` lo vacía.
10. En `<head>` existe un `script[type="application/ld+json"]` con `@type: "FAQPage"` y 10 `Question`; la tarjeta de datos estructurados lo muestra coloreado.
11. Con `prefers-reduced-motion: reduce`, todo se abre y cierra al instante y nada vibra.
12. A 360 px no hay scroll horizontal y las tablas de datos pasan a una columna.
13. Sin JS se leen todas las respuestas.
14. Consola sin errores.

Prueba en frío

Lo que salió al pegarlo en frío

Pegamos el prompt en Claude Sonnet 5.5, sin ayuda ni contexto, y comparamos el resultado con la demo. Prueba hecha el 30-09-2026.

Lo que generó la IA con el prompt Completo de FAQ con acordeón de muelle
Prompt CompletoFiel a la demo9,8/10

Calco prácticamente exacto: el HTML resultante coincide línea a línea con la demo (solo difiere una línea en blanco), así que maquetación, muelles, cabo que vibra, buscador y JSON-LD son idénticos en escritorio y móvil.

Lo que generó la IA con el prompt Lite de FAQ con acordeón de muelle
Prompt LiteFiel a la demo7,2/10

El núcleo sale igual: rejilla, titular, cabo, filas con muelle real (mismos k/c), tensión, buscador sin tildes y JSON-LD. Cambia el acabado: grano de papel más sucio y por encima del contenido, titular sin el eje óptico, respuestas resumidas, contacto convertido en tarjeta con píldoras, tarjeta JSON-LD con título invertido y sin muelle, y en móvil la barra superior se parte en dos líneas.

Solo publicamos lo que hemos probado. Otras herramientas (ChatGPT, Gemini, Lovable…) pueden dar resultados distintos.

Paso a paso

Cómo usarlo en 4 pasos

  1. 01

    Personaliza

    Rellena el formulario de arriba con el nombre, los colores y los textos de tu cliente. Se meten en el prompt al copiar.
  2. 02

    Copia el Completo o el Lite

    El Completo, para Claude, ChatGPT, Gemini o Cursor. El Lite, más corto, para Lovable, Bolt o v0, que aceptan menos texto.
  3. 03

    Pega e itera

    Pégalo en una conversación nueva. Si quieres cambiar algo, pídelo con una frase corta:
    • «Que el muelle rebote menos al abrir»
    • «Cambia las preguntas por las de una clínica dental en Brenmar»
    • «Quita el cabo decorativo y deja un filete recto»
    • «Pon el fondo en azul noche y el texto en crema»
  4. 04

    Publica

    Guarda el resultado como HTML (o en tu proyecto) y súbelo a Vercel, Netlify o tu hosting de siempre. Tienes los pasos en la guía.

Guías por herramientaClaudeChatGPTGeminiCursorLovableBoltv0

Licencia en claro

Qué puedes hacer con esta pieza

Todas las piezas incluyen licencia de uso comercial. Usa el resultado tal cual en tus productos, encargos freelance o webs de clientes, sin atribución.

Puedes

  • Usarlo en tus webs y en las de tus clientes, también si cobras por ellas.
  • Cambiarlo todo lo que quieras.
  • Publicar el resultado sin citarnos.

No puedes

  • Revender o regalar el prompt, la demo o el código como plantilla o biblioteca.
  • Compartir tu cuenta Pro.

Es la misma licencia para lo gratis y lo Pro. La tienes entera en licencia de uso.

Preguntas frecuentes

Lo que suele preguntarse

¿El JSON-LD sirve de verdad para Google?
Sí: se genera un bloque FAQPage válido con las mismas preguntas y respuestas que ve el usuario y se inserta en el head. Si tu web se renderiza en servidor, lo ideal es generar ese mismo JSON en el servidor; el prompt explica cómo se construye.
¿Puedo añadir o quitar preguntas sin tocar el JavaScript?
Sí. Basta con copiar el patrón de un elemento de la lista con ids únicos: el contador, el buscador, el botón de abrir todas y el JSON-LD leen el contenido del HTML y se actualizan solos.
¿Funciona con lector de pantalla y teclado?
Cada pregunta es un botón dentro de un encabezado con aria-expanded y aria-controls, se navega con flechas, Inicio y Fin, el buscador anuncia los resultados y con movimiento reducido todo se abre al instante.
¿Puedo hacer que solo haya una respuesta abierta a la vez?
Sí: pide que al abrir una pregunta se cierren las demás llamando a la función de alternar con false en el resto; el muelle de cierre ya está pensado para no rebotar.
¿Qué diferencia hay entre el prompt Completo y el Lite?
El Completo (≈15566 tokens) describe la pieza con todo detalle y es el que mejor resultado da en Claude, ChatGPT, Gemini o Cursor. El Lite (≈2653 tokens) resume lo esencial para herramientas que aceptan menos texto, como Lovable, Bolt o v0.

Versión 1.0 · publicada el 30-09-2026

Cópialo desde aquí

Tu navegador no nos deja escribir en el portapapeles. El texto ya está seleccionado: pulsa Ctrl + C (o ⌘ + C) o el botón.