Skip to content

Modern Workflow Tutorial

With ESM Modules

Step 1: HTML files

First create an index.html file and a styles.css file. Here we set up the css and add a <script> including the experiment.js file we are about to make.

index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/lightbulb.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Experiment</title>

    <!-- Resets styles to be same across browsers -->
    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@unocss/reset/tailwind.min.css"
    />
    <!-- Styles for jspsych plugins -->
    <link
      href="https://unpkg.com/jspsych@7.3.2/css/jspsych.css"
      rel="stylesheet"
      type="text/css"
    />
    <!-- Our own basic styles -->
    <link rel="stylesheet" href="styles.css" />
    <!-- Allows the use of tailwind syntax -->
    <script src="https://cdn.jsdelivr.net/npm/@unocss/runtime/uno.global.js"></script>

    <script type="module" src="./experiment.js"></script>
  </head>
  <body></body>
</html>

These are basic styles that most experiments won't need to modify.

styles.css
input,
textarea,
select {
  border-radius: 0.25rem;
  border-width: 1px;
  padding-left: 0.5rem;
  padding-right: 0.5rem;
}
input,
select {
  height: 2.5rem;
}

Manually assign conditions

If you would like a page to assign and verify experiment parameters, here are template begin.html and check.html files which can be modified as desired. You would start the experiment at /begin.html, which leads to /check.html, which directs to /index.html.

begin.html
<!DOCTYPE html>
  <html lang="en">
    <head>
      <meta charset="UTF-8" />
      <link rel="icon" type="image/svg+xml" href="/lightbulb.svg" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      <title>Begin</title>
      <link
        rel="stylesheet"
        href="https://cdn.jsdelivr.net/npm/@unocss/reset/tailwind-compat.min.css"
      />
      <link rel="stylesheet" href="styles.css" />
      <script src="https://cdn.jsdelivr.net/npm/@unocss/runtime/uno.global.js"></script>
    </head>
    <body>
      <div class="max-w-screen-sm mx-auto mt-20 rounded overflow-clip shadow-xl">
        <h1 class="bg-blue-600 text-white text-2xl font-semibold p-6">
          Enter Subject Information
        </h1>
        <form class="p-6" action="check.html" method="get">
          <div id="fields" class="space-y-4"></div>
          <button
            type="submit"
            class="bg-blue-500 text-white text-xl px-6 py-2 rounded-sm block mx-auto mt-6"
          >
            Start
          </button>
        </form>
      </div>
      <script>
        const formFields = {
          subNum: {
            type: "input",
            label: "Subject Number",
            placeholder: "e.g. 1234",
          },
          session: {
            type: "select",
            label: "Session",
            options: ["Standard", "Expanding", "Test"],
          },
          set: {
            type: "select",
            label: "Set",
            options: ["A", "B"],
          },
          condition: {
            type: "select",
            label: "Condition",
            options: ["Retrieval", "Study", "Recall"],
          },
        };

        const fields = document.getElementById("fields");
        Object.entries(formFields).forEach(([key, value]) => {
          const group = document.createElement("div");
          if (value.label) {
            const label = document.createElement("label");
            label.htmlFor = key;
            label.textContent = value.label;
            label.className = "inline-block mb-1";

            group.append(label);
          }

          switch (value.type) {
            case "select": {
              const select = document.createElement("select");
              select.id = key;
              select.name = key;
              select.className = "w-full";

              value.options.forEach((opt) => {
                const option = document.createElement("option");
                option.value = opt;
                option.textContent = opt;

                select.append(option);
              });

              group.append(select);
              break;
            }
            case "input": {
              const input = document.createElement("input");
              input.type = "text";
              input.placeholder = value.placeholder;
              input.id = key;
              input.name = key;
              input.className = "w-full";

              group.append(input);
            }
          }

          fields.append(group);
        });
      </script>

    </body>

  </html>
