|
|
|
@ -12,6 +12,9 @@ This project is a Ruby on Rails application developed for the IKEA Foundation. I |
|
|
|
- **Answer:** Records the player's selection; for `chance`, an outcome child is sampled. |
|
|
|
- **Stage result:** Shows the outcome node and applies its score. |
|
|
|
- **Last save / Results:** Final compost-vs-landfill choice, then the scored results screen. |
|
|
|
- **Screen animation:** Every screen swap is animated — CSS exits before the navigation, view |
|
|
|
transitions around the frame render, and a per-stage video that plays between an answer and |
|
|
|
the next stage. Mobile only. See below. |
|
|
|
- **Scoring:** Answer scores live in `config/question_scores.json`, not in the database. See below. |
|
|
|
- **Analytics:** An admin dashboard at `/admin/:locale/analytics` reporting funnel drop-off, |
|
|
|
answer distribution, the results-screen thumbs vote and who played — device, language and |
|
|
|
@ -128,6 +131,100 @@ the UA was never stored, so there is nothing to backfill from. |
|
|
|
again overwrites. The results screen posts to `game#rate` via `rating_controller.js`, which |
|
|
|
flips the button state immediately and does not await the response. |
|
|
|
|
|
|
|
## Screens and animation |
|
|
|
|
|
|
|
Every screen lives inside the single `turbo-frame#game`, so there is no page render to hang an |
|
|
|
animation off. Two mechanisms cover it: a class the outgoing screen puts on itself *before* |
|
|
|
navigating, and a view transition wrapped around the frame render. |
|
|
|
|
|
|
|
**All of it is gated to `(max-width: 1023.98px) and (prefers-reduced-motion: no-preference)`, in |
|
|
|
the CSS and again in JS** (`TRANSITION_MEDIA` in `application.js`, `MOTION` in |
|
|
|
`answer_controller.js`). Desktop and reduced-motion get the plain instant swap, so a new |
|
|
|
transition needs both halves of the gate or the two disagree — a JS-only gate leaves the |
|
|
|
browser's default cross-fade running on desktop. |
|
|
|
|
|
|
|
### Exits |
|
|
|
|
|
|
|
`start_controller#exit` and `answer_controller#exit` add `is-exiting` to `<main>` when the CTA is |
|
|
|
tapped. The start screen animates while the facts modal is being fetched — the modal waits for the |
|
|
|
outgoing wave before it opens — and the answer screen holds its own navigation until the video is |
|
|
|
nearly over. The pattern is always the same: measure the block that has to leave, publish its |
|
|
|
height as a custom property, and let a negative margin free the space while a transform carries |
|
|
|
the block off-screen — `--exit-shift` for the answers container, `--header-shift` for the stage |
|
|
|
header (`is-finishing`, used only on the way to the results, where the video takes the whole |
|
|
|
screen). The hero is `flex: 1`, so it grows into whatever the margin |
|
|
|
releases. |
|
|
|
|
|
|
|
### Stage videos |
|
|
|
|
|
|
|
Every stage node carries a video attachment beside its image. `stage_result` and `done` render it |
|
|
|
in the hero *behind* the copy at `opacity: 0`; the exit fades it up as the result copy slides away. |
|
|
|
`answer_controller` then holds the navigation until `timeupdate` reports less than `--dur-base` |
|
|
|
remaining, so the next screen assembles over the video's tail rather than after it — `ended` and a |
|
|
|
rejected `play()` are the fallbacks, and a stage with no video just navigates. The intro video is |
|
|
|
warmed up ahead of time by `_preload_intro`, a 1px `preload="auto"` element on the start and facts |
|
|
|
screens (the facts modal is appended over the start page, so that copy keeps buffering while the |
|
|
|
player reads). |
|
|
|
|
|
|
|
**Gotcha:** the base `.hero-container:has(video)` rule (white text, shifted `h2`) is scoped to |
|
|
|
`.intro`. Unscoped, it silently restyles every result screen the moment a stage gets a video. |
|
|
|
|
|
|
|
### Transitions |
|
|
|
|
|
|
|
`SCREEN_TRANSITIONS` in `application.js` maps `"<from>><to>"` — the template class the layout puts |
|
|
|
on `<main>` — to a transition name. A match overrides Turbo's `event.detail.render` so the frame |
|
|
|
swap runs inside `document.startViewTransition()`, with the name on `html[data-screen-transition]` |
|
|
|
for the CSS to key off. The current set: `slide-left` (whole screen from the right), `slide-up` |
|
|
|
(whole screen from the bottom, into the results), `answer` (hero cross-fade, options sink, result |
|
|
|
copy rises) and `stage-in` (header updates in place, video cross-fades to the stage image, answers |
|
|
|
slide up). |
|
|
|
|
|
|
|
Anything that must animate *after* the snapshots are gone hangs off `.is-settling`, added to the |
|
|
|
new `<main>` when the transition finishes. Both waves use it. |
|
|
|
|
|
|
|
**Gotcha:** a `view-transition-name` on a descendant cuts it out of its ancestor's snapshot, so |
|
|
|
the ancestor arrives with a transparent hole where it used to be. That is why the waves are hidden |
|
|
|
during the transition and animated afterwards on the real element instead of being named. |
|
|
|
|
|
|
|
**Gotcha:** anything that overflows during a transition gives the scroller a scrollbar, and a |
|
|
|
scrollbar shrinks `clientHeight`. A wave sliding in from off-screen right did exactly that to the |
|
|
|
facts dialog and left a strip of panel showing under the hero. Clip the container (`.carousel-frame`, |
|
|
|
`.hero-container` while settling) and measure from `getBoundingClientRect()`, not `clientHeight`. |
|
|
|
|
|
|
|
**Gotcha:** `main.results` paints no background of its own — `body:has(.results)` does — so its |
|
|
|
snapshot is transparent wherever a child does not paint. It gets an explicit background for the |
|
|
|
duration of `slide-up`, otherwise the video behind shows straight through it. |
|
|
|
|
|
|
|
**Gotcha:** `screen-slide-up` travels `100dvh`, not `100%`. The results page is taller than the |
|
|
|
viewport, so `100%` would start it a page-and-a-half down and travel that whole distance. |
|
|
|
|
|
|
|
**Gotcha:** the results wave is `rotate: 180deg`, and `transform` composes *after* `rotate` — a |
|
|
|
transform-based slide arrives from the wrong side. `wave-slide-in` therefore animates the |
|
|
|
`translate` property, which is applied before the rotation and means the same thing on every wave. |
|
|
|
|
|
|
|
### The facts modal |
|
|
|
|
|
|
|
`start.turbo_stream.erb` appends the dialog to `body#page`, **outside** the frame, so the intro can |
|
|
|
render behind it and the panel can then slide down to reveal it (`modal#leave`). Its CTA needs an |
|
|
|
explicit `data-turbo-frame="game"` for that reason. `modal_controller#settle` waits on one |
|
|
|
element's own transition — it filters out events from descendants and from `::backdrop`, either of |
|
|
|
which would otherwise end the wait early. |
|
|
|
|
|
|
|
The carousel wave is a single overlay in `.carousel-frame`, not one per slide, so it stays put |
|
|
|
while the slides scroll under it. It is positioned from `--slide-copy-height`, which |
|
|
|
`carousel_controller#equalizeHeights` publishes when it levels the slides. |
|
|
|
|
|
|
|
## Development shortcuts |
|
|
|
|
|
|
|
`DevJump` (`app/controllers/concerns/dev_jump.rb`, development only) opens any screen directly |
|
|
|
instead of playing through: it creates a player on demand and back-fills the progress the screen |
|
|
|
expects, scoring it the way the real answer action would. `?dev=good|bad|chance|chance_bad` starts |
|
|
|
a fresh player and answers the whole run that way; without it an existing session player is kept |
|
|
|
and only the gaps are filled, so a jump can be played on from. `?dev_last_save=1` also answers the |
|
|
|
last-save question. `/:locale` always clears the player, so the start screen looks like a first |
|
|
|
visit. The ⚡ panel bottom-left (`shared/_dev_jump`) links every screen; its links are `_top` full |
|
|
|
page loads so entrance animations replay from scratch. |
|
|
|
|
|
|
|
## Project Structure |
|
|
|
|
|
|
|
- `app/controllers/admin/`: Admin backend logic. |
|
|
|
@ -136,6 +233,9 @@ flips the button state immediately and does not await the response. |
|
|
|
- `app/helpers/game_helper.rb`: Result bands and per-impact tone thresholds. |
|
|
|
- `app/services/game_analytics.rb`: Aggregates the admin analytics dashboard. |
|
|
|
- `app/controllers/admin/analytics_controller.rb`: Admin analytics dashboard. |
|
|
|
- `app/controllers/concerns/dev_jump.rb`: Development-only screen jumping. |
|
|
|
- `app/javascript/`: Stimulus controllers for the game, flat (not under `controllers/`), each |
|
|
|
registered in `application.js`, `config/importmap.rb` and the layout's importmap tag list. |
|
|
|
- `app/models/concerns/`: Shared logic for ancestry, attachments, and tags. |
|
|
|
- `config/locales/`: YAML translation files. |
|
|
|
- `config/question_scores.json`: Answer scoring table. |