Mattias Bodlund 3 weeks ago
parent
commit
2dda1b2adc
1 changed files with 100 additions and 0 deletions
  1. +100
    -0
      AGENTS.md

+ 100
- 0
AGENTS.md View File

@ -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. - **Answer:** Records the player's selection; for `chance`, an outcome child is sampled.
- **Stage result:** Shows the outcome node and applies its score. - **Stage result:** Shows the outcome node and applies its score.
- **Last save / Results:** Final compost-vs-landfill choice, then the scored results screen. - **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. - **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, - **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 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 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. 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 ## Project Structure
- `app/controllers/admin/`: Admin backend logic. - `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/helpers/game_helper.rb`: Result bands and per-impact tone thresholds.
- `app/services/game_analytics.rb`: Aggregates the admin analytics dashboard. - `app/services/game_analytics.rb`: Aggregates the admin analytics dashboard.
- `app/controllers/admin/analytics_controller.rb`: 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. - `app/models/concerns/`: Shared logic for ancestry, attachments, and tags.
- `config/locales/`: YAML translation files. - `config/locales/`: YAML translation files.
- `config/question_scores.json`: Answer scoring table. - `config/question_scores.json`: Answer scoring table.

Loading…
Cancel
Save