check.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/lightbulb.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Check</title>
    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@unocss/reset/tailwind-compat.min.css"
    />
    <link rel="stylesheet" href="styles.css" />
    <script src="https://cdn.jsdelivr.net/npm/@unocss/runtime/uno.global.js"></script>
    <style>
      tbody > :nth-child(odd) {
        background-color: rgb(243 244 246);
      }
    </style>
  </head>
  <body>
    <div class="max-w-screen-sm mx-auto mt-20 rounded overflow-clip shadow-xl">
      <h1 class="bg-yellow-600 text-white text-2xl font-semibold p-6">Check</h1>
      <div class="p-6 space-y-4">
        <p>
          Make sure that the subject information is correct. <br />If not, click
          "Back" and re-enter the information.
        </p>
        <table class="w-full text-left text-xl leading-10">
          <thead>
            <tr>
              <th>Parameter</th>
              <th>Value</th>
            </tr>
          </thead>
          <tbody id="table-body"></tbody>
        </table>

        <div class="mt-6 text-center space-x-4">
          <button
            id="backBtn"
            class="bg-cyan-500 text-white text-xl px-6 py-2 rounded-sm"
          >
            Back
          </button>
          <button
            id="nextBtn"
            class="bg-blue-500 text-white text-xl px-6 py-2 rounded-sm"
          >
            Next
          </button>
        </div>
      </div>
    </div>
    <script>
      const urlParams = new URLSearchParams(window.location.search);

      const tbody = document.getElementById("table-body");
      urlParams.forEach((value, key) => {
        const tr = document.createElement("tr");

        const keyTd = document.createElement("td");
        keyTd.textContent = key;
        const valueTd = document.createElement("td");
        valueTd.textContent = value;

        tr.append(keyTd, valueTd);
        tbody.append(tr);
      });

      const backBtn = document.getElementById("backBtn");
      const nextBtn = document.getElementById("nextBtn");
      backBtn.addEventListener("click", () =>
        window.location.assign("./begin.html")
      );
      nextBtn.addEventListener("click", () =>
        window.location.assign(`./${window.location.search}`)
      );
    </script>
  </body>
</html>

Step 2: Adding trials and using plugins

It's time to add new trials. First we have to import the desired plugins. We will user @jspsych/plugin-instructions to show instructions and @pcllab/plugin-free-recall to show a free recall trial.

Documentation for @pcllab/plugins are on Github. Take a look at @pcllab/plugin-free-recall.

Documentation for @jspsych plugins are on the jsPsych website. Take a look at @jspsych/plugin-instructions.

experiment.ts
import { initJsPsych } from "https://esm.sh/jspsych";
import pcllabFreeRecall from "https://esm.sh/@pcllab/plugin-free-recall";
import pcllabAudioResponse from "https://esm.sh/@pcllab/plugin-audio-response";

const jsPsych = initJsPsych();

const timeline = [];

timeline.push({
  type: jsPsychInstructions,
  pages: [
    "Welcome to the experiment. Click next to begin.",
    "This is the second page of instructions.",
    "This is the final page.",
  ],
  show_clickable_nav: true,
});

timeline.push({
  type: pcllabFreeRecall,
});

jsPsych.run(timeline);

Step 3: Testing experiment

While you can just open the index.html file, loading files from local folders (like images or audio), will not work because the browser does not allow file system access.

You have to serve the files from a local server. If you have Node.js installed, you can run the following command to get the experiment running and available at http://127.0.0.1:8080.

> /sample-exp
npx http-server -c-1

Other methods of setting up a local server are available.

Step 4: Saving data to Jarvis

Add an on_finish callback to send the data to a specific endpoint.

//...

const jsPsych = initJsPsych({
  on_finish: () => {
    fetch("DATA_ENDPOINT_HERE", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: jsPsych.data.get().json(),
    });
  },
});

//...

Step 5: Uploading experiment to Jarvis

Place all files into the desired destination on Jarvis.

Final Code

