From 177df84817008373d36b7e288e2cdffbba21520a Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 19:03:30 +0200 Subject: [PATCH] feat: a deck building itself on the landing page, and no captions anywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things this site does, and the third had no picture. A talk built from the same evidence the bank is written from — a template, the figures dropped in, exported as PowerPoint — is hard to say in a sentence and easy to show, so this says nothing at all: a slide assembles itself, the deck beside it lights up as each one lands, and the eye works out what it is watching. The one link is navigation, not explanation. Three slides share a 21s loop, each a third of a turn behind the last by way of a negative delay, so there is no clock in JavaScript and nothing to unwind on unmount. Every part inside a slide rides the same loop and the same delay plus its own `--d`, and that is what staggers the build. Under calm motion the loop is not applied and the three lie side by side, finished. Also here, because it is the same argument: the study panel's caption is gone. The panel is a picture of a question marking itself, which is the whole of what the caption said, and a screen reader is told that by the figure's own label. And the hero no longer says "pediatric". The domain says whose site this is, more steps are coming, and a line naming one exam has to be rewritten the week the next one is added. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- frontend/src/pages/LandingPage.css | 237 ++++++++++++++++++++++++++++- frontend/src/pages/LandingPage.jsx | 139 +++++++++++++++-- 2 files changed, 363 insertions(+), 13 deletions(-) diff --git a/frontend/src/pages/LandingPage.css b/frontend/src/pages/LandingPage.css index 239e868..1e52b4e 100644 --- a/frontend/src/pages/LandingPage.css +++ b/frontend/src/pages/LandingPage.css @@ -361,9 +361,6 @@ gap: 28px; align-items: start; } .lp-mode { margin: 0; } -.lp-mode figcaption { margin-bottom: 14px; } -.lp-mode figcaption strong { display: block; font-size: 1.05rem; font-weight: 700; } -.lp-mode figcaption span { display: block; margin-top: 4px; font-size: .88rem; line-height: 1.5; color: var(--text-muted); } .lp-screen { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; @@ -542,3 +539,237 @@ @media (min-width: 1100px) { .lp-modes { grid-template-columns: repeat(3, minmax(0, 1fr)); } } + + +/* ── The deck studio ────────────────────────────────────────────────── + A slide building itself, and the deck beside it filling up. No prose: the + third thing this site does is make a talk out of the same evidence, and a + picture of that lands before a sentence about it would have been read. + + The colours here are a deliberate exception to the tokens-only rule at the + top of this file, in the same way the mimed question screens are: these are + pictures of slides, and a slide with no colour of its own is not a picture + of a slide. They are declared once, as local properties, and every one of + them is mixed out of --primary so the warm theme stays warm. + + Timing. One 21s loop shared by everything. Each slide is a third of a turn + behind the last, done with a negative delay (`--i * -14s`), so the three + take the stage in order with no clock in JavaScript. Every part inside a + slide carries the same delay plus its own `--d`, and that — not a separate + keyframe per part — is what staggers the build. */ +.lp-studio { + --sl-ink: #1e293b; + --sl-paper: #ffffff; + --sl-accent: var(--primary); + --sl-tint: color-mix(in srgb, var(--primary) 12%, #ffffff); + --sl-tint-2: color-mix(in srgb, var(--primary) 26%, #ffffff); + --sl-quiet: color-mix(in srgb, var(--primary) 16%, #94a3b8); + --sl-warm: color-mix(in srgb, var(--primary) 55%, #f0a04b); + display: grid; grid-template-columns: minmax(0, 1fr) 168px; gap: 22px; + align-items: stretch; max-width: 880px; margin: 0 auto; +} + +.lp-stage { margin: 0; min-width: 0; } + +/* 16:9, because that is the shape people recognise before they read + anything on it. */ +.lp-deck { + position: relative; aspect-ratio: 16 / 9; overflow: hidden; + border: 1px solid var(--border); border-radius: 14px; background: var(--sl-paper); + box-shadow: 0 20px 48px rgba(15, 23, 42, .16); +} + +.lp-slide { + position: absolute; inset: 0; padding: 7% 8%; + display: flex; flex-direction: column; gap: 2.4%; + background: var(--sl-paper); opacity: 0; +} +/* Calm motion, or no motion preference: the three sit side by side, finished. + Nothing is lost — a deck is a thing you look at, not a thing you watch. */ +.lp-studio.is-calm .lp-deck { aspect-ratio: auto; display: grid; gap: 10px; padding: 10px; } +.lp-studio.is-calm .lp-slide { position: static; opacity: 1; aspect-ratio: 16 / 9; border: 1px solid var(--border); border-radius: 8px; } + +/* Slide 1 — the opener. */ +.lp-slide.is-title { justify-content: center; padding-left: 11%; } +.lp-sl-wash { + position: absolute; inset: 0; opacity: 0; + background: + radial-gradient(120% 90% at 8% 110%, var(--sl-tint-2), transparent 62%), + radial-gradient(90% 80% at 100% 0%, color-mix(in srgb, var(--sl-warm) 34%, #ffffff), transparent 58%), + var(--sl-paper); +} +.lp-sl-mark { + position: absolute; top: 9%; right: 8%; width: 26px; height: 26px; border-radius: 50%; + background: var(--sl-accent); opacity: 0; + box-shadow: 0 0 0 6px color-mix(in srgb, var(--sl-accent) 16%, transparent); +} +.lp-sl-rule { width: 52px; height: 5px; border-radius: 3px; background: var(--sl-accent); opacity: 0; } +.lp-sl-h1 { width: 74%; height: 15%; border-radius: 6px; background: var(--sl-ink); opacity: 0; } +.lp-sl-h2 { width: 46%; height: 8%; border-radius: 5px; background: var(--sl-quiet); opacity: 0; } +.lp-sl-foot { position: absolute; left: 11%; bottom: 8%; width: 30%; height: 5px; border-radius: 3px; background: var(--sl-tint-2); opacity: 0; } + +/* Shared furniture on slides 2 and 3. */ +.lp-sl-eyebrow { width: 16%; height: 5px; border-radius: 3px; background: var(--sl-accent); opacity: 0; } +.lp-sl-title { width: 58%; height: 9%; border-radius: 5px; background: var(--sl-ink); opacity: 0; } +.lp-sl-cite { margin-top: auto; width: 34%; height: 4px; border-radius: 2px; background: var(--sl-tint-2); opacity: 0; } + +/* Slide 2 — the figure. */ +.lp-sl-cols { display: grid; grid-template-columns: 1fr 1.15fr; gap: 5%; flex: 1; min-height: 0; margin-top: 2%; } +.lp-sl-copy { display: flex; flex-direction: column; justify-content: center; gap: 11%; } +.lp-sl-copy span { display: block; height: 5px; border-radius: 3px; background: var(--sl-quiet); opacity: 0; } +.lp-sl-copy span:nth-child(1) { width: 100%; } +.lp-sl-copy span:nth-child(2) { width: 92%; } +.lp-sl-copy span:nth-child(3) { width: 96%; } +.lp-sl-copy span:nth-child(4) { width: 64%; } +.lp-sl-figure { + border-radius: 8px; overflow: hidden; opacity: 0; + background: linear-gradient(150deg, var(--sl-tint), color-mix(in srgb, var(--sl-warm) 20%, #ffffff)); +} +.lp-sl-figure svg { display: block; width: 100%; height: 100%; } +.lp-sl-band { fill: var(--sl-accent); opacity: .16; } +.lp-sl-curve { fill: none; stroke: var(--sl-accent); stroke-width: 3; stroke-linecap: round; + stroke-dasharray: 100; stroke-dashoffset: 100; } +.lp-sl-dot { fill: var(--sl-accent); opacity: 0; } + +/* Slide 3 — the numbers. */ +.lp-sl-data { display: grid; grid-template-columns: 1fr 86px; gap: 6%; align-items: end; flex: 1; min-height: 0; margin-top: 3%; } +.lp-sl-bars { list-style: none; margin: 0; padding: 0 0 6px; height: 100%; + display: flex; align-items: flex-end; gap: 7%; border-bottom: 2px solid var(--sl-tint-2); } +.lp-sl-bars li { + flex: 1; height: 0; border-radius: 5px 5px 0 0; + background: linear-gradient(180deg, var(--sl-accent), color-mix(in srgb, var(--sl-accent) 55%, #ffffff)); +} +.lp-sl-bars li:nth-child(4) { background: linear-gradient(180deg, var(--sl-warm), color-mix(in srgb, var(--sl-warm) 50%, #ffffff)); } +.lp-sl-ring { align-self: center; justify-self: end; width: 86px; opacity: 0; } +.lp-sl-ring svg { display: block; width: 100%; height: auto; transform: rotate(-90deg); } +.lp-sl-ring-track { fill: none; stroke: var(--sl-tint); stroke-width: 7; } +.lp-sl-ring-fill { fill: none; stroke: var(--sl-accent); stroke-width: 7; stroke-linecap: round; + stroke-dasharray: 100; stroke-dashoffset: 100; } + +/* A single pass of light once a slide is standing. It is the only part of + this that is purely decorative, and it is why the thing reads as finished + rather than as still loading. */ +.lp-sl-sheen { + position: absolute; inset: -40% -60%; pointer-events: none; opacity: 0; + background: linear-gradient(72deg, transparent 42%, rgba(255,255,255,.55) 50%, transparent 58%); +} + +/* The deck filling up beside the stage. */ +.lp-strip { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; } +.lp-thumb { + position: relative; aspect-ratio: 16 / 9; overflow: hidden; padding: 11% 10%; + display: flex; flex-direction: column; gap: 7%; + border: 1px solid var(--border); border-radius: 8px; background: var(--sl-paper); + box-shadow: 0 4px 12px rgba(15, 23, 42, .07); +} +.lp-th-wash { position: absolute; inset: 0; + background: radial-gradient(120% 90% at 10% 110%, var(--sl-tint-2), transparent 62%); } +.lp-th-bar { position: relative; height: 5px; border-radius: 3px; background: var(--sl-ink); } +.lp-th-bar.w70 { width: 70%; } .lp-th-bar.w52 { width: 52%; } .lp-th-bar.w44 { width: 44%; background: var(--sl-quiet); } +.lp-th-split { display: grid; grid-template-columns: 1fr 1fr; gap: 8%; flex: 1; } +.lp-th-lines { border-radius: 3px; + background: repeating-linear-gradient(180deg, var(--sl-quiet) 0 3px, transparent 3px 9px); } +.lp-th-fig { border-radius: 4px; background: linear-gradient(150deg, var(--sl-tint-2), var(--sl-tint)); } +.lp-th-cols { display: flex; align-items: flex-end; gap: 8%; flex: 1; } +.lp-th-cols i { flex: 1; height: var(--h); border-radius: 2px 2px 0 0; background: var(--sl-accent); opacity: .75; } +.lp-th-cols i:last-child { background: var(--sl-warm); } + +@media (prefers-reduced-motion: no-preference) { + .lp-studio:not(.is-calm) .lp-slide { + animation: lp-sl-stand 21s linear infinite; + animation-delay: calc(var(--i) * -14s); + } + /* Everything a slide is made of, on the one loop and the one delay, each + part late by its own --d. */ + .lp-studio:not(.is-calm) .lp-slide > *, + .lp-studio:not(.is-calm) .lp-sl-copy span { + animation: lp-sl-part 21s cubic-bezier(.2, .7, .3, 1) infinite; + animation-delay: calc(var(--i) * -14s + var(--d, 0s)); + } + .lp-studio:not(.is-calm) .lp-sl-wash { animation-name: lp-sl-wash-in; } + .lp-studio:not(.is-calm) .lp-sl-curve { + animation: lp-sl-draw 21s cubic-bezier(.4, 0, .2, 1) infinite; + animation-delay: calc(var(--i) * -14s + 1s); + } + .lp-studio:not(.is-calm) .lp-sl-dot { + animation: lp-sl-part 21s ease-out infinite; + animation-delay: calc(var(--i) * -14s + 2.1s); + } + .lp-studio:not(.is-calm) .lp-sl-ring-fill { + animation: lp-sl-draw 21s cubic-bezier(.4, 0, .2, 1) infinite; + animation-delay: calc(var(--i) * -14s + 1.2s); + } + .lp-studio:not(.is-calm) .lp-sl-bars li { + animation: lp-sl-grow 21s cubic-bezier(.2, .8, .25, 1) infinite; + animation-delay: calc(var(--i) * -14s + var(--d)); + } + .lp-studio:not(.is-calm) .lp-sl-sheen { + animation: lp-sl-sheen 21s linear infinite; + } + .lp-studio:not(.is-calm) .lp-thumb { + animation: lp-sl-land 21s ease-in-out infinite; + animation-delay: calc(var(--i) * -14s); + } +} + +/* A slide holds the stage for a third of the loop and leaves before the next + arrives, so two are never on it at once. */ +@keyframes lp-sl-stand { + 0% { opacity: 0; transform: translate3d(14px, 0, 0) scale(.985); } + 3% { opacity: 1; transform: none; } + 29% { opacity: 1; transform: none; } + 32%, 100% { opacity: 0; transform: translate3d(-14px, 0, 0) scale(.985); } +} +/* Parts clear out well before the slide does, so the next turn always starts + on an empty sheet rather than on the last one's leftovers. */ +@keyframes lp-sl-part { + 0% { opacity: 0; transform: translate3d(0, 9px, 0); } + 4% { opacity: 1; transform: none; } + 26% { opacity: 1; transform: none; } + 27%, 100% { opacity: 0; transform: none; } +} +@keyframes lp-sl-wash-in { + 0% { opacity: 0; } + 5%, 26% { opacity: 1; } + 27%, 100% { opacity: 0; } +} +@keyframes lp-sl-draw { + 0%, 3% { stroke-dashoffset: 100; } + 14%, 26% { stroke-dashoffset: 0; } + 27%, 100% { stroke-dashoffset: 100; } +} +@keyframes lp-sl-grow { + 0%, 3% { height: 0; } + 13%, 26% { height: var(--h); } + 27%, 100% { height: 0; } +} +@keyframes lp-sl-sheen { + 0%, 20% { opacity: 0; transform: translate3d(-70%, 0, 0); } + 23% { opacity: 1; } + 28% { opacity: 0; transform: translate3d(70%, 0, 0); } + 29%, 100% { opacity: 0; transform: translate3d(-70%, 0, 0); } +} +/* The thumbnail lifts while its slide is the one on the stage. */ +@keyframes lp-sl-land { + 0%, 2% { transform: none; border-color: var(--border); box-shadow: 0 4px 12px rgba(15, 23, 42, .07); } + 6%, 28% { transform: translate3d(-6px, 0, 0); + border-color: color-mix(in srgb, var(--primary) 60%, transparent); + box-shadow: 0 8px 22px color-mix(in srgb, var(--primary) 22%, transparent); } + 33%, 100% { transform: none; border-color: var(--border); box-shadow: 0 4px 12px rgba(15, 23, 42, .07); } +} + +@media (max-width: 720px) { + .lp-studio { grid-template-columns: minmax(0, 1fr); } + .lp-strip { flex-direction: row; } + .lp-thumb { flex: 1; } + /* Sideways on a narrow screen, so the lift has to be sideways too. */ + @keyframes lp-sl-land { + 0%, 2% { transform: none; border-color: var(--border); } + 6%, 28% { transform: translate3d(0, -5px, 0); + border-color: color-mix(in srgb, var(--primary) 60%, transparent); + box-shadow: 0 8px 22px color-mix(in srgb, var(--primary) 22%, transparent); } + 33%, 100% { transform: none; border-color: var(--border); } + } +} + +.lp-studio-cta { margin-top: 34px; text-align: center; } diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx index bfa6896..3c2a044 100644 --- a/frontend/src/pages/LandingPage.jsx +++ b/frontend/src/pages/LandingPage.jsx @@ -511,13 +511,11 @@ function ModeShowcase({ calm }) { const cls = `lp-modes${calm ? ' is-calm' : ''}` return (
-
-
- Study - Marked as you answer, with the reasoning underneath. -
- {/* aria-hidden throughout: this is a picture of the product, and a - screen reader should hear the caption, not a mimed question. */} +
+ {/* No caption. The panel is a picture of a study question marking + itself, which is the whole of what a caption would have said — and + a screen reader is told that by the figure's own label rather than + by a line of prose under a mimed screen. */}