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, RandomOrder(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 (see Placing the buttons yourself) |
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, data), or any other app-level function. In those places, construct the context explicitly:
See PlayerContext in the public_goods_game example · trust_game 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.
Back leads to the nearest earlier page that is shown: pages whose show is False are passed over. Back never crosses the start of an app, a round of Rounds or an iteration of Repeat, because player.app and player.round would not go back with the participant. If there is no such page, or allow_back is False on the current page, the participant stays where they are. In particular, a “Back” button on the first page of an app does nothing.
Going back does not ask may_proceed, and it saves nothing from the page that the participant leaves. Back checks show to select the destination before running its entry hooks. It then enters and displays that page without checking show again, even if an entry hook changes its visibility. If the participant has not seen it before (for example, because it was not shown when they first passed it), its before_once runs. After a page’s timeout has expired, Back counts as a timed-out submission: the participant moves on, not back.
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.
Placing the buttons yourself¶
uproot puts a “Next” button (and a “Back” button, if allowed) below your page content. To put them elsewhere, or to style or relabel them, call button_next() and button_back() in your template:
{% extends "Base.html" %}
{% block main %}
<p>Please read the consent form below.</p>
<div class="d-flex justify-content-center gap-3 my-5">
{{ button_back() }}
{{ button_next(label="I agree", class_="btn btn-success btn-lg") }}
</div>
{% endblock main %}
As soon as you place either button, uproot no longer adds the default buttons. Both helpers accept HTML attributes as keyword arguments. Write class_ (with an underscore) for the class attribute, because class is a reserved word in Python. label replaces the button text. button_back() renders nothing on pages without allow_back.
Submitting from JavaScript¶
Sometimes the page should only be submitted after your own JavaScript has run, for example after a confirmation dialog. Make the button an ordinary button with type="button", so that clicking it does not submit the page, and call uproot.submit() when you are ready:
{{ button_next(type="button", id="review", label="Submit") }}
<script>
document.getElementById("review").addEventListener("click", () => {
if (confirm("Submit your answers? You cannot change them later.")) {
uproot.submit();
}
});
</script>
uproot.submit() sends the page’s form fields like a click on “Next” would. Form inputs without a name attribute are never sent.
Pages without buttons¶
To show no buttons at all, set buttons to False at the top of the template:
Participants then stay on this page until something else moves them on, such as a timeout, live method, or the admin. If you put such a page last, participants never reach the end page. This can be what you want, but the admin’s session monitor then shows them on that page rather than on End.html.
See the button_placement example
The end page¶
After the last page in the page order, participants see uproot’s end page: “You have reached the end of this study. You may now close this page.” To add your own content below this text, create a file named EndInfo.html:
EndInfo.html is included in the end page, so it must not extend Base.html. You can use player and the other built-in template variables. uproot looks for the file in the app of the participant’s last page first, then in the project root.
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>
Do not paste template variables into scripts
It is tempting to write const nickname = "{{ player.nickname }}"; instead. This breaks as soon as a value contains a quotation mark, a backslash, or a line break: the participant’s browser then sees a different value, or the whole script fails. jsvars converts values to JSON, which keeps strings, numbers, lists, and dictionaries intact.
jsvars can only pass values that JSON knows: strings, numbers, Booleans, None, lists, tuples, and dictionaries. Money amounts are a common exception. player.payoff and values created with cu are Decimal numbers, and returning one from jsvars fails with TypeError: Type is not JSON serializable: decimal.Decimal. Convert it first: str(player.payoff) keeps the exact value, while float(player.payoff) gives a number you can calculate with in JavaScript.
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