index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/lightbulb.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Experiment</title>
    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@unocss/reset/tailwind.min.css"
    />
    <link
      href="https://unpkg.com/jspsych@7.3.2/css/jspsych.css"
      rel="stylesheet"
      type="text/css"
    />
    <link rel="stylesheet" href="styles.css" />
    <script src="https://cdn.jsdelivr.net/npm/@unocss/runtime/uno.global.js"></script>

    <script type="module" src="./experiment.js"></script>
  </head>
  <body></body>
</html>
experiment.js
import { initJsPsych } from "https://esm.sh/jspsych";
import pcllabFreeRecall from "https://esm.sh/@pcllab/plugin-free-recall";
import pcllabAudioResponse from "https://esm.sh/@pcllab/plugin-audio-response";

const jsPsych = initJsPsych({
  on_finish: async () => {
    console.log(jsPsych.data.get().json());
    await fetch(
      "https://jarvis.psych.purdue.edu/beta/api/v2/experiments/61fa32135db16e3e8a9ae5fb/data",
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: jsPsych.data.get().json(),
      }
    );
  },
});

const timeline = [];

timeline.push({
  type: jsPsychInstructions,
  pages: [
    "Welcome to the experiment. Click next to begin.",
    "This is the second page of instructions.",
    "This is the final page.",
  ],
  show_clickable_nav: true,
});

timeline.push({
  type: pcllabFreeRecall,
});

jsPsych.run(timeline);

Script Tags

It is also possible to load plugins using <script> tags. This is less convenient, but still an available option.

These need to be added after the jspsych script and before the experiment script, because scripts are loaded in order. While @jspsych/plugin-instructions can be added like so...

<!-- below jspsych-->

<script src="https://unpkg.com/@jspsych/plugin-instructions"></script>

<!-- above experiment.js -->

Plugins like @pcllab/plugin-free-recall have a dependency on react so it must be included as well. Adding the plugins and their dependencies looks like this.

Final Code

index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/lightbulb.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Experiment</title>
    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@unocss/reset/tailwind.min.css"
    />
    <link
      href="https://unpkg.com/jspsych@7.3.2/css/jspsych.css"
      rel="stylesheet"
      type="text/css"
    />
    <link rel="stylesheet" href="styles.css" />
    <script src="https://cdn.jsdelivr.net/npm/@unocss/runtime/uno.global.js"></script>

    <script src="https://unpkg.com/jspsych@7.3.2"></script>
    <script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
    <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
    <script src="https://unpkg.com/@pcllab/plugin-audio-response"></script>
    <script src="https://unpkg.com/@pcllab/plugin-free-recall"></script>

    <script src="./experiment.js"></script>
  </head>
  <body></body>
</html>
experiment.js
const jsPsych = initJsPsych({
  on_finish: async () => {
    console.log(jsPsych.data.get().json());
    await fetch(
      "https://jarvis.psych.purdue.edu/beta/api/v2/experiments/61fa32135db16e3e8a9ae5fb/data",
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: jsPsych.data.get().json(),
      }
    );
  },
});

const timeline = [];

timeline.push({
  type: jsPsychInstructions,
  pages: [
    "Welcome to the experiment. Click next to begin.",
    "This is the second page of instructions.",
    "This is the final page.",
  ],
  show_clickable_nav: true,
});

timeline.push({
  type: pcllabFreeRecall,
});

jsPsych.run(timeline);

With Build Step

Requirements:

  • Node.js

Step 1: Initialize experiment files

Here we are using the pcllab/create-exp tool to quickly scaffold a new experiment.

Open a terminal where you want to create the experiment. If you are using VSCode, press Ctrl+J to open a terminal in the current directory.

Terminal
# Create a new folder
npm create @pcllab/exp

# Create experiment in current folder
npm create @pcllab/exp .

Manually assign conditions

Terminal
Include pages to manually assign conditions before the experiment? (y/N)

If you would like a page to assign and verify experiment parameters, this option will generate those files. You would start the experiment at /begin.html, which leads to /check.html, which directs to /index.html. begin.html can be modified to have the desired form fields.

