From 5670ebbddab7bc078afeb3b88b5005bee913dfbb Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 19:57:06 +0200 Subject: [PATCH] feat: the deck studio is a deck being made, not three pictures of slides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three improvements and one real bug. The bug: `.lp-sl-wash` is absolutely positioned and ends in an opaque white, so it painted *over* its static siblings — the title slide laid its rule, heading and picture out correctly and then covered them with its own background. An explicit z-index on the wash and its siblings fixes it. The improvements: the slide now sits inside editor chrome, with an export control that lights up as each slide lands, because three slides in a row are three pictures and the same three inside a window are a deck being made; a pointer moves across the canvas, places two things and presses export, which is what makes it read as being *built*; and the artwork is worth looking at — a figure on the title slide, a line chart with gridlines, a shaded band and plotted points, a two-segment ring and a legend. Each slide carries its own accent, so the three do not look like one slide shown three times. The warm accent was half theme and half amber, which against a blue primary is the colour of a wet pavement. It is mostly amber now. Also: "Pediatric clinical tools" is "Clinical tools", for the same reason the hero no longer says pediatric. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- frontend/src/pages/LandingPage.css | 205 ++++++++++++++++++++++------- frontend/src/pages/LandingPage.jsx | 129 +++++++++++------- 2 files changed, 236 insertions(+), 98 deletions(-) diff --git a/frontend/src/pages/LandingPage.css b/frontend/src/pages/LandingPage.css index 1e52b4e..6f9abe0 100644 --- a/frontend/src/pages/LandingPage.css +++ b/frontend/src/pages/LandingPage.css @@ -542,21 +542,22 @@ /* ── 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. + A slide building itself inside an editor, 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 is 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. + of a slide. They are declared once, as local properties, and mixed out of + --primary so the warm theme stays warm — with one accent per slide, so three + slides in a row do not look like one slide shown three times. 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. */ + 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 keyframe per + part — is what staggers the build. */ .lp-studio { --sl-ink: #1e293b; --sl-paper: #ffffff; @@ -564,75 +565,132 @@ --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); + /* A little of the theme, mostly the hue itself. Half and half turned amber + into mud against a blue primary, which is how the highlight bar came out + the colour of a wet pavement. */ + --sl-warm: color-mix(in srgb, var(--primary) 12%, #f0a53d); + --sl-cool: color-mix(in srgb, var(--primary) 35%, #14b8a6); 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. */ +/* The editor around the slide. Three slides in a row are three pictures; the + same three inside a window are a deck being made. */ .lp-deck { - position: relative; aspect-ratio: 16 / 9; overflow: hidden; + position: relative; 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-chrome { + display: flex; align-items: center; gap: 12px; + padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--bg); +} +.lp-chrome-dots, .lp-chrome-tools { display: inline-flex; gap: 5px; } +.lp-chrome-dots i { width: 8px; height: 8px; border-radius: 50%; background: var(--border); } +.lp-chrome-tools i { width: 22px; height: 7px; border-radius: 3px; background: var(--sl-tint-2); } +.lp-chrome-tools i:nth-child(2) { width: 14px; } +.lp-chrome-tools i:nth-child(3) { width: 30px; } +/* The export control, which flashes as each slide lands: a downward arrow, the + one shape that means "out of here and onto your disk" without a word. */ +.lp-chrome-export { + position: relative; margin-left: auto; width: 20px; height: 20px; + border-radius: 6px; background: color-mix(in srgb, var(--primary) 12%, transparent); +} +.lp-chrome-export::before { + content: ''; position: absolute; left: 9px; top: 4px; + width: 2px; height: 9px; border-radius: 1px; background: var(--sl-accent); +} +.lp-chrome-export::after { + content: ''; position: absolute; left: 6px; top: 8px; + width: 7px; height: 7px; transform: rotate(45deg); + border-right: 2px solid var(--sl-accent); border-bottom: 2px solid var(--sl-accent); +} + +/* 16:9, because that is the shape people recognise before they read anything. */ +.lp-canvas { position: relative; aspect-ratio: 16 / 9; overflow: hidden; } .lp-slide { - position: absolute; inset: 0; padding: 7% 8%; - display: flex; flex-direction: column; gap: 2.4%; + position: absolute; inset: 0; padding: 6% 7%; + display: flex; flex-direction: column; gap: 2.2%; 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; } +/* One accent each, so the deck reads as three different slides. */ +.lp-slide.is-figure { --sl-accent: var(--sl-cool); } +.lp-slide.is-data { --sl-accent: var(--primary); } + +/* Calm motion: the three lie 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-canvas { 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; } +.lp-studio.is-calm .lp-sl-cursor, .lp-studio.is-calm .lp-sl-sheen { display: none; } /* Slide 1 — the opener. */ -.lp-slide.is-title { justify-content: center; padding-left: 11%; } +.lp-slide.is-title { display: grid; grid-template-columns: 1.5fr 1fr; align-items: center; gap: 6%; } +/* A fixed gap: the block is only as tall as its three bars, so a percentage + gap resolves to three pixels and the title crowds its own rule. */ +.lp-sl-titleblock { display: flex; flex-direction: column; gap: 11px; } +/* Behind the slide's content, explicitly. An absolutely positioned element + paints above its static siblings, and this one ends in an opaque white — so + the title, the rule and the picture were being laid out correctly and then + covered by their own background. */ +.lp-slide > * { position: relative; z-index: 1; } .lp-sl-wash { - position: absolute; inset: 0; opacity: 0; + position: absolute; inset: 0; opacity: 0; z-index: 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%), + radial-gradient(120% 90% at 6% 110%, var(--sl-tint-2), transparent 60%), + radial-gradient(90% 80% at 100% 0%, color-mix(in srgb, var(--sl-warm) 30%, #ffffff), transparent 55%), var(--sl-paper); } .lp-sl-mark { - position: absolute; top: 9%; right: 8%; width: 26px; height: 26px; border-radius: 50%; + position: absolute; top: 7%; right: 6%; width: 22px; height: 22px; border-radius: 50%; background: var(--sl-accent); opacity: 0; - box-shadow: 0 0 0 6px color-mix(in srgb, var(--sl-accent) 16%, transparent); + box-shadow: 0 0 0 5px 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; } +.lp-sl-rule { width: 46px; height: 5px; border-radius: 3px; background: var(--sl-accent); opacity: 0; } +.lp-sl-h1 { width: 92%; height: 17px; border-radius: 5px; background: var(--sl-ink); opacity: 0; } +.lp-sl-h2 { width: 58%; height: 9px; border-radius: 5px; background: var(--sl-quiet); opacity: 0; } +.lp-sl-foot { position: absolute; left: 7%; bottom: 7%; width: 26%; height: 5px; border-radius: 3px; background: var(--sl-tint-2); opacity: 0; } +/* The picture on a title slide. Abstract on purpose — a figure, not a diagram + of anything in particular. */ +.lp-sl-hero { + align-self: stretch; margin: 6% 0; border-radius: 10px; overflow: hidden; opacity: 0; + background: linear-gradient(150deg, var(--sl-tint), color-mix(in srgb, var(--sl-warm) 22%, #ffffff)); +} +.lp-sl-hero svg { display: block; width: 100%; height: 100%; } +.lp-hero-a { fill: var(--sl-accent); opacity: .28; } +.lp-hero-b { fill: var(--sl-accent); opacity: .18; } /* 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; } +.lp-sl-eyebrow { width: 14%; height: 5px; border-radius: 3px; background: var(--sl-accent); opacity: 0; } +.lp-sl-title { width: 54%; height: 11px; border-radius: 5px; background: var(--sl-ink); opacity: 0; } +.lp-sl-cite { margin-top: auto; width: 30%; 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-cols { display: grid; grid-template-columns: 1fr 1.2fr; gap: 5%; flex: 1; min-height: 0; margin-top: 2%; } +.lp-sl-copy { display: flex; flex-direction: column; justify-content: center; gap: 10%; } .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(2) { width: 90%; } .lp-sl-copy span:nth-child(3) { width: 96%; } -.lp-sl-copy span:nth-child(4) { width: 64%; } +.lp-sl-copy span:nth-child(4) { width: 62%; } +/* The line an author adds last, in the accent: the point of the slide. */ +.lp-sl-copy .lp-sl-bullet { width: 76%; height: 6px; background: var(--sl-accent); opacity: 0; } .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)); + background: linear-gradient(150deg, var(--sl-tint), color-mix(in srgb, var(--sl-cool) 14%, #ffffff)); } .lp-sl-figure svg { display: block; width: 100%; height: 100%; } -.lp-sl-band { fill: var(--sl-accent); opacity: .16; } +.lp-sl-grid line { stroke: var(--sl-accent); stroke-width: .6; opacity: .18; } +.lp-sl-band { fill: var(--sl-accent); opacity: .14; } .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; } +.lp-sl-dot.is-last { fill: var(--sl-warm); } /* 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-data { display: grid; grid-template-columns: 1fr 80px; 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 { @@ -640,15 +698,30 @@ 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 { align-self: center; justify-self: end; width: 80px; 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 second segment, so the ring is a proportion of something rather than a + progress bar bent into a circle. */ +.lp-sl-ring-second { fill: none; stroke: var(--sl-warm); stroke-width: 7; stroke-linecap: round; + stroke-dasharray: 18 82; stroke-dashoffset: -62; opacity: 0; } +.lp-sl-legend { list-style: none; margin: 3% 0 0; padding: 0; display: flex; gap: 10px; } +.lp-sl-legend li { width: 34px; height: 5px; border-radius: 3px; background: var(--sl-tint-2); opacity: 0; } +.lp-sl-legend li:nth-child(1) { background: var(--sl-accent); } +.lp-sl-legend li:nth-child(3) { background: var(--sl-warm); } -/* 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. */ +/* The pointer that puts things where they go. It is what makes the panel read + as a deck being *made* rather than three slides being shown. */ +.lp-sl-cursor { + position: absolute; top: 0; left: 0; width: 11px; height: 11px; border-radius: 50%; + background: var(--sl-ink); opacity: 0; pointer-events: none; + box-shadow: 0 0 0 4px color-mix(in srgb, var(--sl-ink) 14%, transparent); +} + +/* A single pass of light once a slide is standing. The only purely decorative + part, and the reason the thing reads as finished rather than 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%); @@ -669,7 +742,7 @@ .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-fig { border-radius: 4px; background: linear-gradient(150deg, var(--sl-cool), 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); } @@ -682,10 +755,19 @@ /* 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 { + .lp-studio:not(.is-calm) .lp-sl-titleblock > *, + .lp-studio:not(.is-calm) .lp-sl-copy span, + .lp-studio:not(.is-calm) .lp-sl-legend li { animation: lp-sl-part 21s cubic-bezier(.2, .7, .3, 1) infinite; animation-delay: calc(var(--i) * -14s + var(--d, 0s)); } + /* The two wrappers are layout, not content: they must not fade as a block or + the parts inside them build twice over. */ + .lp-studio:not(.is-calm) .lp-sl-titleblock, + .lp-studio:not(.is-calm) .lp-sl-copy, + .lp-studio:not(.is-calm) .lp-sl-cols, + .lp-studio:not(.is-calm) .lp-sl-data, + .lp-studio:not(.is-calm) .lp-sl-bars { animation: none; opacity: 1; } .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; @@ -693,19 +775,23 @@ } .lp-studio:not(.is-calm) .lp-sl-dot { animation: lp-sl-part 21s ease-out infinite; - animation-delay: calc(var(--i) * -14s + 2.1s); + animation-delay: calc(var(--i) * -14s + var(--d, 1.9s)); } .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); + animation-delay: calc(var(--i) * -14s + 1.1s); + } + .lp-studio:not(.is-calm) .lp-sl-ring-second { + animation: lp-sl-part 21s ease-out infinite; + animation-delay: calc(var(--i) * -14s + 1.8s); } .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-sl-sheen { animation: lp-sl-sheen 21s linear infinite; } + .lp-studio:not(.is-calm) .lp-sl-cursor { animation: lp-sl-point 7s cubic-bezier(.5, 0, .3, 1) infinite; } + .lp-studio:not(.is-calm) .lp-chrome-export { animation: lp-sl-export 7s ease-out infinite; } .lp-studio:not(.is-calm) .lp-thumb { animation: lp-sl-land 21s ease-in-out infinite; animation-delay: calc(var(--i) * -14s); @@ -749,6 +835,27 @@ 28% { opacity: 0; transform: translate3d(70%, 0, 0); } 29%, 100% { opacity: 0; transform: translate3d(-70%, 0, 0); } } +/* The pointer runs on the per-slide beat rather than the whole loop: it places + two things, presses the export, and gets out of the way. */ +@keyframes lp-sl-point { + 0% { opacity: 0; transform: translate3d(18%, 74%, 0) scale(1); } + 6% { opacity: .85; transform: translate3d(18%, 62%, 0) scale(1); } + 14% { opacity: .85; transform: translate3d(24%, 34%, 0) scale(.8); } + 18% { opacity: .85; transform: translate3d(24%, 34%, 0) scale(1); } + 30% { opacity: .85; transform: translate3d(66%, 52%, 0) scale(1); } + 34% { opacity: .85; transform: translate3d(66%, 52%, 0) scale(.8); } + 38% { opacity: .85; transform: translate3d(66%, 52%, 0) scale(1); } + 58% { opacity: .85; transform: translate3d(95%, 4%, 0) scale(1); } + 62% { opacity: .85; transform: translate3d(95%, 4%, 0) scale(.75); } + 70% { opacity: 0; transform: translate3d(95%, 4%, 0) scale(1); } + 100% { opacity: 0; transform: translate3d(18%, 74%, 0) scale(1); } +} +/* And the export lights up as the pointer reaches it. */ +@keyframes lp-sl-export { + 0%, 60% { background: color-mix(in srgb, var(--primary) 12%, transparent); transform: none; } + 64% { background: color-mix(in srgb, var(--primary) 34%, transparent); transform: scale(1.12); } + 74%, 100% { background: color-mix(in srgb, var(--primary) 12%, transparent); transform: none; } +} /* 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); } diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx index 3c2a044..4eefddc 100644 --- a/frontend/src/pages/LandingPage.jsx +++ b/frontend/src/pages/LandingPage.jsx @@ -621,66 +621,97 @@ function ModeShowcase({ calm }) { function SlideStudio({ calm }) { return (
- {/* aria-hidden: this is a picture of a deck, not a deck. A screen reader - is told what it is by the figure's label and skips the mime. */} -
+ {/* aria-hidden throughout: this is a picture of a deck being made, not a + deck. A screen reader is told what it is by the figure's label and + skips the mime. */} +
@@ -800,7 +831,7 @@ export default function LandingPage() {
Also from PedsHub -

Pediatric clinical tools

+

Clinical tools

The same material, pointed at the ward instead of the exam: well visit planning from newborn to adolescence, developmental milestones, the full