From 2dda1b2adc7bbbf07b1c1205918b5da9396e5051 Mon Sep 17 00:00:00 2001 From: Mattias Bodlund Date: Wed, 26 Aug 2026 16:31:17 +0200 Subject: [PATCH] na --- AGENTS.md | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 4e1c960..0585faf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `
` 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 `">"` — the template class the layout puts +on `
` — 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 `
` 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.