Browse Source

animation mobil v1

main
Mattias Bodlund 3 weeks ago
parent
commit
391fcdd751
10 changed files with 324 additions and 41 deletions
  1. +183
    -15
      app/assets/stylesheets/application.css
  2. +63
    -0
      app/javascript/answer_controller.js
  3. +16
    -2
      app/javascript/application.js
  4. +3
    -1
      app/javascript/carousel_controller.js
  5. +14
    -4
      app/javascript/modal_controller.js
  6. +17
    -14
      app/views/game/_facts_content.html.erb
  7. +12
    -1
      app/views/game/done.html.erb
  8. +13
    -2
      app/views/game/stage_result.html.erb
  9. +2
    -2
      app/views/layouts/application.html.erb
  10. +1
    -0
      config/importmap.rb

+ 183
- 15
app/assets/stylesheets/application.css View File

@ -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);
}


+ 63
- 0
app/javascript/answer_controller.js View File

@ -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
}
}

+ 16
- 2
app/javascript/application.js View File

@ -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)
}


+ 3
- 1
app/javascript/carousel_controller.js View File

@ -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() {


+ 14
- 4
app/javascript/modal_controller.js View File

@ -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")


+ 17
- 14
app/views/game/_facts_content.html.erb View File

@ -1,18 +1,21 @@
<div class="modal-panel facts" data-controller="carousel">
<ul class="carousel" data-carousel-target="carousel">
<% @node.attachments.each do |attachment| %>
<li>
<div class="hero-container">
<%= render_responsive_picture(attachment.asset, alt: "Hero", fetchpriority: "high") %>
<%= svg "inter-ikea-systems" %>
<%= svg "ico-wave" %>
</div>
<article>
<%= attachment.body.html_safe %>
</article>
</li>
<% end %>
</ul>
<div class="carousel-frame">
<ul class="carousel" data-carousel-target="carousel">
<% @node.attachments.each do |attachment| %>
<li>
<div class="hero-container">
<%= render_responsive_picture(attachment.asset, alt: "Hero", fetchpriority: "high") %>
<%= svg "inter-ikea-systems" %>
</div>
<article>
<%= attachment.body.html_safe %>
</article>
</li>
<% end %>
</ul>
<%= svg "ico-wave" %>
</div>
<div class="scroll-marker-group" role="tablist">
<% @node.attachments.each_with_index do |_attachment, index| %>


+ 12
- 1
app/views/game/done.html.erb View File

@ -1,10 +1,20 @@
<%- content_for :title, node_title(@node) %>
<%- content_for :main_controller, "answer" %>
<% image_attachment = @node.attachments&.select { |a| a.asset&.file&.image? }[0] %>
<% video_attachment = @node.attachments&.select { |a| a.asset&.file&.video? }[0] %>
<%= render partial: "stage_header" %>
<div class="hero-container">
<%= render_responsive_picture(image_attachment.asset, alt: "Hero", fetchpriority: "high") if image_attachment %>
<% if video_attachment %>
<%= video_tag rails_storage_proxy_path(video_attachment.asset.file),
muted: true,
loop: false,
playsinline: true,
preload: "auto",
data: { answer_target: "video" } %>
<% end %>
<article>
<%= @result_node.attachments[0]&.body&.html_safe %>
</article>
@ -23,6 +33,7 @@
<div>
<%= link_to tag.span(t("game.drumroll_see_the_result")),
{ action: "results" },
class: (@result_node.parent.chance? and @result_node.bad_answer?) ? "cta last_save" : "cta" %>
class: (@result_node.parent.chance? and @result_node.bad_answer?) ? "cta last_save" : "cta",
data: { action: "click->answer#exit", answer_full_param: true } %>
</div>
</article>

+ 13
- 2
app/views/game/stage_result.html.erb View File

@ -1,10 +1,20 @@
<%- content_for :title, node_title(@node) %>
<%- content_for :main_controller, "answer" %>
<% image_attachment = @node.attachments&.select { |a| a.asset&.file&.image? }[0] %>
<% video_attachment = @node.attachments&.select { |a| a.asset&.file&.video? }[0] %>
<%= render partial: "stage_header" %>
<div class="hero-container">
<%= render_responsive_picture(image_attachment.asset, alt: "Hero", fetchpriority: "high") if image_attachment %>
<% if video_attachment %>
<%= video_tag rails_storage_proxy_path(video_attachment.asset.file),
muted: true,
loop: false,
playsinline: true,
preload: "auto",
data: { answer_target: "video" } %>
<% end %>
<article>
<%= @result_node.attachments[0]&.body&.html_safe %>
</article>
@ -25,12 +35,13 @@
<% if stage_index == n_stages %>
<%= link_to tag.span(t("game.drumroll_see_the_result")),
{ action: "results" },
class: "cta" %>
class: "cta", data: { action: "click->answer#exit", answer_full_param: true } %>
<% elsif @result_node.parent.chance? and @result_node.bad_answer? %>
<%= link_to tag.span(t("game.let_me_try")), {action: "last_save" }, class: "cta last_save" %>
<% else %>
<%= link_to tag.span(t("game.next_stage")), {action: "stage", id: stage_index + 1 }, class: "cta" %>
<%= link_to tag.span(t("game.next_stage")), { action: "stage", id: stage_index + 1 },
class: "cta", data: { action: "click->answer#exit" } %>
<% end %>
</div>
</article>


+ 2
- 2
app/views/layouts/application.html.erb View File

@ -17,7 +17,7 @@
<link rel="icon" sizes="192x192" href="/ikea-favicon-300x300.png">
<%= 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 start_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 answer_controller] %>
</head>
<body id="page">
<%= turbo_frame_tag "game" do %>
@ -46,6 +46,6 @@
<% end %>
<% end %>
<%= render "shared/dev_jump" if Rails.env.development? %>
<%# render "shared/dev_jump" if Rails.env.development? %>
</body>
</html>

+ 1
- 0
config/importmap.rb View File

@ -22,3 +22,4 @@ pin "share_controller", preload: false
pin "modal_controller", preload: false
pin "rating_controller", preload: false
pin "start_controller", preload: false
pin "answer_controller", preload: false

Loading…
Cancel
Save