Follow the instructions and finish setup by installing the dependencies.

Terminal > /sample-experiment
# If a folder was created, cd into the directory
cd sample-exp

npm install

Finally, you should have a file structure that looks like this.

📂 sample-exp
--  📂 node_modules
--  📂 public
--  📂 src
----  📄 experiment.js
----  📄 style.css
--  📄 index.html
--  📄 package.json
--  📄 postcss.config.cjs
--  📄 tailwind.config.cjs
--  📄 tsconfig.json

Step 2: Adding trials and using plugins

It's time to add new trials. First we have to install the desired plugins. We will install @jspsych/plugin-instructions to show instructions and @pcllab/plugin-free-recall to show a free recall trial.

With this modern workflow, we can install plugins from the terminal. These plugins are hosted on npm, a registry for javascript packages.

> /sample-experiment
# we can use official jsPsych plugins
npm i @jspsych/plugin-instructions

# we can also have our own custom developed plugins
npm i @pcllab/plugin-free-recall
Using Local Plugins

It is still possible to use plugins located in a local folder. In that case, make sure to import from the relative path. It might look something like this.

import jsPsychInstructions from "../plugins/plugin-instructions";

This makes it harder to maintain plugin versions, but use it if you need to.

Add the plugin imports at the top of the file.

experiment.js
import jsPsychInstructions from "@jspsych/plugin-instructions";

import pcllabFreeRecall from "@pcllab/plugin-free-recall";

Documentation for @pcllab/plugins are on Github. Take a look at @pcllab/plugin-free-recall.

Documentation for @jspsych plugins are on the jsPsych website. Take a look at @jspsych/plugin-instructions.

experiment.ts
const jsPsych = initJsPsych();

const timeline = [];

timeline.push({
  type: jsPsychInstructions,
  pages: [
    "Welcome to the experiment. Click next to begin.",
    "This is the second page of instructions.",
    "This is the final page.",
  ],
  show_clickable_nav: true,
});

timeline.push({
  type: pcllabFreeRecall,
});

jsPsych.run(timeline);

Step 3: Testing experiment

> /sample-exp
npm dev

Head to http://localhost:5173/ to run through your experiment.

While this is running, any change you make to your experiment will cause the page at http://localhost:5173/ to reload with your changes!

To stop the development server, press Ctrl+C (on MacOS as well!) in the terminal where you ran the original command. You can also just close the terminal if you prefer the nuclear approach.

Step 4 Saving data to Jarvis

Add an on_finish callback to send the data to a specific endpoint.

//...

const jsPsych = initJsPsych({
  on_finish: () => {
    fetch("DATA_ENDPOINT_HERE", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: jsPsych.data.get().json(),
    });
  },
});

//...

Step 5: Uploading experiment to Jarvis

The files need to be built into their final form. This transpiles Typescript into extremely terse Javascript and puts all the dependencies into fewer files.

> /sample-exp
npm build

Then place all files in the dist folder into the desired destination on Jarvis.

Final Demo Code

Here is the final code.

experiment.ts
import "jspsych/css/jspsych.css";
import "./style.css";

import { initJsPsych } from "jspsych";
import jsPsychInstructions from "@jspsych/plugin-instructions";
import pcllabFreeRecall from "@pcllab/plugin-free-recall";

const jsPsych = initJsPsych({
  on_finish: async () => {
    console.log(jsPsych.data.get().json());
    await fetch(
      "https://jarvis.psych.purdue.edu/beta/api/v2/experiments/61fa32135db16e3e8a9ae5fb/data",
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: jsPsych.data.get().json(),
      }
    );
  },
});

const timeline = [];

timeline.push({
  type: jsPsychInstructions,
  pages: [
    "Welcome to the experiment. Click next to begin.",
    "This is the second page of instructions.",
    "This is the final page.",
  ],
  show_clickable_nav: true,
});

timeline.push({
  type: pcllabFreeRecall,
});

jsPsych.run(timeline);