Acceso en un diálogo de dos mitades y arregla el apilamiento del carrusel

El acceso deja de ser un salto de página: «Entrar» abre un diálogo con la
imagen de marca a la izquierda y el formulario a la derecha, y al entrar
se vuelve a donde se estaba en vez de aterrizar en /cuenta. `/entrar`
sigue existiendo con el mismo formulario y la misma estampa, para quien
llegue por un enlace directo o sin JavaScript.

Tres cosas que salieron de probarlo, no de suponerlo:

- `showModal()` enfocaba el campo trampa contra envíos automáticos: está
  fuera de pantalla y fuera del recorrido de tabulación, pero se puede
  enfocar. Ahora el campo se enfoca a mano.
- Una regla de `display` en el diálogo le gana a la del navegador, que es
  hoja de usuario. El diálogo cerrado se pintaba igual: una pantalla de
  vacío al final de todas las páginas del sitio.
- Los enlaces legales navegaban por debajo del modal. Van en pestaña
  aparte: se consultan y se vuelve con lo escrito intacto.

Y aparte, el carrusel de la portada. Cada lámina lleva su fondo con
`z-index: -1`, pero con `opacity: 1` la lámina no crea contexto de
apilamiento, así que ese fondo se escapaba y se pintaba detrás del
carrusel entero. Al pasar de lámina asomaba la foto de la de al lado y el
fondo de la que entraba saltaba hacia atrás al terminar el fundido.
Parecía que el shader se desincronizaba; lo que se movía era el orden de
pintado.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
master
dev 4 weeks ago
parent f248ad57da
commit 383d0ce913

