From 4df81d9e8663af89bbb952e5818d8184c26cc780 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 21 Jul 2026 17:36:04 -0700 Subject: [PATCH] Question page: stack deal, ambient hover, lightbox Paul's motion and exploration pass. The reveal is now a real deal: the cards begin piled at the grid center, blurred and slightly rotated, and travel to their seats with a 110ms stagger on the brand ease (JS measures each card's seat, so the pile works at any grid shape; reduced motion skips it). Hovering a card bleeds its hero into the page ground behind a lacquer scrim. An expand chip beside Board opens whichever face is showing in a zoom-out lightbox with Escape to close. Co-Authored-By: Claude Fable 5 --- skill/scripts/serve-question.mjs | 83 ++++++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 9 deletions(-) diff --git a/skill/scripts/serve-question.mjs b/skill/scripts/serve-question.mjs index 345544b32..7f2650aec 100644 --- a/skill/scripts/serve-question.mjs +++ b/skill/scripts/serve-question.mjs @@ -170,7 +170,8 @@ const options = payload.options.map((option) => ({ const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); function page() { - const flipChip = (label) => ``; + const flipChip = (label) => ``; + const expandChip = ``; const cards = options.map((option, index) => `
@@ -178,7 +179,7 @@ function page() { ${option.kicker ? `${esc(option.kicker)}` : ''}
${option.heroSrc ? `` : '
'} - ${option.boardSrc ? flipChip('Board') : ''} +
${option.heroSrc ? expandChip : ''}${option.boardSrc ? flipChip('Board') : ''}
${option.lineage ? `

${esc(option.lineage)}

` : ''} @@ -190,7 +191,7 @@ function page() { ${option.boardSrc ? `
- ${flipChip('Hero')} +
${expandChip}${flipChip('Hero')}

Design-system board · ${esc(option.label)}

@@ -230,6 +231,12 @@ function page() { } * { box-sizing: border-box; margin: 0; } body { background: var(--ks-lacquer); color: var(--ks-text); font: 15px/1.55 var(--ks-font); padding: 1.8rem clamp(1rem, 5vw, 4rem) 2rem; min-height: 100dvh; display: flex; flex-direction: column; } + #ambient { position: fixed; inset: -40px; z-index: 0; background-size: cover; background-position: center; filter: blur(34px) saturate(1.05); opacity: 0; transition: opacity .55s ease, background-image .2s; pointer-events: none; } + #scrim { position: fixed; inset: 0; z-index: 0; background: linear-gradient(180deg, oklch(7% 0.006 95 / 0.82), oklch(7% 0.006 95 / 0.9)); pointer-events: none; } + header, main, footer { position: relative; z-index: 1; } + #lightbox { position: fixed; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center; background: oklch(4% 0.004 95 / 0.93); cursor: zoom-out; opacity: 0; transition: opacity .25s ease; } + #lightbox.open { opacity: 1; } + #lightbox img { max-width: 94vw; max-height: 94vh; border: 1px solid var(--ks-rule); border-radius: 8px; box-shadow: 0 30px 80px oklch(0% 0 0 / 0.6); } header { width: 100%; max-width: 90rem; margin: 0 auto; } .brand { display: flex; align-items: center; gap: .55rem; color: var(--ks-kinpaku); } .brand svg { width: 22px; height: 22px; } @@ -241,7 +248,7 @@ function page() { main { flex: 1; display: flex; align-items: center; width: 100%; max-width: 90rem; margin: 0 auto; } .stage { width: 100%; display: flex; flex-direction: column; gap: 1.5rem; } .grid { display: grid; gap: 1.6rem; grid-template-columns: repeat(auto-fit, minmax(min(23rem, 100%), 1fr)); width: 100%; } - .card { position: relative; perspective: 1400px; transform: rotate(var(--fan, 0deg)); opacity: 0; animation: deal .5s cubic-bezier(.16, 1, .3, 1) forwards; animation-delay: var(--deal, 0ms); transition: transform .25s cubic-bezier(.16, 1, .3, 1); } + .card { position: relative; perspective: 1400px; transform: rotate(var(--fan, 0deg)); transition: transform .25s cubic-bezier(.16, 1, .3, 1); } .card:hover { transform: rotate(0deg) translateY(-4px); } .card-inner { position: relative; height: 100%; transform-style: preserve-3d; transition: transform .7s cubic-bezier(.16, 1, .3, 1); } .card.flipped .card-inner { transform: rotateY(180deg); } @@ -251,8 +258,7 @@ function page() { .face.lead { border-color: var(--ks-kinpaku); box-shadow: 0 0 0 1px var(--ks-kinpaku), 0 18px 40px oklch(0% 0 0 / 0.45); } .card:hover .face { border-color: var(--ks-kinpaku-deep); } .card:hover .face.lead { border-color: var(--ks-kinpaku); } - @keyframes deal { from { opacity: 0; transform: translateY(26px) rotate(calc(var(--fan, 0deg) + 2deg)); } to { opacity: 1; transform: translateY(0) rotate(var(--fan, 0deg)); } } - @media (prefers-reduced-motion: reduce) { .card { animation: none; opacity: 1; } .card-inner { transition: none; } } + @media (prefers-reduced-motion: reduce) { .card-inner { transition: none; } } .kicker { position: absolute; z-index: 2; top: 12px; left: 12px; padding: 4px 10px; background: var(--ks-kinpaku); color: var(--ks-dark-ink); font-family: var(--ks-mono); font-size: .625rem; letter-spacing: .24em; text-transform: uppercase; border-radius: 4px; } .media { position: relative; width: 100%; aspect-ratio: 16/9; flex: none; } .media img { width: 100%; height: 100%; object-fit: cover; display: block; background: linear-gradient(100deg, var(--ks-graphite) 40%, var(--ks-graphite-2) 50%, var(--ks-graphite) 60%); } @@ -260,9 +266,10 @@ function page() { .back-bar { margin-top: auto; background: var(--ks-lacquer-raised); } .hero-blank { width: 100%; height: 100%; background: linear-gradient(100deg, var(--ks-graphite) 40%, var(--ks-graphite-2) 50%, var(--ks-graphite) 60%); } .back-bar { flex: none; flex-direction: row; align-items: center; justify-content: space-between; gap: .8rem; } - .flip { position: absolute; z-index: 1; right: 10px; bottom: 10px; display: inline-flex; align-items: center; gap: 6px; padding: 4px 9px; font-family: var(--ks-mono); font-size: .625rem; letter-spacing: .18em; text-transform: uppercase; color: var(--ks-text); background: oklch(7% 0.006 95 / 0.72); border: 1px solid var(--ks-rule); border-radius: 5px; cursor: pointer; backdrop-filter: blur(4px); transition: color .2s, border-color .2s; } - .flip:hover { color: var(--ks-kinpaku); border-color: var(--ks-kinpaku-deep); } - .flip svg { width: 12px; height: 12px; } + .chips { position: absolute; z-index: 1; right: 10px; bottom: 10px; display: flex; gap: 6px; } + .chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 9px; font-family: var(--ks-mono); font-size: .625rem; letter-spacing: .18em; text-transform: uppercase; color: var(--ks-text); background: oklch(7% 0.006 95 / 0.72); border: 1px solid var(--ks-rule); border-radius: 5px; cursor: pointer; backdrop-filter: blur(4px); transition: color .2s, border-color .2s; } + .chip:hover { color: var(--ks-kinpaku); border-color: var(--ks-kinpaku-deep); } + .chip svg { width: 12px; height: 12px; } .body { padding: .95rem 1.1rem 1.2rem; display: flex; flex-direction: column; gap: .5rem; flex: 1; } .tier { font-family: var(--ks-mono); font-size: .625rem; letter-spacing: .24em; text-transform: uppercase; color: var(--ks-text-faint); } h2 { font-family: var(--ks-font); font-size: 1.125rem; font-weight: 500; line-height: 1.35; color: var(--ks-champagne); } @@ -277,6 +284,9 @@ function page() { #reroll svg { width: 15px; height: 15px; } .done { display: flex; flex-direction: column; align-items: center; gap: 1rem; padding: 7rem 1rem; font-family: var(--ks-font-display); font-size: 1.4rem; color: var(--ks-champagne); text-align: center; } + + +
@@ -308,6 +318,61 @@ function page() { e.stopPropagation(); b.closest('.card').classList.toggle('flipped'); })); + + // Deal from the stack: cards begin piled at the grid's center, blurred, + // then travel to their seats with a stagger. + const cards = [...document.querySelectorAll('.card')]; + if (!matchMedia('(prefers-reduced-motion: reduce)').matches && cards.length) { + const grid = document.querySelector('.grid').getBoundingClientRect(); + const cx = grid.left + grid.width / 2, cy = grid.top + grid.height / 2; + cards.forEach((card, i) => { + const r = card.getBoundingClientRect(); + const dx = cx - (r.left + r.width / 2), dy = cy - (r.top + r.height / 2); + card.style.transition = 'none'; + card.style.transform = 'translate(' + dx + 'px,' + (dy + 14) + 'px) rotate(' + (i % 2 ? 5 : -4) + 'deg) scale(.9)'; + card.style.opacity = '0'; + card.style.filter = 'blur(10px)'; + card.style.zIndex = String(cards.length - i); + }); + requestAnimationFrame(() => requestAnimationFrame(() => { + cards.forEach((card, i) => { + const delay = i * 110; + card.style.transition = 'transform .7s cubic-bezier(.16,1,.3,1) ' + delay + 'ms, opacity .45s ease ' + delay + 'ms, filter .55s ease ' + delay + 'ms'; + card.style.transform = ''; card.style.opacity = '1'; card.style.filter = ''; + card.addEventListener('transitionend', function done(e) { + if (e.propertyName !== 'transform') return; + card.style.transition = ''; card.style.opacity = ''; card.style.zIndex = ''; + card.removeEventListener('transitionend', done); + }); + }); + })); + } + + // Ambient: the hovered card's hero bleeds into the page ground under a scrim. + const ambient = document.getElementById('ambient'); + document.querySelectorAll('.card').forEach(card => { + const hero = card.querySelector('.face.front .media img'); + if (!hero) return; + card.addEventListener('mouseenter', () => { ambient.style.backgroundImage = 'url("' + hero.getAttribute('src') + '")'; ambient.style.opacity = '0.45'; }); + card.addEventListener('mouseleave', () => { ambient.style.opacity = '0'; }); + }); + + // Expand: lightbox for whichever face is showing. + const lightbox = document.getElementById('lightbox'); + const lightboxImg = lightbox.querySelector('img'); + document.querySelectorAll('.expand').forEach(b => b.addEventListener('click', (e) => { + e.stopPropagation(); + const card = b.closest('.card'); + const face = card.classList.contains('flipped') ? '.face.back' : '.face.front'; + const img = card.querySelector(face + ' .media img'); + if (!img) return; + lightboxImg.src = img.getAttribute('src'); + lightbox.hidden = false; + requestAnimationFrame(() => lightbox.classList.add('open')); + })); + const closeLightbox = () => { lightbox.classList.remove('open'); setTimeout(() => { lightbox.hidden = true; }, 250); }; + lightbox.addEventListener('click', closeLightbox); + document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !lightbox.hidden) closeLightbox(); }); document.getElementById('reroll')?.addEventListener('click', () => answer('reroll')); `; }