La portada abre con tres láminas: el disco en el tocadiscos, una invitación al taller y otra a la tienda. Un carrusel mal hecho es de las piezas más hostiles que hay —cambia mientras lees, no se para, y para un lector de pantalla es un cajón que aparece y desaparece—, así que este se para al pasar el puntero o al entrar el foco, tiene botón de pausa, no gira solo con `prefers-reduced-motion` y deja las láminas ocultas con `inert`, fuera del orden de tabulación. Las letras que no están en español se enfrentan con su traducción en dos columnas, verso a verso. Cada columna declara su `lang`, que es lo que impide que un lector de pantalla lea el inglés con voz española, y la traducción se pinta con `pre-line`: es verso, y perder los cortes la deja inservible para seguir la original. El nombre del idioma sale de `Intl.DisplayNames`. Sobre el reproductor: ya sobrevivía a la navegación, porque vive en el layout raíz fuera de `main`. Lo que faltaba era comprobarlo de verdad; el test decía que la barra seguía visible, no que siguiera sonando. Ahora mira que la posición avance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>master
parent
4c65a5b719
commit
e1884864cf
@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
titulo: 'The long way home'
|
||||||
|
estilo: pop-melodico
|
||||||
|
ano: 2025
|
||||||
|
idioma: en
|
||||||
|
duracion: '3:24'
|
||||||
|
interpretes:
|
||||||
|
- 'Senza Paura'
|
||||||
|
etiquetas:
|
||||||
|
- 'Viajes'
|
||||||
|
- 'Distancia'
|
||||||
|
alaVenta: false
|
||||||
|
traduccion: |
|
||||||
|
Dejé las luces encendidas al salir,
|
||||||
|
no porque fuera a volver
|
||||||
|
sino porque la casa vacía
|
||||||
|
no tenía por qué parecerlo.
|
||||||
|
|
||||||
|
Cojo el camino largo a casa,
|
||||||
|
el que pasa por donde ya no vives.
|
||||||
|
---
|
||||||
|
|
||||||
|
I left the lights on when I walked out,
|
||||||
|
not because I planned on coming back
|
||||||
|
but because an empty house
|
||||||
|
had no business looking like one.
|
||||||
|
|
||||||
|
I take the long way home,
|
||||||
|
the one that passes where you don't live anymore.
|
||||||
@ -0,0 +1,262 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
/** Una lámina del carrusel. El `id` va en el ancla y en los controles. */
|
||||||
|
export interface Lamina {
|
||||||
|
id: string;
|
||||||
|
/** Cómo se nombra en los puntos y en el anuncio a un lector de pantalla. */
|
||||||
|
etiqueta: string;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Carrusel de la portada.
|
||||||
|
*
|
||||||
|
* Un carrusel mal hecho es de las piezas más hostiles que hay: cambia solo
|
||||||
|
* mientras alguien lee, no se puede parar y para un lector de pantalla es un
|
||||||
|
* cajón de contenido que aparece y desaparece sin avisar. Este intenta no
|
||||||
|
* serlo:
|
||||||
|
*
|
||||||
|
* - **Se para solo** al pasar el puntero o al poner el foco dentro, y sigue
|
||||||
|
* al salir. Nadie pierde una lámina por leerla despacio.
|
||||||
|
* - **Hay un botón de pausa** de verdad, y su estado manda sobre todo lo
|
||||||
|
* demás: si alguien lo pulsa, no se vuelve a mover.
|
||||||
|
* - **Con `prefers-reduced-motion` no gira solo.** Quien pide menos
|
||||||
|
* movimiento no quiere que la página se mueva sin pedirlo.
|
||||||
|
* - Las láminas que no se ven quedan **fuera del orden de tabulación**
|
||||||
|
* (`inert`), así que el teclado no se pierde en botones invisibles.
|
||||||
|
* - Las flechas izquierda y derecha mueven el carrusel cuando el foco está
|
||||||
|
* en sus controles.
|
||||||
|
*
|
||||||
|
* Todas las láminas se pintan a la vez y se cruzan con opacidad; no se
|
||||||
|
* desmontan. Así el vídeo o el fondo de la primera no se recargan cada vez
|
||||||
|
* que se vuelve a ella.
|
||||||
|
*/
|
||||||
|
import Icono from './Icono.svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
laminas: Lamina[];
|
||||||
|
/** Qué se pinta en cada lámina, por su índice. */
|
||||||
|
lamina: Snippet<[number]>;
|
||||||
|
/** Cada cuánto pasa sola, en milisegundos. */
|
||||||
|
intervalo?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { laminas, lamina, intervalo = 7000 }: Props = $props();
|
||||||
|
|
||||||
|
let actual = $state(0);
|
||||||
|
/** Lo que ha decidido quien mira. Manda sobre la parada automática. */
|
||||||
|
let pausadoAMano = $state(false);
|
||||||
|
/** Parada temporal: el puntero encima o el foco dentro. */
|
||||||
|
let retenido = $state(false);
|
||||||
|
|
||||||
|
const total = $derived(laminas.length);
|
||||||
|
const enMovimiento = $derived(!pausadoAMano && !retenido);
|
||||||
|
|
||||||
|
function ir(indice: number) {
|
||||||
|
actual = (indice + total) % total;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!enMovimiento || total < 2) return;
|
||||||
|
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||||
|
|
||||||
|
const reloj = setInterval(() => ir(actual + 1), intervalo);
|
||||||
|
return () => clearInterval(reloj);
|
||||||
|
});
|
||||||
|
|
||||||
|
function alTeclado(evento: KeyboardEvent) {
|
||||||
|
if (evento.key === 'ArrowLeft') ir(actual - 1);
|
||||||
|
else if (evento.key === 'ArrowRight') ir(actual + 1);
|
||||||
|
else return;
|
||||||
|
evento.preventDefault();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="carrusel"
|
||||||
|
aria-roledescription="carrusel"
|
||||||
|
aria-label="Portada"
|
||||||
|
onpointerenter={() => (retenido = true)}
|
||||||
|
onpointerleave={() => (retenido = false)}
|
||||||
|
onfocusin={() => (retenido = true)}
|
||||||
|
onfocusout={() => (retenido = false)}
|
||||||
|
>
|
||||||
|
<div class="laminas">
|
||||||
|
{#each laminas as l, i (l.id)}
|
||||||
|
<div
|
||||||
|
class="lamina"
|
||||||
|
class:visible={i === actual}
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="lámina"
|
||||||
|
aria-label="{i + 1} de {total}: {l.etiqueta}"
|
||||||
|
aria-hidden={i === actual ? undefined : 'true'}
|
||||||
|
inert={i === actual ? undefined : true}
|
||||||
|
>
|
||||||
|
{@render lamina(i)}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if total > 1}
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<div class="mandos" role="group" aria-label="Controles de la portada" onkeydown={alTeclado}>
|
||||||
|
<button type="button" class="flecha" onclick={() => ir(actual - 1)} aria-label="Anterior">
|
||||||
|
<Icono nombre="anterior" tamano={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ul class="puntos">
|
||||||
|
{#each laminas as l, i (l.id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="punto"
|
||||||
|
class:activo={i === actual}
|
||||||
|
onclick={() => ir(i)}
|
||||||
|
aria-label="Ir a: {l.etiqueta}"
|
||||||
|
aria-current={i === actual ? 'true' : undefined}
|
||||||
|
></button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button type="button" class="flecha" onclick={() => ir(actual + 1)} aria-label="Siguiente">
|
||||||
|
<Icono nombre="siguiente" tamano={16} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flecha"
|
||||||
|
onclick={() => (pausadoAMano = !pausadoAMano)}
|
||||||
|
aria-label={pausadoAMano ? 'Reanudar el paso automático' : 'Parar el paso automático'}
|
||||||
|
>
|
||||||
|
<Icono nombre={pausadoAMano ? 'play' : 'pausa'} tamano={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.carrusel {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Las láminas se apilan y se cruzan con opacidad. La rejilla de una sola
|
||||||
|
* celda las superpone dejando que la más alta marque la altura, así que el
|
||||||
|
* bloque no da saltos al cambiar de lámina.
|
||||||
|
*/
|
||||||
|
.laminas {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lamina {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition:
|
||||||
|
opacity 0.7s var(--suave),
|
||||||
|
visibility 0s linear 0.7s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lamina.visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transition:
|
||||||
|
opacity 0.7s var(--suave),
|
||||||
|
visibility 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mandos {
|
||||||
|
/* En variable: el minificador junta las funciones del filtro sin espacio. */
|
||||||
|
--desenfoque-mandos: blur(10px) saturate(1.3);
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
right: max(var(--sp-4), calc((100vw - var(--container)) / 2));
|
||||||
|
bottom: var(--sp-5);
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-1);
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in oklab, var(--c-bg) 70%, transparent);
|
||||||
|
backdrop-filter: var(--desenfoque-mandos);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flecha {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--c-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s var(--suave);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flecha:hover,
|
||||||
|
.flecha:focus-visible {
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.puntos {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* El punto activo se alarga en vez de solo cambiar de color: así se
|
||||||
|
* distingue sin depender del color, que es lo que hace falta para quien no
|
||||||
|
* los separa bien.
|
||||||
|
*/
|
||||||
|
.punto {
|
||||||
|
display: block;
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--c-text-muted);
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
width 0.4s var(--sal),
|
||||||
|
opacity 0.3s var(--suave),
|
||||||
|
background 0.3s var(--suave);
|
||||||
|
}
|
||||||
|
|
||||||
|
.punto:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.punto.activo {
|
||||||
|
width: 1.5rem;
|
||||||
|
background: var(--c-accent);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
.mandos {
|
||||||
|
right: 50%;
|
||||||
|
transform: translateX(50%);
|
||||||
|
bottom: var(--sp-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.lamina,
|
||||||
|
.flecha,
|
||||||
|
.punto {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,115 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Una lámina del carrusel de la portada que propone algo.
|
||||||
|
*
|
||||||
|
* Comparte el alto y el fondo con la cabecera del disco para que el carrusel
|
||||||
|
* no dé saltos al pasar de una a otra, pero no repite el nombre del artista:
|
||||||
|
* el `h1` está en la primera lámina y aquí sobraría un segundo.
|
||||||
|
*
|
||||||
|
* El fondo es el mismo archivo que ya se descargó para la cabecera, así que
|
||||||
|
* estas dos láminas no añaden peso.
|
||||||
|
*/
|
||||||
|
import { heroe } from '$lib/site';
|
||||||
|
import { tema } from '$lib/tema.svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Rótulo corto sobre el título. */
|
||||||
|
eyebrow: string;
|
||||||
|
titulo: string;
|
||||||
|
texto: string;
|
||||||
|
/** Los botones. Van como snippet para poder mezclar enlaces y acciones. */
|
||||||
|
acciones: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { eyebrow, titulo, texto, acciones }: Props = $props();
|
||||||
|
|
||||||
|
const fondo = $derived(heroe.fondo[tema.actual]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="lamina-cta">
|
||||||
|
<div class="fondo">
|
||||||
|
{#if fondo}
|
||||||
|
<img
|
||||||
|
src={fondo.src}
|
||||||
|
srcset={fondo.srcset}
|
||||||
|
sizes="100vw"
|
||||||
|
width={fondo.ancho}
|
||||||
|
height={fondo.alto}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<span class="velo"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<p class="eyebrow">{eyebrow}</p>
|
||||||
|
<h2>{titulo}</h2>
|
||||||
|
<p class="entradilla">{texto}</p>
|
||||||
|
<div class="acciones">{@render acciones()}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.lamina-cta {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: min(88vh, 46rem);
|
||||||
|
padding-block: var(--sp-8);
|
||||||
|
overflow: hidden;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fondo {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
background: var(--c-bg-sunken);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fondo img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Aquí el velo es más cerrado que en la cabecera del disco: no hay
|
||||||
|
* tocadiscos que enseñar a la derecha y lo único que importa es que se lea
|
||||||
|
* lo que se propone.
|
||||||
|
*/
|
||||||
|
.velo {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
to right,
|
||||||
|
color-mix(in oklab, var(--c-bg) 92%, transparent) 0%,
|
||||||
|
color-mix(in oklab, var(--c-bg) 70%, transparent) 55%,
|
||||||
|
color-mix(in oklab, var(--c-bg) 30%, transparent) 100%
|
||||||
|
),
|
||||||
|
linear-gradient(to top, var(--c-bg) 0%, transparent 35%);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
max-width: 18ch;
|
||||||
|
margin: var(--sp-2) 0 0;
|
||||||
|
font-size: var(--fs-3xl);
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entradilla {
|
||||||
|
max-width: 52ch;
|
||||||
|
margin-top: var(--sp-4);
|
||||||
|
font-size: var(--fs-lg);
|
||||||
|
color: var(--c-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.acciones {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
margin-top: var(--sp-5);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in new issue