@ -130,6 +130,69 @@ test.describe('Carrusel de la portada', () => {
}
});
test('cada lámina se lleva su propio fondo', async ({ page }) => {
/*
* El fondo a sangre de cada lámina va con `z-index: -1`. Si la lámina no
* crea su propio contexto de apilamiento, ese fondo se escapa hacia atrás
* y se pinta detrás del carrusel entero: al pasar de una lámina a otra
* asomaba la foto de la de al lado, y el fondo de la que entraba daba un
* salto al terminar el fundido.
*
* `opacity: 1` no crea contexto —por eso fallaba justo en la lámina
* visible—, así que lo que se comprueba es que haya algo que sí lo cree.
*/
await page.goto('/');
const laminas = await page.locator('.lamina').all();
expect(laminas.length).toBeGreaterThan(1);
for (const lamina of laminas) {
const propio = await lamina.evaluate((el) => {
const cs = getComputedStyle(el);
return (
cs.isolation === 'isolate' ||
Number(cs.opacity) < 1 ||
cs.transform !== 'none' ||
cs.filter !== 'none' ||
(cs.position !== 'static' && cs.zIndex !== 'auto')
);
});
expect(propio).toBe(true);
}
});
test('al pasar de lámina no asoma la foto de la otra', async ({ page }) => {
await page.goto('/');
// A mitad del fundido, que es cuando se veían las dos superpuestas.
await page.getByRole('button', { name: 'Ir a: Aprender a escribir letras' }).click();
await page.waitForTimeout(300);
// El fondo de la lámina que se va tiene que estar dentro de ella, no
// suelto por detrás: se comprueba que su opacidad efectiva la acompañe.
const asoma = await page.evaluate(() => {
const laminas = [...document.querySelectorAll('.lamina')];
return laminas.some((l) => {
const fondo = l.querySelector('.fondo');
if (!fondo) return false;
const cs = getComputedStyle(l);
// Una lámina que se está yendo no puede pintar su fondo a plena vista.
return Number(cs.opacity) < 0.5 && getComputedStyle(fondo).opacity === '1' && !esSuyo(l);
});
function esSuyo(l: Element) {
const cs = getComputedStyle(l);
return (
cs.isolation === 'isolate' ||
Number(cs.opacity) < 1 ||
cs.transform !== 'none' ||
(cs.position !== 'static' && cs.zIndex !== 'auto')
);
}
});
expect(asoma).toBe(false);
});
test('se puede parar el paso automático', async ({ page }) => {
await page.goto('/');

@ -96,12 +96,125 @@ test.describe('Carrito', () => {
});
});
test.describe('Modal de acceso', () => {
test('«Entrar» abre el diálogo sin salir de la página', async ({ page }) => {
await page.goto('/musica');
await page.locator('header').getByRole('link', { name: 'Entrar', exact: true }).click();
const dialogo = page.locator('dialog.acceso');
await expect(dialogo).toBeVisible();
// Sigue siendo /musica: entrar no debería sacarte de donde estabas.
await expect(page).toHaveURL(/\/musica$/);
});
test('tiene las dos mitades: imagen a un lado y formulario al otro', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/musica');
await page.locator('header').getByRole('link', { name: 'Entrar', exact: true }).click();
const imagen = page.locator('dialog.acceso .ilustracion');
const formulario = page.locator('dialog.acceso .panel');
await expect(imagen).toBeVisible();
await expect(formulario).toBeVisible();
const izquierda = (await imagen.boundingBox())!;
const derecha = (await formulario.boundingBox())!;
// La imagen entera queda a la izquierda del formulario, sin solaparse.
expect(izquierda.x + izquierda.width).toBeLessThanOrEqual(derecha.x + 1);
});
test('el foco entra en el campo del correo, no en el campo trampa', async ({ page }) => {
// `showModal()` enfoca lo primero que encuentra alcanzable, y lo primero
// del formulario es la trampa contra envíos automáticos: está fuera de
// pantalla pero se puede enfocar.
await page.goto('/musica');
await page.locator('header').getByRole('link', { name: 'Entrar', exact: true }).click();
await expect(page.locator('dialog.acceso #email')).toBeFocused();
});
test('se cierra con Escape y el foco vuelve a «Entrar»', async ({ page }) => {
await page.goto('/musica');
const abrir = page.locator('header').getByRole('link', { name: 'Entrar', exact: true });
await abrir.click();
await expect(page.locator('dialog.acceso')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('dialog.acceso')).toBeHidden();
await expect(abrir).toBeFocused();
});
test('cerrado no ocupa sitio en la página', async ({ page }) => {
/*
* El navegador oculta un `<dialog>` cerrado, pero cualquier regla nuestra
* de `display` le gana por venir de la hoja del autor. Con una suelta, el
* diálogo cerrado añadía una pantalla entera de vacío al final de todas
* las páginas del sitio.
*/
await page.goto('/taller');
const oculto = await page
.locator('dialog.acceso')
.evaluate((d) => getComputedStyle(d).display === 'none');
expect(oculto).toBe(true);
});
test('desde el modal, la vuelta apunta a donde se estaba', async ({ page }) => {
await page.goto('/musica/baladas-romanticas');
await page.locator('header').getByRole('link', { name: 'Entrar', exact: true }).click();
await expect(page.locator('dialog.acceso input[name="volverA"]')).toHaveValue(
'/musica/baladas-romanticas'
);
});
test('los enlaces legales abren aparte y no se llevan el formulario', async ({
page,
context
}) => {
/*
* Desde un modal, un enlace normal navega por debajo: la página cambia sin
* que se vea y, al cerrar el diálogo, has aterrizado en otro sitio con lo
* escrito perdido.
*/
await page.goto('/musica');
await page.locator('header').getByRole('link', { name: 'Entrar', exact: true }).click();
await page.fill('dialog.acceso #email', 'sigue.aqui@example.com');
const [aparte] = await Promise.all([
context.waitForEvent('page'),
page.locator('dialog.acceso').getByRole('link', { name: 'condiciones de uso' }).click()
]);
await aparte.waitForLoadState();
expect(aparte.url()).toContain('/legal/condiciones-de-uso');
await expect(page).toHaveURL(/\/musica$/);
await expect(page.locator('dialog.acceso')).toBeVisible();
await expect(page.locator('dialog.acceso #email')).toHaveValue('sigue.aqui@example.com');
await aparte.close();
});
test('sin JavaScript, «Entrar» sigue llevando a la página', async ({ browser }) => {
const contexto = await browser.newContext({ javaScriptEnabled: false });
const pagina = await contexto.newPage();
await pagina.goto('/musica');
await pagina.locator('header').getByRole('link', { name: 'Entrar', exact: true }).click();
await expect(pagina).toHaveURL(/\/entrar/);
await expect(pagina.getByLabel('Correo electrónico')).toBeVisible();
await contexto.close();
});
});
test.describe('Cuenta', () => {
test('la cuenta exige entrar', async ({ page }) => {
await page.goto('/cuenta');
await expect(page).toHaveURL(/\/entrar/);
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Entrar');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Entrar o crear cuenta');
});
test('pedir un código deja el alta en marcha', async ({ page }) => {

@ -0,0 +1,40 @@
/**
* Quién abre el modal de acceso y desde dónde.
*
* Vive fuera del componente porque quien lo abre —la cabecera, el carrito, un
* botón de valorar— no es quien lo pinta: el diálogo se monta una sola vez en
* el layout, y así no hay uno por página ni hace falta pasarse una propiedad
* de arriba abajo.
*/
class ControlDeAcceso {
/** Si el diálogo está pedido. Lo baja el propio componente al cerrarse. */
abierto = $state(false);
/**
* A dónde volver cuando la sesión se abra.
*
* Desde el modal es la página en la que se está: quien pulsa «Entrar»
* mientras mira un disco quiere seguir mirándolo, no aterrizar en su cuenta.
*/
volverA = $state('/cuenta');
/** El elemento que lo abrió, para devolverle el foco al cerrar. */
#origen: HTMLElement | null = null;
abrir(volverA?: string): void {
this.volverA = volverA ?? (typeof location === 'undefined' ? '/cuenta' : location.pathname);
this.#origen = typeof document === 'undefined' ? null : (document.activeElement as HTMLElement);
this.abierto = true;
}
cerrar(): void {
this.abierto = false;
// Sin esto, el foco vuelve al principio del documento y quien navega con
// teclado tiene que recorrer la cabecera entera para seguir donde estaba.
this.#origen?.focus?.();
this.#origen = null;
}
}
export const acceso = new ControlDeAcceso();

@ -1,6 +1,7 @@
<script lang="ts">
/** Cabecera del sitio: marca, navegación principal, carrito y cuenta. */
import { page } from '$app/state';
import { acceso } from '$lib/acceso.svelte';
import { resolve } from '$app/paths';
import BotonTema from './BotonTema.svelte';
import Icono from './Icono.svelte';
@ -129,7 +130,23 @@
Mi cuenta
</a>
{:else}
<a href={resolve('/entrar')}>Entrar</a>
<!--
Sigue siendo un enlace a /entrar, no un botón: sin JavaScript lleva a
la página, que funciona igual. Con JavaScript se queda aquí y abre el
diálogo, porque entrar no debería sacarte de donde estabas.
-->
<a
href={resolve('/entrar')}
onclick={(evento) => {
// Ctrl/Cmd/medio: abrir en otra pestaña tiene que seguir funcionando.
if (evento.metaKey || evento.ctrlKey || evento.shiftKey || evento.button !== 0) return;
evento.preventDefault();
acceso.abrir(page.url.pathname + page.url.search);
menuAbierto = false;
}}
>
Entrar
</a>
{/if}
<BotonTema />

@ -152,6 +152,20 @@
.lamina {
grid-area: 1 / 1;
/*
* Cada lámina tiene que ser su propio contexto de apilamiento SIEMPRE.
*
* El fondo a sangre de cada una va con `z-index: -1`, para quedar detrás
* de su texto. Con `opacity: 1` la lámina no crea contexto, así que ese
* fondo se escapaba hacia atrás y se pintaba detrás del carrusel entero
* —incluidos los fondos de las otras láminas, que sí tenían el suyo por
* estar a media opacidad—. El resultado: al pasar de una a otra
* aparecía la foto de la lámina de al lado por encima, y el fondo de la
* que entraba daba un salto hacia atrás justo al terminar el fundido.
* Parecía que el shader se desincronizaba; lo que se movía era el orden
* de pintado.
*/
isolation: isolate;
opacity: 0;
visibility: hidden;
transition:

@ -0,0 +1,310 @@
<script lang="ts">
import { untrack } from 'svelte';
import { applyAction, enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import { resolve } from '$app/paths';
type Estado = {
paso: 'email' | 'codigo';
email: string;
error?: string;
};
let {
/** Estado inicial: en la página viene del servidor; en el modal, vacío. */
inicial = { paso: 'email', email: '' } as Estado,
/** A dónde ir cuando la sesión se abra. */
volverA = '/cuenta',
/**
* En el modal no hay recarga: la respuesta se aplica aquí y se avisa. En
* la página, `verificar` responde con una redirección y no llega a
* llamarse.
*/
alEntrar = undefined as (() => void) | undefined,
/** El campo del correo se enfoca solo cuando el formulario aparece. */
enfocar = false
} = $props();
/*
* A partir de aquí el estado es de este componente y solo cambia con lo que
* responda el servidor. `untrack` lo dice explícitamente: de `inicial` se
* toma el valor de partida y nada más, que es justo lo que se quiere.
*/
let estado = $state<Estado>(untrack(() => ({ ...inicial })));
let enviando = $state(false);
let campoEmail = $state<HTMLInputElement | null>(null);
let campoCodigo = $state<HTMLInputElement | null>(null);
/**
* En el marcado, `{` abre una expresión de Svelte, así que el patrón del
* campo se declara aquí en vez de pelearse con el escapado.
*/
const PATRON_CODIGO = '[0-9]{6}';
/*
* Las acciones son siempre las de /entrar, aunque el formulario se pinte
* dentro de un diálogo en cualquier otra página: la ruta es la dueña de la
* lógica y del limitador de envíos, y no hay motivo para duplicarla.
*/
const ACCION_SOLICITAR = `${resolve('/entrar')}?/solicitar`;
const ACCION_VERIFICAR = `${resolve('/entrar')}?/verificar`;
$effect(() => {
if (!enfocar) return;
// Al pasar al segundo paso, el foco salta al código: quien acaba de
// recibirlo lo pega y ya está.
const campo = estado.paso === 'codigo' ? campoCodigo : campoEmail;
campo?.focus();
});
/**
* Aplica la respuesta del servidor sin recargar la página.
*
* Se hace a mano en vez de con el `update()` de siempre porque este
* formulario puede estar en una página que no es la dueña de la acción: allí
* `form` no le llega, y el estado tiene que quedarse aquí.
*/
function alEnviar() {
enviando = true;
return async ({ result }: { result: import('@sveltejs/kit').ActionResult }) => {
enviando = false;
if (result.type === 'failure' || result.type === 'success') {
const datos = result.data as Estado | undefined;
if (datos?.paso) estado = { paso: datos.paso, email: datos.email, error: datos.error };
return;
}
if (result.type === 'redirect') {
// La sesión ya está abierta. Si hay quien quiera enterarse —el
// modal, para cerrarse—, se le avisa; si no, se navega como haría
// la página.
await invalidateAll();
if (alEntrar) alEntrar();
else await applyAction(result);
return;
}
await applyAction(result);
};
}
</script>
{#if estado.paso === 'email'}
<p class="entradilla">
Escribe tu correo y te enviamos un código de seis dígitos. No hace falta contraseña: si es la
primera vez, la cuenta se crea sola.
</p>
{#if estado.error}
<p class="error" role="alert">{estado.error}</p>
{/if}
<form method="POST" action={ACCION_SOLICITAR} use:enhance={alEnviar}>
<input type="hidden" name="volverA" value={volverA} />
<!--
Campo trampa: invisible y fuera del recorrido de tabulación, pero
presente en el HTML. Los envíos automáticos suelen rellenarlo, y este
formulario manda correo a terceros, así que conviene el freno.
-->
<div class="trampa" aria-hidden="true">
<label for="companiaWeb">No rellenes este campo</label>
<input id="companiaWeb" name="companiaWeb" type="text" tabindex="-1" autocomplete="off" />
</div>
<div class="campo">
<label for="email">Correo electrónico</label>
<input
bind:this={campoEmail}
id="email"
name="email"
type="email"
required
autocomplete="email"
placeholder="tu@correo.com"
value={estado.email}
aria-invalid={estado.error ? 'true' : undefined}
/>
</div>
<button class="btn btn--primary" type="submit" disabled={enviando}>
{enviando ? 'Enviando…' : 'Enviarme el código'}
</button>
</form>
<!--
En pestaña aparte, no en la misma.
Desde el diálogo, un enlace normal navega por debajo del modal: la página
cambia sin que se vea, y al cerrar apareces en otro sitio con el correo a
medio escribir perdido. Aquí el texto legal se consulta y se vuelve.
-->
<p class="letra-pequena muted">
Al entrar aceptas las <a
href={resolve('/legal/[slug]', { slug: 'condiciones-de-uso' })}
target="_blank"
rel="noopener">condiciones de uso</a
>
y la
<a href={resolve('/legal/[slug]', { slug: 'privacidad' })} target="_blank" rel="noopener"
>política de privacidad</a
>.
</p>
{:else}
<p class="entradilla">
Hemos enviado un código a <strong>{estado.email}</strong>. Caduca en quince minutos.
</p>
{#if estado.error}
<p class="error" role="alert">{estado.error}</p>
{/if}
<form method="POST" action={ACCION_VERIFICAR} use:enhance={alEnviar}>
<input type="hidden" name="email" value={estado.email} />
<input type="hidden" name="volverA" value={volverA} />
<div class="campo">
<label for="codigo">Código</label>
<input
bind:this={campoCodigo}
id="codigo"
name="codigo"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
pattern={PATRON_CODIGO}
maxlength="6"
required
class="codigo"
aria-invalid={estado.error ? 'true' : undefined}
/>
</div>
<button class="btn btn--primary" type="submit" disabled={enviando}>
{enviando ? 'Comprobando…' : 'Entrar'}
</button>
</form>
<div class="secundarias">
<form method="POST" action={ACCION_SOLICITAR} use:enhance={alEnviar}>
<input type="hidden" name="email" value={estado.email} />
<input type="hidden" name="volverA" value={volverA} />
<button type="submit" class="enlace">Enviar otro código</button>
</form>
<button
type="button"
class="enlace"
onclick={() => (estado = { paso: 'email', email: estado.email })}
>
Usar otro correo
</button>
</div>
{/if}
<style>
.entradilla {
color: var(--c-text-muted);
font-size: var(--fs-sm);
}
form {
display: grid;
gap: var(--sp-4);
justify-items: start;
}
.campo {
display: grid;
gap: var(--sp-1);
width: 100%;
}
label {
font-size: var(--fs-sm);
font-weight: 600;
}
input {
font: inherit;
color: inherit;
width: 100%;
padding: var(--sp-3);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
background: var(--c-bg);
}
input:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 1px;
border-color: transparent;
}
.codigo {
font-family: var(--font-mono);
font-size: var(--fs-xl);
letter-spacing: 0.3em;
text-align: center;
}
[aria-invalid='true'] {
border-color: var(--c-danger);
}
/* Oculta sin display:none, que algunos clientes automáticos detectan. */
.trampa {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.error {
padding: var(--sp-3);
border: 1px solid var(--c-danger);
border-radius: var(--radius-boton);
color: var(--c-danger);
font-size: var(--fs-sm);
}
/* El botón ocupa el ancho: es la única acción del formulario. */
form :global(button[type='submit']) {
width: 100%;
justify-content: center;
}
.secundarias {
display: flex;
flex-wrap: wrap;
gap: var(--sp-4);
margin-top: var(--sp-4);
}
.secundarias form {
display: contents;
}
.enlace {
border: none;
background: none;
padding: 0;
font: inherit;
font-size: var(--fs-sm);
color: var(--c-text-muted);
text-decoration: underline;
cursor: pointer;
}
.enlace:hover {
color: var(--c-accent);
}
.letra-pequena {
margin: var(--sp-5) 0 0;
font-size: var(--fs-xs);
line-height: 1.5;
}
</style>

@ -0,0 +1,292 @@
<script lang="ts">
/**
* El acceso, en un diálogo de dos mitades: la imagen de marca a la izquierda
* y el formulario a la derecha.
*
* Se monta una sola vez, en el layout, y lo abre quien haga falta a través
* de `acceso`. Entrar no es cambiar de sitio: quien pulsa «Entrar» mientras
* mira un disco quiere seguir mirándolo, y mandarlo a otra página para
* escribir un correo le hace perder lo que estaba haciendo.
*
* `/entrar` sigue existiendo y funcionando por su cuenta —sin JavaScript, o
* desde un enlace directo—, con el mismo formulario y la misma estampa. Esto
* es la versión cómoda, no la única.
*
* Se usa `<dialog>` de verdad, no un div con `role="dialog"`: el navegador ya
* trae el fondo modal, el cierre con Escape, el foco atrapado dentro y el
* resto de la página marcada como inerte. Reescribir todo eso a mano es la
* forma habitual de acabar con un modal del que no se puede salir con el
* teclado.
*/
import { acceso } from '$lib/acceso.svelte';
import { heroe } from '$lib/site';
import FormularioAcceso from './FormularioAcceso.svelte';
import Icono from './Icono.svelte';
let dialogo = $state<HTMLDialogElement | null>(null);
/** La imagen de marca, la misma de la portada. */
const imagen = heroe.fondo.oscuro;
$effect(() => {
if (!dialogo) return;
if (acceso.abierto && !dialogo.open) {
dialogo.showModal();
/*
* Y se enfoca el campo a mano.
*
* `showModal()` enfoca por su cuenta lo primero que encuentre alcanzable
* dentro del diálogo, y lo primero es el campo trampa contra envíos
* automáticos: está fuera de la pantalla y fuera del recorrido de
* tabulación, pero se puede enfocar. El resultado era un diálogo que se
* abría con el foco en ninguna parte visible.
*/
dialogo.querySelector<HTMLInputElement>('input[name="email"], input[name="codigo"]')?.focus();
} else if (!acceso.abierto && dialogo.open) {
dialogo.close();
}
});
</script>
<!--
`onclose` cubre las salidas que no pasan por nuestro botón: Escape, y el
cierre del propio navegador. Sin esto el estado se quedaría en «abierto» y no
se podría volver a abrir.
-->
<dialog
bind:this={dialogo}
class="acceso"
aria-labelledby="titulo-acceso"
onclose={() => acceso.cerrar()}
onclick={(evento) => {
// Pulsar en el fondo cierra. El fondo es el propio <dialog>: su caja
// ocupa la pantalla entera y el contenido va en un hijo, así que un
// clic cuyo destino sea el diálogo mismo es un clic fuera.
if (evento.target === dialogo) acceso.cerrar();
}}
>
<div class="marco">
<!--
La mitad de la imagen. `aria-hidden` porque no cuenta nada que no esté
escrito al lado; para quien no la ve, el diálogo empieza en el título.
-->
<div class="ilustracion" aria-hidden="true">
<img
src={imagen.src}
srcset={imagen.srcset}
sizes="20rem"
width={imagen.ancho}
height={imagen.alto}
alt=""
loading="lazy"
/>
<div class="sobreimpreso">
<p class="marca">Senza Paura</p>
<p class="lema">
Una cuenta guarda lo que compras, lo que marcas como favorito y lo que has valorado. Sin
contraseñas que recordar.
</p>
</div>
</div>
<div class="panel">
<h2 id="titulo-acceso">Entrar o crear cuenta</h2>
{#if acceso.abierto}
<!--
Se monta y se desmonta con el diálogo: así el formulario vuelve al
primer paso cada vez que se abre, en vez de reaparecer con el
código de la vez anterior a medio escribir.
-->
<FormularioAcceso volverA={acceso.volverA} enfocar alEntrar={() => acceso.cerrar()} />
{/if}
<!--
Al final a propósito. `showModal()` enfoca el primer elemento
alcanzable del diálogo: con el botón de cerrar delante, el foco caía
ahí y había que tabular para llegar al campo. Puesto aquí, se entra
directamente en el correo y el cierre queda al terminar, que es
cuando hace falta.
-->
<button type="button" class="cerrar" onclick={() => acceso.cerrar()} aria-label="Cerrar">
<Icono nombre="cerrar" tamano={16} />
</button>
</div>
</div>
</dialog>
<style>
/*
* El diálogo ocupa toda la pantalla y hace de fondo; el contenido va en
* `.marco`, centrado. Así el clic fuera se detecta sin poner un div extra
* que tape el fondo nativo.
*/
.acceso {
width: 100%;
max-width: 100%;
height: 100%;
max-height: 100%;
margin: 0;
padding: var(--sp-4);
border: none;
background: transparent;
overflow: auto;
place-items: center;
}
/*
* El `display` va SOLO en el estado abierto.
*
* El navegador oculta un `<dialog>` cerrado con `display: none`, pero eso es
* hoja de usuario y cualquier regla nuestra le gana, por poca especificidad
* que tenga. Con `display: grid` suelto en `.acceso`, el diálogo cerrado se
* pintaba igualmente: novecientos píxeles transparentes al final de todas las
* páginas del sitio, invisibles pero con su scroll y con su encabezado
* contando en el esquema de la página.
*/
.acceso[open] {
display: grid;
}
.acceso::backdrop {
/* En variable: el minificador junta las funciones del filtro sin espacio. */
background: rgb(3 8 20 / 72%);
backdrop-filter: blur(4px);
}
.marco {
display: grid;
grid-template-columns: 20rem minmax(0, 24rem);
width: 100%;
max-width: 44rem;
overflow: hidden;
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
background: var(--c-bg-raised);
box-shadow: 0 24px 64px rgb(0 0 0 / 45%);
}
/* --- La mitad de la imagen ------------------------------------------- */
.ilustracion {
position: relative;
background: var(--c-bg-sunken);
}
/*
* Recortada por la izquierda: la imagen tiene las pinceladas en las esquinas
* y el centro casi liso, así que centrarla dejaba un rectángulo azul plano.
*/
.ilustracion img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: 12% center;
}
/*
* El velo carga abajo, que es donde va el texto, y deja arriba la textura a
* la vista: si se atenúa entera, la imagen deja de aportar y solo estorba.
*/
.sobreimpreso {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: var(--sp-2);
padding: var(--sp-5);
background: linear-gradient(
to top,
color-mix(in oklab, #030814 88%, transparent) 0%,
color-mix(in oklab, #030814 40%, transparent) 45%,
transparent 75%
);
}
.marca {
margin: 0;
font-family: var(--font-display);
font-size: var(--fs-xl);
background: var(--oro);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
.lema {
margin: 0;
font-size: var(--fs-sm);
line-height: 1.5;
/* Sobre la imagen el color va fijo: no lo cambia el tema. */
color: rgb(255 255 255 / 78%);
}
/* --- La mitad del formulario ----------------------------------------- */
.panel {
position: relative;
padding: var(--sp-6);
}
h2 {
margin: 0 0 var(--sp-3);
font-size: var(--fs-xl);
}
.cerrar {
position: absolute;
top: var(--sp-3);
right: var(--sp-3);
display: inline-flex;
align-items: center;
justify-content: center;
/* 2.25rem: lo mínimo para acertarle con el dedo. */
width: 2.25rem;
height: 2.25rem;
padding: 0;
border: none;
border-radius: var(--radius-boton);
background: transparent;
color: var(--c-text-muted);
cursor: pointer;
transition: color 0.2s var(--suave);
}
.cerrar:hover,
.cerrar:focus-visible {
color: var(--c-text);
}
/*
* En pantallas estrechas la imagen pasa a ser una franja arriba: dos
* columnas de diez centímetros no son dos columnas, son dos rendijas.
*/
@media (max-width: 44rem) {
.marco {
grid-template-columns: minmax(0, 1fr);
max-width: 26rem;
}
.ilustracion {
height: 9rem;
}
.sobreimpreso {
padding: var(--sp-4);
}
.lema {
display: none;
}
}
/* Con la pantalla baja —un móvil en horizontal— la franja sobra del todo. */
@media (max-height: 34rem) {
.ilustracion {
display: none;
}
}
</style>

@ -4,6 +4,7 @@
import Cabecera from '$lib/components/Cabecera.svelte';
import PieDePagina from '$lib/components/PieDePagina.svelte';
import BarraReproductor from '$lib/components/player/BarraReproductor.svelte';
import ModalAcceso from '$lib/components/ModalAcceso.svelte';
import { reproductor } from '$lib/player/player.svelte';
let { children } = $props();
@ -37,6 +38,12 @@
-->
<BarraReproductor />
<!--
Igual que la barra: uno solo para todo el sitio. Lo abre quien lo necesite a
través de `acceso`, sin pasarse propiedades de página en página.
-->
<ModalAcceso />
<style>
.sitio {
display: flex;

@ -1,32 +1,35 @@
<script lang="ts">
import { enhance } from '$app/forms';
/**
* La misma tarjeta de dos mitades que el diálogo, pero como página.
*
* Existe para lo que el diálogo no cubre: entrar sin JavaScript, un enlace
* directo, o volver al paso del código desde el correo. Comparte el
* formulario con el modal —no hay dos copias de la lógica— y comparte la
* estampa, para que no parezcan dos sitios distintos.
*/
import Meta from '$lib/components/Meta.svelte';
import FormularioAcceso from '$lib/components/FormularioAcceso.svelte';
import { heroe } from '$lib/site';
let { data, form } = $props();
let enviando = $state(false);
const imagen = heroe.fondo.oscuro;
/**
* En el marcado, `{` abre una expresión de Svelte, así que el patrón del
* campo se declara aquí en vez de pelearse con el escapado.
/*
* Sin JavaScript la respuesta del servidor llega en `form` y no por el
* camino del componente; con JavaScript, `use:enhance` la intercepta y esto
* no cambia. Se pasa lo que haya para que el primer pintado ya lleve el paso
* correcto en vez de empezar de cero.
*/
const PATRON_CODIGO = '[0-9]{6}';
const inicial = $derived({
paso: form?.paso ?? data.paso,
email: form?.email ?? data.email,
error: form && 'error' in form ? form.error : undefined
});
/** El formulario tiene dos pasos: pedir el código y canjearlo. */
const paso = $derived(form?.paso ?? data.paso);
const email = $derived(form?.email ?? data.email);
const volverA = $derived(
form && 'volverA' in form ? (form.volverA ?? data.volverA) : data.volverA
);
const error = $derived(form && 'error' in form ? form.error : undefined);
function alEnviar() {
enviando = true;
return async ({ update }: { update: () => Promise<void> }) => {
await update();
enviando = false;
};
}
</script>
<Meta
@ -35,169 +38,119 @@
sinIndexar
/>
<div class="container">
<div class="panel">
<h1>Entrar</h1>
{#if paso === 'email'}
<p class="entradilla">
Escribe tu correo y te enviamos un código de seis dígitos. No hace falta contraseña: si es
la primera vez, la cuenta se crea sola.
</p>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
<form method="POST" action="?/solicitar" use:enhance={alEnviar}>
<input type="hidden" name="volverA" value={volverA} />
<!--
Campo trampa: invisible y fuera del recorrido de tabulación, pero
presente en el HTML. Los envíos automáticos suelen rellenarlo, y
este formulario manda correo a terceros, así que conviene el freno.
-->
<div class="trampa" aria-hidden="true">
<label for="companiaWeb">No rellenes este campo</label>
<input id="companiaWeb" name="companiaWeb" type="text" tabindex="-1" autocomplete="off" />
</div>
<div class="campo">
<label for="email">Correo electrónico</label>
<input
id="email"
name="email"
type="email"
required
autocomplete="email"
value={email}
aria-invalid={error ? 'true' : undefined}
/>
</div>
<button class="btn btn--primary" type="submit" disabled={enviando}>
{enviando ? 'Enviando...' : 'Enviarme el código'}
</button>
</form>
{:else}
<p class="entradilla">
Hemos enviado un código a <strong>{email}</strong>. Caduca en quince minutos.
</p>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
<form method="POST" action="?/verificar" use:enhance={alEnviar}>
<input type="hidden" name="email" value={email} />
<input type="hidden" name="volverA" value={volverA} />
<div class="campo">
<label for="codigo">Código</label>
<input
id="codigo"
name="codigo"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
pattern={PATRON_CODIGO}
maxlength="6"
required
class="codigo"
aria-invalid={error ? 'true' : undefined}
/>
</div>
<button class="btn btn--primary" type="submit" disabled={enviando}>
{enviando ? 'Comprobando...' : 'Entrar'}
</button>
</form>
<form method="POST" action="?/solicitar" class="reenviar" use:enhance={alEnviar}>
<input type="hidden" name="email" value={email} />
<input type="hidden" name="volverA" value={volverA} />
<button type="submit" class="enlace">Enviar otro código</button>
</form>
{/if}
<div class="container acceso">
<div class="marco">
<div class="ilustracion" aria-hidden="true">
<img
src={imagen.src}
srcset={imagen.srcset}
sizes="22rem"
width={imagen.ancho}
height={imagen.alto}
alt=""
/>
<div class="sobreimpreso">
<p class="marca">Senza Paura</p>
<p class="lema">
Una cuenta guarda lo que compras, lo que marcas como favorito y lo que has valorado. Sin
contraseñas que recordar.
</p>
</div>
</div>
<div class="panel">
<h1>Entrar o crear cuenta</h1>
{#key inicial}
<FormularioAcceso {inicial} {volverA} />
{/key}
</div>
</div>
</div>
<style>
.panel {
max-width: 26rem;
padding-block: var(--sp-7) var(--sp-8);
}
.entradilla {
color: var(--c-text-muted);
}
form {
.acceso {
display: grid;
gap: var(--sp-4);
justify-items: start;
place-items: center;
padding-block: var(--sp-7) var(--sp-8);
}
.campo {
.marco {
display: grid;
gap: var(--sp-1);
grid-template-columns: 22rem minmax(0, 26rem);
width: 100%;
max-width: 48rem;
overflow: hidden;
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
background: var(--c-bg-raised);
}
label {
font-size: var(--fs-sm);
font-weight: 600;
.ilustracion {
position: relative;
background: var(--c-bg-sunken);
}
input {
font: inherit;
color: inherit;
.ilustracion img {
width: 100%;
padding: var(--sp-2) var(--sp-3);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
background: var(--c-bg);
height: 100%;
object-fit: cover;
}
.codigo {
font-family: var(--font-mono);
font-size: var(--fs-xl);
letter-spacing: 0.3em;
text-align: center;
.sobreimpreso {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: var(--sp-2);
padding: var(--sp-5);
background: linear-gradient(
to top,
color-mix(in oklab, #030814 88%, transparent) 0%,
color-mix(in oklab, #030814 40%, transparent) 45%,
transparent 75%
);
}
[aria-invalid='true'] {
border-color: var(--c-danger);
.marca {
margin: 0;
font-family: var(--font-display);
font-size: var(--fs-xl);
background: var(--oro);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
/* Oculta sin display:none, que algunos clientes automáticos detectan. */
.trampa {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
.lema {
margin: 0;
font-size: var(--fs-sm);
line-height: 1.5;
/* Sobre la imagen el color va fijo: no lo cambia el tema. */
color: rgb(255 255 255 / 78%);
}
.error {
padding: var(--sp-3);
border: 1px solid var(--c-danger);
border-radius: var(--radius-boton);
color: var(--c-danger);
font-size: var(--fs-sm);
.panel {
padding: var(--sp-6);
}
.reenviar {
margin-top: var(--sp-4);
h1 {
margin: 0 0 var(--sp-3);
font-size: var(--fs-xl);
}
.enlace {
border: none;
background: none;
padding: 0;
font-size: var(--fs-sm);
color: var(--c-text-muted);
text-decoration: underline;
cursor: pointer;
@media (max-width: 48rem) {
.marco {
grid-template-columns: minmax(0, 1fr);
max-width: 28rem;
}
.ilustracion {
height: 10rem;
}
.sobreimpreso {
padding: var(--sp-4);
}
}
</style>

Loading…
Cancel
Save

Powered by TurnKey Linux.