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