diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 1638e8d..d137dcb 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -352,6 +352,21 @@ main, .modal-panel { to { opacity: 1; transform: translateY(0); } } +@keyframes wave-rise-in { + from { translate: 0 200px; } + to { translate: 0 0; } +} + +@keyframes hero-detail-slide-in { + from { transform: translateX(100vw); } + to { transform: translateX(0); } +} + +@keyframes hero-wave-slide-in { + from { transform: translateY(100vw); } + to { transform: translateY(0); } +} + @media (max-width: 1023.98px) and (prefers-reduced-motion: no-preference) { .start .hero-container { overflow: clip; @@ -610,6 +625,108 @@ main, .modal-panel { } +@media (min-width: 1024px) and (prefers-reduced-motion: no-preference) { + body:has(.start) header { + animation: header-drop-in var(--dur-base) var(--ease-out) 100ms both; + } + + .start > article { + animation: fade-in var(--dur-slow) var(--ease-out) 250ms both; + } + + .start .hero-container { + animation: fade-in var(--dur-slow) var(--ease-out) both; + } + + .start .hero-container svg.ico-wave { + animation: wave-rise-in var(--dur-slow) var(--ease-out) both; + } + + .facts-modal[open] { + translate: 0 0; + transition: translate var(--dur-base) var(--ease-out); + } + + @starting-style { + .facts-modal[open] { + translate: 0 100dvh; + } + } + + .facts-modal[open]::backdrop { + opacity: 1; + transition: opacity var(--dur-base) var(--ease-out); + } + + @starting-style { + .facts-modal[open]::backdrop { + opacity: 0; + } + } + + .facts-modal[open].is-leaving { + translate: 0 -100dvh; + transition-timing-function: var(--ease-in); + pointer-events: none; + } + + .facts-modal[open].is-leaving::backdrop { + opacity: 0; + } + + html[data-screen-transition="stage-in-wide"] main { + view-transition-name: game; + } + + html[data-screen-transition="stage-in-wide"]::view-transition-old(root), + html[data-screen-transition="stage-in-wide"]::view-transition-new(root), + html[data-screen-transition="stage-in-wide"]::view-transition-old(game), + html[data-screen-transition="stage-in-wide"]::view-transition-new(game) { + mix-blend-mode: normal; + } + + html[data-screen-transition="stage-in-wide"]::view-transition-old(root), + html[data-screen-transition="stage-in-wide"]::view-transition-new(root), + html[data-screen-transition="stage-in-wide"]::view-transition-old(game) { + animation: none; + } + + html[data-screen-transition="stage-in-wide"]::view-transition-new(game) { + animation: screen-slide-in var(--dur-base) var(--ease-out) both; + } + + html[data-screen-transition="stage-in-wide"] .stage .hero-container img { + view-transition-name: hero-image; + } + + html[data-screen-transition="stage-in-wide"]::view-transition-new(hero-image) { + animation: fade-in var(--dur-base) linear var(--dur-base) both; + mix-blend-mode: normal; + } + + html[data-screen-transition="stage-in-wide"] .stage .hero-container svg.ico-wave { + view-transition-name: hero-wave; + } + + html[data-screen-transition="stage-in-wide"] .stage .hero-container #inter-ikea-systems { + view-transition-name: hero-logo; + } + + html[data-screen-transition="stage-in-wide"]::view-transition-new(hero-wave), + html[data-screen-transition="stage-in-wide"]::view-transition-new(hero-logo) { + mix-blend-mode: normal; + } + + html[data-screen-transition="stage-in-wide"]::view-transition-new(hero-wave) { + animation: hero-wave-slide-in var(--dur-base) var(--ease-out) both; + } + + html[data-screen-transition="stage-in-wide"]::view-transition-new(hero-logo) { + animation: hero-detail-slide-in var(--dur-base) var(--ease-out) both; + } +} + + .facts { --clr-bg: var(--clr-green); --clr-action: var(--clr-white); diff --git a/app/javascript/application.js b/app/javascript/application.js index b663638..6fd2780 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -47,16 +47,26 @@ const SCREEN_TRANSITIONS = { "bad_answer>results": "slide-up" } +// The desktop layout is a different composition -- two columns rather than one +// stack -- so the pairs that animate there, and how, are their own map. +const WIDE_SCREEN_TRANSITIONS = { + "intro>stage": "stage-in-wide" +} + const TRANSITION_MEDIA = "(max-width: 1023.98px) and (prefers-reduced-motion: no-preference)" +const WIDE_TRANSITION_MEDIA = "(min-width: 1024px) and (prefers-reduced-motion: no-preference)" function screenTransition(newFrame) { if (!document.startViewTransition) return null - if (!window.matchMedia(TRANSITION_MEDIA).matches) return null const from = document.querySelector("turbo-frame#game main")?.classList[0] const to = newFrame.querySelector("main")?.classList[0] + const pair = `${from}>${to}` + + if (window.matchMedia(TRANSITION_MEDIA).matches) return SCREEN_TRANSITIONS[pair] + if (window.matchMedia(WIDE_TRANSITION_MEDIA).matches) return WIDE_SCREEN_TRANSITIONS[pair] - return SCREEN_TRANSITIONS[`${from}>${to}`] + return null } // Turbo hands over the function that swaps the frame's contents, so running it diff --git a/app/javascript/modal_controller.js b/app/javascript/modal_controller.js index d869617..64ad795 100644 --- a/app/javascript/modal_controller.js +++ b/app/javascript/modal_controller.js @@ -41,7 +41,7 @@ export default class extends Controller { // The screen underneath may still be animating out. The wave is the last // thing to leave, so waiting on it holds the modal until the exit is done. async #openWhenSettled() { - const wave = document.querySelector(".is-exiting .hero-container svg.ico-wave") + const wave = this.#isMobile && document.querySelector(".is-exiting .hero-container svg.ico-wave") if (wave) await this.#settle(wave, "animationend") if (!this.element.isConnected) return @@ -58,6 +58,10 @@ export default class extends Controller { // hero pushes past it. The wave rides the same shrink from the screen edge // up to the seam, since it no longer sits inside the hero it draws under. async #playEntrance() { + // Desktop opens the panel as a centred dialog that slides up whole, so the + // hero never needs to fill the screen first. + if (!this.#isMobile) return + const panel = this.element.querySelector(".modal-panel") const frame = panel?.querySelector(".carousel-frame") const carousel = frame?.querySelector(".carousel") @@ -94,6 +98,12 @@ export default class extends Controller { boxes.forEach((box) => (box.style.height = "")) } + // The screen exit and the panel's full-bleed entrance are mobile choreography; + // the desktop panel just slides up over a screen that stays put. + get #isMobile() { + return window.matchMedia("(max-width: 1023.98px)").matches + } + // Resolves when the element's animation or transition ends. Safari doesn't // reliably have one registered at the moment we ask for it, so the declared // timing is used as a fallback rather than getAnimations().