From d59739532e105e1bcac0389202dc77255d4462cd Mon Sep 17 00:00:00 2001 From: Mattias Bodlund Date: Tue, 25 Aug 2026 15:20:39 +0200 Subject: [PATCH] animation step 1 --- app/assets/stylesheets/application.css | 44 ++++++++++++++++ app/javascript/application.js | 2 + app/javascript/carousel_controller.js | 6 +++ app/javascript/modal_controller.js | 71 +++++++++++++++++++++++++- app/javascript/start_controller.js | 31 +++++++++++ app/views/game/_facts_content.html.erb | 2 +- app/views/game/start.html.erb | 3 +- app/views/layouts/application.html.erb | 2 +- config/importmap.rb | 1 + 9 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 app/javascript/start_controller.js diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index abe49d3..0c1d0ab 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -103,6 +103,8 @@ h2@property --glow-deg { --td-3xl: 700 4rem/1.2 var(--ff-ikea); --ease-out: cubic-bezier(0.22, 0.61, 0.36, 1); + --ease-in: cubic-bezier(0.55, 0, 0.68, 0.53); + --dur-fast: 400ms; --dur-base: 600ms; --dur-slow: 900ms; @@ -310,6 +312,11 @@ main, .modal-panel { to { transform: translateX(0); } } +@keyframes wave-slide-out { + from { transform: translateX(0); } + to { transform: translateX(-100vw); } +} + @media (max-width: 1023.98px) and (prefers-reduced-motion: no-preference) { .start .hero-container { overflow: clip; @@ -327,6 +334,43 @@ main, .modal-panel { .start .hero-container svg.ico-wave { animation: wave-slide-in var(--dur-base) var(--ease-out) calc(var(--dur-slow) * 0.4) both; } + + .start > article { + transition: transform var(--dur-base) var(--ease-in), + margin-block-end var(--dur-base) var(--ease-in); + } + + .start.is-exiting { + overflow: clip; + } + + .start.is-exiting > article { + transform: translateY(100%); + margin-block-end: calc(var(--exit-shift, 0px) * -1); + } + + .start.is-exiting .hero-container svg.ico-wave { + animation: wave-slide-out var(--dur-fast) var(--ease-in) var(--dur-base) both; + } + + .facts-modal .carousel > li { + overflow: clip; + } + + .facts-modal .hero-container svg.ico-wave { + animation: wave-slide-in var(--dur-base) var(--ease-out) both; + } + + .facts-modal .is-entering { + max-height: 100%; + overflow: clip; + } + + .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); + } } .facts { diff --git a/app/javascript/application.js b/app/javascript/application.js index d9197b6..a21393b 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -8,6 +8,7 @@ import ChanceController from "chance_controller" import ShareController from "share_controller" import ModalController from "modal_controller" import RatingController from "rating_controller" +import StartController from "start_controller" const application = Application.start() @@ -18,6 +19,7 @@ application.register("chance", ChanceController) application.register("share", ShareController) application.register("modal", ModalController) application.register("rating", RatingController) +application.register("start", StartController) // 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 diff --git a/app/javascript/carousel_controller.js b/app/javascript/carousel_controller.js index 355210f..7258278 100644 --- a/app/javascript/carousel_controller.js +++ b/app/javascript/carousel_controller.js @@ -8,6 +8,9 @@ export default class extends Controller { this.boundEqualizeHeights = this.equalizeHeights.bind(this) this.carouselTarget.addEventListener("scroll", this.boundUpdate, { passive: true }) window.addEventListener("resize", this.boundEqualizeHeights) + // Inside a modal the slides have no height until the dialog is open, so the + // first useful measurement can only happen once it announces itself. + window.addEventListener("modal:opened", this.boundEqualizeHeights) this.equalizeHeights() this.update() } @@ -15,8 +18,11 @@ export default class extends Controller { disconnect() { this.carouselTarget.removeEventListener("scroll", this.boundUpdate) window.removeEventListener("resize", this.boundEqualizeHeights) + window.removeEventListener("modal:opened", this.boundEqualizeHeights) } + // One height for every slide, so the hero below is the same size on each and + // swiping doesn't jog the layout. equalizeHeights() { const articles = this.carouselTarget.querySelectorAll(":scope > li > article") articles.forEach((article) => (article.style.height = "auto")) diff --git a/app/javascript/modal_controller.js b/app/javascript/modal_controller.js index f1d5aef..0015c53 100644 --- a/app/javascript/modal_controller.js +++ b/app/javascript/modal_controller.js @@ -4,7 +4,7 @@ export default class extends Controller { static targets = ["dialog"] connect() { - this.dialogTarget.showModal() + this.#openWhenSettled() } close() { @@ -18,6 +18,75 @@ export default class extends Controller { } remove() { + this.dispatch("closed", { target: window }) this.element.remove() } + + // 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") + if (wave) await this.#settle(wave, "animationend") + + if (!this.element.isConnected) return + + this.dialogTarget.showModal() + this.dispatch("opened", { target: window }) + this.#playEntrance() + } + + // The hero opens full-bleed, pushing the copy and the markers off the bottom. + // 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. + async #playEntrance() { + const panel = this.element.querySelector(".modal-panel") + const carousel = panel?.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 + + panel.classList.add("is-entering") + boxes.forEach((box) => { + box.style.transition = "none" + box.style.height = `${full}px` + }) + getComputedStyle(carousel).height + boxes.forEach((box) => (box.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`)) + await this.#settle(carousel, "transitionend") + + panel.classList.remove("is-entering") + boxes.forEach((box) => (box.style.height = "")) + } + + // 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(). + #settle(element, eventName) { + return new Promise((resolve) => { + const style = getComputedStyle(element) + const seconds = + eventName === "animationend" + ? parseFloat(style.animationDuration) + parseFloat(style.animationDelay) + : parseFloat(style.transitionDuration) + parseFloat(style.transitionDelay) + + const finish = () => { + element.removeEventListener(eventName, finish) + clearTimeout(timer) + resolve() + } + + const timer = setTimeout(finish, (seconds || 0) * 1000 + 50) + element.addEventListener(eventName, finish) + }) + } } diff --git a/app/javascript/start_controller.js b/app/javascript/start_controller.js new file mode 100644 index 0000000..53711cc --- /dev/null +++ b/app/javascript/start_controller.js @@ -0,0 +1,31 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + this.reset = this.reset.bind(this) + window.addEventListener("modal:closed", this.reset) + } + + disconnect() { + window.removeEventListener("modal:closed", this.reset) + } + + // Slides the copy out and lets the hero flex up to fill the viewport. The + // negative margin is what frees the space; the transform carries the article + // the rest of the way off-screen. + exit() { + const article = this.element.querySelector(":scope > article") + if (!article) return + + this.element.style.setProperty("--exit-shift", `${article.offsetHeight}px`) + this.element.classList.add("is-exiting") + + // Force the transitions into existence now, so a fast turbo_stream response + // still finds something to wait on before it opens the modal. + getComputedStyle(article).marginBlockEnd + } + + reset() { + this.element.classList.remove("is-exiting") + } +} diff --git a/app/views/game/_facts_content.html.erb b/app/views/game/_facts_content.html.erb index 6b84916..b1c4176 100644 --- a/app/views/game/_facts_content.html.erb +++ b/app/views/game/_facts_content.html.erb @@ -27,6 +27,6 @@
- <%= link_to tag.span(t("game.got_it_lets_get_started")), {action: "intro"}, class: "cta" %> + <%= link_to tag.span(t("game.got_it_lets_get_started")), { action: "intro" }, class: "cta" %>
diff --git a/app/views/game/start.html.erb b/app/views/game/start.html.erb index 5bfdc1b..6013805 100644 --- a/app/views/game/start.html.erb +++ b/app/views/game/start.html.erb @@ -1,4 +1,5 @@ <%- content_for :title, node_title(@node) %> +<%- content_for :main_controller, "start" %> <%- content_for :header do %> <%= render partial: "language-menu" %> @@ -15,7 +16,7 @@ <%= tag.h1 t("game.intro_title").html_safe %> <%= tag.p t("game.intro_description") %> - <%= button_to tag.span(t("game.let_me_try")), start_path, class: "cta" %> + <%= button_to tag.span(t("game.let_me_try")), start_path, class: "cta", data: { action: "click->start#exit" } %> <%= tag.div class: "play-time" do %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 35c1b9f..f13061d 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -17,7 +17,7 @@ <%= stylesheet_link_tag "reset", "application" %> - <%= frontend_javascript_importmap_tags %w[application @hotwired/turbo-rails @hotwired/stimulus language_menu_controller carousel_controller intro_controller chance_controller share_controller modal_controller rating_controller] %> + <%= frontend_javascript_importmap_tags %w[application @hotwired/turbo-rails @hotwired/stimulus language_menu_controller carousel_controller intro_controller chance_controller share_controller modal_controller rating_controller start_controller] %> <%= turbo_frame_tag "game" do %> diff --git a/config/importmap.rb b/config/importmap.rb index 2bca5c2..010f212 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -21,3 +21,4 @@ pin "chance_controller", preload: false pin "share_controller", preload: false pin "modal_controller", preload: false pin "rating_controller", preload: false +pin "start_controller", preload: false