Pages and templates¶
Pages are the building blocks of uproot experiments. Each page represents a screen that participants see, for instance, instructions, questions, feedback, or results. Pages are defined as Python classes and rendered using HTML or Markdown templates.
Defining a page¶
A page is a class that inherits from Page:
This minimal page displays the template Welcome.html from your app directory. Every page needs a matching template: Welcome.html, or Welcome.md if you prefer Markdown (see Markdown pages).
The page_order list¶
The page_order list defines which pages participants see and in what sequence:
Participants progress through pages in order. You can use SmoothOperators to randomize, repeat, or conditionally select pages.
page_order can also be a callable that takes player as a keyword argument, letting you build the sequence dynamically per participant:
def page_order(player):
pages = [Instructions, Task]
if player.in_treatment:
pages.append(TreatmentPage)
pages.append(Results)
return pages
Nested lists are flattened automatically, so you can compose page sequences from reusable building blocks, including SmoothOperators:
def page_order(player):
warmup = [Instructions, Random(PracticeA, PracticeB)]
main_task = [Rounds(Decision, Feedback, n=5)]
return [warmup, main_task, Results]
Templates¶
Templates define what participants see. Most are HTML files; you can also write Markdown pages. uproot uses Jinja2 for templating.
Template naming and location¶
By default, uproot looks for a template matching the page class name:
my_app/
├── __init__.py # Contains class Welcome(Page)
└── Welcome.html # Template for Welcome page (or Welcome.md)
To use a custom template path:
Explicit template paths start at the project root, so include the app module name as shown.
Basic template structure¶
A typical template extends the base layout and defines content:
{% extends "Base.html" %}
{% block main %}
<h1>Welcome to the experiment</h1>
<p>Thank you for participating.</p>
{% endblock %}
The Base.html base template provides the form wrapper, navigation buttons, and styling. Your content goes in the main block.
Available blocks:
| Block | Position |
|---|---|
title |
Page title (shown in the browser tab and as the heading) |
head |
Extra content in <head> (CSS, meta tags) |
pre_main |
Before the main content, outside the Bootstrap container |
main |
Main page content (inside the container) |
main_full_width |
After main, full viewport width; use this for banners or charts that should break out of the container |
main2 |
A second container section after main_full_width |
late |
Extra content at the end of <body> (scripts) |
There are also main2_full_width, main3, late2, footer, header_start, and header_end if you need more slots.
Base template switches¶
The built-in templates also read a few optional Jinja variables. Set them near the top of a child template when you need to disable part of the default layout:
| Variable | Default | Effect when set to True |
|---|---|---|
buttons |
True |
Set to False to hide the default Back/Next buttons |
disable_bootstrap |
False |
Do not load uproot’s bundled Bootstrap CSS and JavaScript |
disable_uproot_fonts |
False |
Do not load uproot’s bundled web fonts |
disable_tabular_numbers |
False |
Do not load the tabular-number font stylesheet |
disable_terms |
False |
Do not load the terms script |
disable_auto_start |
False |
Do not run the default uproot.init() and WebSocket startup hook |
disable_connection_lost_modal |
False |
Do not show or enable the connection-lost modal |
For admin templates that extend the built-in admin layout, disable_navigation = True hides the admin navigation bar.
Adding a form¶
To collect data, include form fields in your template:
{% extends "Base.html" %}
{% block main %}
<h1>Your decision</h1>
{{ form.amount.label }}
{{ form.amount }}
{% endblock %}
See Collecting data with forms for details on defining form fields.
Passing data to templates¶
The templatevars method¶
Use the templatevars method to pass data from Python to your template:
class Results(Page):
@classmethod
def templatevars(page, player):
return dict(
earnings=player.payoff,
partner_choice=player.other_in_group.choice,
)
Then use these variables in your template:
{% extends "Base.html" %}
{% block main %}
<h1>Results</h1>
<p>You earned {{ earnings }} points.</p>
<p>Your partner chose: {{ partner_choice }}</p>
{% endblock %}
The templatevars method receives page (the page class) and player (the current participant’s data).
The PlayerContext class¶
Use PlayerContext when you want to compute the same value on several pages. Define a Context class in your app, then add a property for each value:
class Context(PlayerContext):
@property
def earnings(self):
return self.player.payoff
@property
def partner_choice(self):
return self.player.other_in_group.choice
Access these in templates:
<p>You earned {{ player.context.earnings }} points.</p>
<p>Your partner chose: {{ player.context.partner_choice }}</p>
You can use the same properties in Python page methods:
The self.player attribute gives the Context object access to the current participant. Store lasting data on self.player, self.player.group, or self.player.session—not on the Context object itself. Each access to player.context creates a lightweight Context object.
How uproot selects the Context class¶
player.context refers to the Context class of the app that the participant is currently using. uproot reads player.app, finds that app’s Context class, and constructs it with the player. If the current app does not define a Context class, player.context is None.
While the participant is inside an app that defines Context, these two expressions therefore produce the same result:
The second expression does not need to discover an app. Context is an ordinary Python name that refers directly to the class defined in the current app module. The constructor inherited from PlayerContext stores player as self.player.
Usually, prefer player.context: it is concise and automatically follows the participant’s current app. Use Context(player) when you are outside page execution (see the warning below) or when code must target a specific app’s Context class.
Do not use player.context outside page execution
player.context relies on player.app, which tracks whichever app the participant is currently in. Outside page methods and templates, that value is unreliable: it may be None (before the first app), or it may point to a different app entirely.
Do not use player.context in page_order(player), new_player(player), digest(session), pipeline(session), or any other app-level function. In those places, construct the context explicitly:
See PlayerContext in the prisoners_dilemma example · bertrand example
Built-in template variables¶
Every template has access to these variables:
| Variable | Description |
|---|---|
player |
The current participant’s data |
form |
The form instance (if the page has fields) |
page |
The page class |
C |
Constants defined in your app’s C class |
session |
The current session |
_("text") |
Translation function for internationalization |
Accessing player data¶
Using constants¶
Define constants in your app:
Use them in templates:
To also use constants in JavaScript (and Alpine.js), list their names on C.__export__:
uproot copies those values to window.C, so C.WORD_LENGTH works in scripts. Set __export__ = ... (Ellipsis) to export every non-dunder attribute.
See C.export in the encryption_task example · emoji_sort example
Static files¶
App-specific static files¶
Place static files (images, CSS, JavaScript) in an _static/ folder within your app:
Reference them using appstatic():
<img src="{{ appstatic('diagram.png') }}" alt="Diagram">
<link rel="stylesheet" href="{{ appstatic('custom.css') }}">
Project-wide static files¶
For files shared across apps, put them in _static/ at the project root and use projectstatic():
To inject HTML into every page, add ProjectHead.html (inside <head>) or ProjectBody.html (inside <body>) at the project root.
Markdown pages¶
If Welcome.html is missing, uproot looks for Welcome.md instead. Put exactly one level-one heading (#) in the file; uproot uses it as the page title and removes it from the main content.
You can still use Jinja. That is, fields, player, C, and the rest work as usual:
# Welcome
Thank you for participating. You start with {{ C.ENDOWMENT }} points.
{{ field(form.consent) }}
If the Markdown file name does not match the page class, set its project-root-relative path:
See the anchoring_markdown example
Translations¶
uproot’s built-in interface strings ship in English (en), French (fr), German (de), Spanish (es), and Japanese (ja). This includes buttons, wait pages, and error messages of forms. Set the default in main.py:
To choose a language per participant, define language(player) in the app. It should return an ISO 639 code:
In templates, _("text") looks up a translation for the current language. Wrap phrases in {% translate %}...{% endtranslate %} when you want the same lookup with whitespace collapsed. In JavaScript, _("text") works the same way.
To add your own phrases, put YAML files in a directory (one file per language, or one file with all languages) and load them at startup:
A file per language is named after its language code, such as locales/fr.yml. Each entry maps the phrase as written in your template to its translation:
? "Please answer the following questions:"
: "Veuillez répondre aux questions suivantes :"
? "Click “Next” when you are ready."
: "Cliquez sur « Suivant » lorsque vous êtes prêt(e)."
Your files can also translate uproot’s built-in strings, such as Next or Please wait. This lets you run studies in a language that uproot does not ship. The built-in strings are listed in uproot’s en.yml.
Keys must match exactly
uproot finds a translation only if the key matches the phrase in your template exactly, apart from whitespace. Straight quotes ("Next") and typographical quotes (“Next”) are different keys. If no translation matches, uproot silently shows the phrase itself, usually in English.
To find such problems, run uproot check-translations in your project directory:
It lists every phrase that is missing for a language that has a YAML file in your project, including the labels and choices of form fields. It also points out keys that differ only in quotes or spacing.
Conditional page display¶
Use the show method to conditionally display pages:
class BonusRound(Page):
@classmethod
def show(page, player):
return player.score >= 80 # Only show if score is high enough
Pages where show returns False are skipped automatically.
Role-based pages¶
A common pattern for multiplayer experiments:
class ProposerDecision(Page):
@classmethod
def show(page, player):
return player.role == "proposer"
class ResponderDecision(Page):
@classmethod
def show(page, player):
return player.role == "responder"
Allowing back navigation¶
By default, participants can only move forward. To allow going back:
This adds a “Back” button that lets participants revisit and change previous answers.
Set keep_values = True to pre-fill the form from values already stored on the player. This is useful when someone returns to a page and should see their previous answers.
Note
Going back re-displays the page but does not undo any data that was
already saved. Submission hooks like after_once do not run again.
NoshowPage for logic-only pages¶
Sometimes you need to run code without displaying anything to participants. Use NoshowPage:
class CalculatePayoffs(NoshowPage):
@classmethod
def after_always_once(page, player):
player.payoff = player.correct_answers * 10
NoshowPage runs its lifecycle methods but never renders a template. Use it for:
- Calculating scores or payoffs
- Initializing player data
- Setting up randomization
See NoshowPage in the big5 example
Page lifecycle methods¶
Pages have several methods that run at different points:
| Method | When it runs |
|---|---|
show |
Before displaying; return False to skip the page |
early |
Earliest hook when entering the page; has the HTTP request |
before_always_once |
Once when this page position is reached |
before_once |
Once per player, before first display |
templatevars |
Before rendering; return template variables |
after_once |
Once per player, after first submission |
after_always_once |
Once after this page position is submitted |
Example: one-time initialization¶
class Task(Page):
@classmethod
def before_once(page, player):
# Runs once when the player first sees this page
player.start_time = time()
Example: cleanup after submission¶
class Task(Page):
@classmethod
def after_always_once(page, player):
# Runs after this page position is submitted
player.attempts += 1
See Page methods reference for the complete list.
JavaScript variables¶
To pass data to JavaScript, use the jsvars method:
class TradingGame(Page):
@classmethod
def jsvars(page, player):
return dict(
initial_price=player.price,
max_trades=C.MAX_TRADES,
)
Access these in your template’s JavaScript:
<script>
const price = uproot.vars.initial_price;
const maxTrades = uproot.vars.max_trades;
</script>
Complete example¶
Here is a complete page with context, conditional display, and form handling:
class Offer(Page):
allow_back = True
fields = dict(
amount=IntegerField(
label="How much do you offer?",
min=0,
max=100,
),
)
@classmethod
def show(page, player):
return player.role == "proposer"
@classmethod
def templatevars(page, player):
return dict(
endowment=C.ENDOWMENT,
partner=player.other_in_group.name,
)
@classmethod
def after_once(page, player):
player.offer_made = True