From 391fcdd7515f044cc1f7b60f0f5481c17caa9f15 Mon Sep 17 00:00:00 2001 From: Mattias Bodlund Date: Wed, 26 Aug 2026 15:46:18 +0200 Subject: [PATCH] animation mobil v1 --- app/assets/stylesheets/application.css | 198 +++++++++++++++++++++++-- app/javascript/answer_controller.js | 63 ++++++++ app/javascript/application.js | 18 ++- app/javascript/carousel_controller.js | 4 +- app/javascript/modal_controller.js | 18 ++- app/views/game/_facts_content.html.erb | 31 ++-- app/views/game/done.html.erb | 13 +- app/views/game/stage_result.html.erb | 15 +- app/views/layouts/application.html.erb | 4 +- config/importmap.rb | 1 + 10 files changed, 324 insertions(+), 41 deletions(-) create mode 100644 app/javascript/answer_controller.js diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index c45b133..b5089e0 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -308,8 +308,8 @@ main, .modal-panel { } @keyframes wave-slide-in { - from { transform: translateX(100%); } - to { transform: translateX(0); } + from { translate: 100% 0; } + to { translate: 0 0; } } @keyframes wave-slide-out { @@ -322,6 +322,11 @@ main, .modal-panel { to { transform: translateX(0); } } +@keyframes screen-slide-up { + from { transform: translateY(100dvh); } + to { transform: translateY(0); } +} + @keyframes answers-sink { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(1.5rem); } @@ -332,6 +337,21 @@ main, .modal-panel { to { opacity: 1; transform: translateY(0); } } +@keyframes answers-slide-up { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +@keyframes indicator-blink { + from { background-color: var(--clr-green); } + to { background-color: var(--clr-white); } +} + +@keyframes hero-rise { + from { opacity: 0; transform: translateY(1.5rem); } + to { opacity: 1; transform: translateY(0); } +} + @media (max-width: 1023.98px) and (prefers-reduced-motion: no-preference) { .start .hero-container { overflow: clip; @@ -372,8 +392,9 @@ main, .modal-panel { overflow: clip; } - .facts-modal .hero-container svg.ico-wave { + .facts-modal .carousel-frame > svg.ico-wave { animation: wave-slide-in var(--dur-base) var(--ease-out) both; + transition: bottom var(--dur-base) var(--ease-out); } .facts-modal .is-entering { @@ -381,26 +402,27 @@ main, .modal-panel { overflow: clip; } + .facts-modal .is-entering .carousel-frame, .facts-modal .is-entering .carousel, .facts-modal .is-entering .carousel > li > .hero-container { flex: 0 0 auto; transition: height var(--dur-base) var(--ease-out); } - html[data-screen-transition="slide-left"] main { + html:is([data-screen-transition="slide-left"], [data-screen-transition="slide-up"]) main { view-transition-name: game; } - html[data-screen-transition="slide-left"]::view-transition-old(root), - html[data-screen-transition="slide-left"]::view-transition-new(root), - html[data-screen-transition="slide-left"]::view-transition-old(game), - html[data-screen-transition="slide-left"]::view-transition-new(game) { + html:is([data-screen-transition="slide-left"], [data-screen-transition="slide-up"])::view-transition-old(root), + html:is([data-screen-transition="slide-left"], [data-screen-transition="slide-up"])::view-transition-new(root), + html:is([data-screen-transition="slide-left"], [data-screen-transition="slide-up"])::view-transition-old(game), + html:is([data-screen-transition="slide-left"], [data-screen-transition="slide-up"])::view-transition-new(game) { mix-blend-mode: normal; } - html[data-screen-transition="slide-left"]::view-transition-old(root), - html[data-screen-transition="slide-left"]::view-transition-new(root), - html[data-screen-transition="slide-left"]::view-transition-old(game) { + html:is([data-screen-transition="slide-left"], [data-screen-transition="slide-up"])::view-transition-old(root), + html:is([data-screen-transition="slide-left"], [data-screen-transition="slide-up"])::view-transition-new(root), + html:is([data-screen-transition="slide-left"], [data-screen-transition="slide-up"])::view-transition-old(game) { animation: none; } @@ -408,6 +430,26 @@ main, .modal-panel { animation: screen-slide-in var(--dur-base) var(--ease-out) both; } + html[data-screen-transition="slide-up"]::view-transition-new(game) { + animation: screen-slide-up var(--dur-base) var(--ease-out) both; + } + + html[data-screen-transition="slide-up"] main.results { + background-color: var(--clr-sand-light); + } + + html[data-screen-transition="slide-up"] .results-tagline svg.ico-wave { + opacity: 0; + } + + .results.is-settling .results-tagline { + overflow: clip; + } + + .results.is-settling .results-tagline svg.ico-wave { + animation: wave-slide-in var(--dur-fast) var(--ease-out) both; + } + html[data-screen-transition="answer"] .hero-container { view-transition-name: hero; } @@ -437,6 +479,109 @@ main, .modal-panel { animation: answers-rise calc(var(--dur-base) * 0.6) var(--ease-out) calc(var(--dur-base) * 0.4) both; } + + .good_answer > .answers-container, + .bad_answer > .answers-container { + transition: transform var(--dur-base) var(--ease-in), + margin-block-end var(--dur-base) var(--ease-in); + } + + .good_answer > .stage-header-container, + .bad_answer > .stage-header-container { + transition: margin-block-start var(--dur-base) var(--ease-in); + } + + .good_answer.is-finishing > .stage-header-container, + .bad_answer.is-finishing > .stage-header-container { + margin-block-start: calc(var(--header-shift, 0px) * -1); + } + + .good_answer.is-exiting .stage-progress li.done + li:not(.done), + .bad_answer.is-exiting .stage-progress li.done + li:not(.done) { + animation: indicator-blink calc(var(--dur-base) * 2) steps(2, jump-none) infinite; + } + + .good_answer .hero-container article, + .bad_answer .hero-container article, + .good_answer .hero-container video, + .bad_answer .hero-container video { + transition: opacity var(--dur-base) var(--ease-out); + } + + .good_answer.is-exiting, + .bad_answer.is-exiting { + overflow: clip; + } + + .good_answer.is-exiting > .answers-container, + .bad_answer.is-exiting > .answers-container { + transform: translateY(100%); + margin-block-end: calc(var(--exit-shift, 0px) * -1); + } + + .good_answer.is-exiting .hero-container article, + .bad_answer.is-exiting .hero-container article { + opacity: 0; + } + + .good_answer.is-exiting .hero-container video, + .bad_answer.is-exiting .hero-container video { + opacity: 1; + } + + .good_answer.is-exiting .hero-container svg.ico-wave, + .bad_answer.is-exiting .hero-container svg.ico-wave { + animation: wave-slide-out var(--dur-fast) var(--ease-in) both; + } + + html[data-screen-transition="stage-in"] .stage-header-container { + view-transition-name: stage-header; + } + + html[data-screen-transition="stage-in"] .hero-container { + view-transition-name: hero; + } + + html[data-screen-transition="stage-in"] .stage .answers-container { + view-transition-name: answers; + } + + html[data-screen-transition="stage-in"] .stage .hero-container svg.ico-wave { + opacity: 0; + } + + .stage.is-settling .hero-container { + overflow: clip; + } + + .stage.is-settling .hero-container svg.ico-wave { + animation: wave-slide-in var(--dur-fast) var(--ease-out) both; + } + + html[data-screen-transition="stage-in"]::view-transition-group(stage-header), + html[data-screen-transition="stage-in"]::view-transition-group(hero), + html[data-screen-transition="stage-in"]::view-transition-old(stage-header), + html[data-screen-transition="stage-in"]::view-transition-new(stage-header) { + animation-duration: var(--dur-base); + animation-timing-function: var(--ease-out); + } + + html[data-screen-transition="stage-in"]::view-transition-old(hero) { + height: 100%; + object-fit: cover; + object-position: top; + animation: none; + } + + html[data-screen-transition="stage-in"]::view-transition-new(hero) { + animation: hero-rise var(--dur-base) var(--ease-out) both; + } + + html[data-screen-transition="stage-in"]::view-transition-new(answers) { + animation: answers-slide-up var(--dur-base) var(--ease-out) both; + mix-blend-mode: normal; + } + } .facts { @@ -512,6 +657,10 @@ main, .modal-panel { img { display: none; } + + video { + opacity: 0; + } } .hero-container article { @@ -564,11 +713,29 @@ main, .modal-panel { } } -.hero-container, .carousel { +.hero-container, .carousel, .carousel-frame { flex: 1 1 0; min-height: 0; } +.carousel-frame { + display: flex; + flex-direction: column; + position: relative; + overflow: clip; + + > svg.ico-wave { + position: absolute; + right: 0; + bottom: var(--slide-copy-height, 0px); + width: 190px; + height: 96px; + display: block; + fill: var(--clr-bg, var(--clr-green)); + pointer-events: none; + } +} + .hero-container { display: grid; grid-template: 1fr / 1fr; @@ -590,7 +757,7 @@ main, .modal-panel { background-color: transparent; } - &:has(video) { + .intro &:has(video) { color: var(--clr-white); & h2 { @@ -1119,7 +1286,8 @@ dialog::backdrop { --clr-action: var(--clr-green); .hero-container, - .carousel { + .carousel, + .carousel-frame { flex: 0 0 auto; } @@ -1127,7 +1295,7 @@ dialog::backdrop { height: min(30vh, 320px); } - .hero-container svg.ico-wave { + .carousel-frame > svg.ico-wave { transform: translateY(1px); } diff --git a/app/javascript/answer_controller.js b/app/javascript/answer_controller.js new file mode 100644 index 0000000..1cc691d --- /dev/null +++ b/app/javascript/answer_controller.js @@ -0,0 +1,63 @@ +import { Controller } from "@hotwired/stimulus" + +// The animation this drives only exists on small screens, and never for +// someone who asked for less motion. Anywhere else the link just navigates. +const MOTION = "(max-width: 1023.98px) and (prefers-reduced-motion: no-preference)" + +export default class extends Controller { + static targets = ["video"] + + // Tapping through to the next stage hands over to the stage's own video: the + // copy leaves, the hero grows into the space it frees, and the video fades up + // behind the result. The next screen then arrives over the video's tail. + exit(event) { + if (this.leaving) return + if (!this.hasVideoTarget) return + if (!window.matchMedia(MOTION).matches) return + + event.preventDefault() + this.link = event.currentTarget + + const answers = this.element.querySelector(".answers-container") + if (answers) this.element.style.setProperty("--exit-shift", `${answers.offsetHeight}px`) + + // The last video hands over to the results rather than to another stage, so + // the header leaves with the copy and the video takes the whole screen. + if (event.params.full) { + const header = this.element.querySelector(".stage-header-container") + if (header) this.element.style.setProperty("--header-shift", `${header.offsetHeight}px`) + this.element.classList.add("is-finishing") + } + + this.element.classList.add("is-exiting") + + this.videoTarget.addEventListener("timeupdate", this.checkTail) + this.videoTarget.addEventListener("ended", this.follow, { once: true }) + this.videoTarget.addEventListener("error", this.follow, { once: true }) + this.videoTarget.play().catch(this.follow) + } + + // The next stage assembles itself over the tail of the video rather than + // after it, so the last frame is still moving while the new screen arrives. + checkTail = () => { + const { duration, currentTime } = this.videoTarget + if (!duration || duration === Infinity) return + + if ((duration - currentTime) * 1000 <= this.#lead()) this.follow() + } + + follow = () => { + if (this.leaving) return + + this.leaving = true + this.videoTarget.removeEventListener("timeupdate", this.checkTail) + this.link?.click() + } + + // How much of the video the incoming screen overlaps -- the same length the + // transition itself runs for. + #lead() { + const declared = getComputedStyle(document.documentElement).getPropertyValue("--dur-base") + return parseFloat(declared) * (declared.includes("ms") ? 1 : 1000) || 600 + } +} diff --git a/app/javascript/application.js b/app/javascript/application.js index a9bf998..2131902 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -9,6 +9,7 @@ import ShareController from "share_controller" import ModalController from "modal_controller" import RatingController from "rating_controller" import StartController from "start_controller" +import AnswerController from "answer_controller" const application = Application.start() @@ -20,6 +21,7 @@ application.register("share", ShareController) application.register("modal", ModalController) application.register("rating", RatingController) application.register("start", StartController) +application.register("answer", AnswerController) // The whole game runs inside one turbo-frame so the URL never changes. Frame // navigations don't reset scroll the way a full page visit does, so each new @@ -34,7 +36,11 @@ document.addEventListener("turbo:frame-render", (event) => { const SCREEN_TRANSITIONS = { "intro>stage": "slide-left", "stage>good_answer": "answer", - "stage>bad_answer": "answer" + "stage>bad_answer": "answer", + "good_answer>stage": "stage-in", + "bad_answer>stage": "stage-in", + "good_answer>results": "slide-up", + "bad_answer>results": "slide-up" } const TRANSITION_MEDIA = "(max-width: 1023.98px) and (prefers-reduced-motion: no-preference)" @@ -65,8 +71,16 @@ document.addEventListener("turbo:before-frame-render", (event) => { // Two navigations at once -- the intro video ending on top of a tap -- leave // the first transition aborted, so the settled state is reached either way. const clear = () => delete document.documentElement.dataset.screenTransition + + // Anything a screen wants to animate once it has landed hangs off this: the + // snapshots are gone by then, so it plays on the real elements. + const settle = () => { + clear() + document.querySelector("turbo-frame#game main")?.classList.add("is-settling") + } + const transition = document.startViewTransition(() => render(currentElement, newElement)) - transition.finished.then(clear, clear) + transition.finished.then(settle, clear) return transition.updateCallbackDone.catch(clear) } diff --git a/app/javascript/carousel_controller.js b/app/javascript/carousel_controller.js index 7258278..2e8daac 100644 --- a/app/javascript/carousel_controller.js +++ b/app/javascript/carousel_controller.js @@ -22,12 +22,14 @@ export default class extends Controller { } // One height for every slide, so the hero below is the same size on each and - // swiping doesn't jog the layout. + // swiping doesn't jog the layout. The wave sits outside the carousel and + // needs the same measurement to find the seam it draws. equalizeHeights() { const articles = this.carouselTarget.querySelectorAll(":scope > li > article") articles.forEach((article) => (article.style.height = "auto")) const tallest = Array.from(articles).reduce((max, article) => Math.max(max, article.offsetHeight), 0) articles.forEach((article) => (article.style.height = `${tallest}px`)) + this.element.style.setProperty("--slide-copy-height", `${tallest}px`) } update() { diff --git a/app/javascript/modal_controller.js b/app/javascript/modal_controller.js index 0015c53..8ea691a 100644 --- a/app/javascript/modal_controller.js +++ b/app/javascript/modal_controller.js @@ -39,29 +39,39 @@ export default class extends Controller { // Once the wave has landed, the carousel and the hero shrink back to their // measured resting heights, which carries everything below up into view. // Sizing both is what hides the slide's own copy: the li clips whatever the - // hero pushes past it. + // 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() { const panel = this.element.querySelector(".modal-panel") - const carousel = panel?.querySelector(".carousel") + const frame = panel?.querySelector(".carousel-frame") + const carousel = frame?.querySelector(".carousel") if (!carousel) return const heroes = Array.from(carousel.querySelectorAll(":scope > li > .hero-container")) const boxes = [carousel, ...heroes] const rest = new Map(boxes.map((box) => [box, box.offsetHeight])) - const full = this.dialogTarget.clientHeight + // Border-box, not clientHeight: a scrollbar must not shorten the hero and + // leave a strip of panel showing under it. + const full = this.dialogTarget.getBoundingClientRect().height + const wave = frame.querySelector(":scope > svg.ico-wave") panel.classList.add("is-entering") boxes.forEach((box) => { box.style.transition = "none" box.style.height = `${full}px` }) + if (wave) { + wave.style.transition = "none" + wave.style.bottom = "0px" + } getComputedStyle(carousel).height boxes.forEach((box) => (box.style.transition = "")) + if (wave) wave.style.transition = "" - const wave = carousel.querySelector(".hero-container svg.ico-wave") if (wave) await this.#settle(wave, "animationend") boxes.forEach((box) => (box.style.height = `${rest.get(box)}px`)) + if (wave) wave.style.bottom = "" await this.#settle(carousel, "transitionend") panel.classList.remove("is-entering") diff --git a/app/views/game/_facts_content.html.erb b/app/views/game/_facts_content.html.erb index b1c4176..46ddf2a 100644 --- a/app/views/game/_facts_content.html.erb +++ b/app/views/game/_facts_content.html.erb @@ -1,18 +1,21 @@