JavaScript Framework for Psychology tasks. Make psychology task development like making PPTs. Compatible with the jsPsych plugins.
Compared to jsPsych, PsyTask has:
via NPM:
npm create psytask # create a project
npm i psytask # only install
via CDN:
<!-- add required packages -->
<script type="importmap">
{
"imports": {
"psytask": "https://cdn.jsdelivr.net/npm/psytask@1/dist/index.min.js",
"@psytask/core": "https://cdn.jsdelivr.net/npm/@psytask/core@1/dist/index.min.js",
"@psytask/components": "https://cdn.jsdelivr.net/npm/@psytask/components@1/dist/index.min.js",
"vanjs-core": "https://cdn.jsdelivr.net/npm/vanjs-core@1.6",
"vanjs-ext": "https://cdn.jsdelivr.net/npm/vanjs-ext@0.6"
}
}
</script>
<!-- load packages -->
<script type="module">
import { createApp } from 'psytask';
using app = await creaeApp();
</script>
PsyTask uses the modern JavaScript using keyword for automatic resource cleanup.
For CDN usage in old browsers that don't support the using keyword, you will see Uncaught SyntaxError: Unexpected identifier 'app'. You need to change the code:
// Instead of: using app = await createApp();
const app = await createApp();
// ... your code ...
app.emit('dispose'); // Manually clean up when done
Or, you can use the bundlers (like Vite, Bun, etc.) to transpile it.
The psychology tasks are just like PPTs, they both have a series of scenes. So writing a psychology task only requires 2 steps:
import { Container } from '@psytask/components';
using simpleText = app.scene(
// scene setup
Container,
// scene options
{
defaultProps: { content: '' }, // props is show params
duration: 1e3, // show 1000ms
close_on: 'key: ', // close on space key
},
);
Most of the time, you need to write the scene yourself, see setup scene:
using scene = app.scene(
/** @param {{ text: string }} props */
(props, ctx) => {
/** @type {{ response_key: string; response_time: number }} */
let data;
const node = document.createElement('div');
ctx
.on('scene:show', (newProps) => {
// Reset data when the scene shows
data = { response_key: '', response_time: 0 };
// update DOM
node.textContent = newProps.text;
})
// Capture keyboard responses
.on('key:f', (e) => {
data = { response_key: e.key, response_time: e.timeStamp };
ctx.close(); // close scene when key f was pressed
})
.on('key:j', (e) => {
data = { response_key: e.key, response_time: e.timeStamp };
ctx.close();
});
// Return the element and data getter
return {
// use other Component
node: Container({ content: node }, ctx),
// data getter
data: () => data,
};
},
{
defaultProps: { text: '' }, // same with setup params
duration: 1e3,
close_on: 'mouse:left',
},
);
use JSDoc Comment to get type hint in JavaScript.
// show with parameters
const data = await scene.show({ text: 'Press F or J' });
// show with new scene options
const data = await scene.config({ duration: Math.random() * 1e3 }).show();
Usually, we need to show a block:
import { RandomSampling, StairCase } from 'psytask';
// fixed sequence
for (const text of ['A', 'B', 'C']) {
await scene.show({ text });
}
// random sequence
for (const text of RandomSampling({
candidates: ['A', 'B', 'C'],
sample: 10,
replace: true,
})) {
await scene.show({ text });
}
// staircase
const staircase = StairCase({
start: 10,
step: 1,
up: 3,
down: 1,
reversals: 6,
min: 1,
max: 12,
trial: 20,
});
for (const value of staircase) {
const data = await scene.show({ text: value });
const correct = data.response_key === 'f';
staircase.response(correct); // set response
}
using dc = app.collector('data.csv');
for (const text of ['A', 'B', 'C']) {
const data = await scene.show({ text });
// add a row
dc.add({
text,
response: data.response_key,
rt: data.response_time - data.start_time,
correct: data.response_key === 'f',
});
}
dc.final(); // get final text
dc.download(); // download file
Add packages:
npm i @psytask/jspsych @jspsych/plugin-cloze
npm i -d jspsych # for type hint
Or using CDN:
<!-- load jspsych css-->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/jspsych@8.2.2/css/jspsych.css"
/>
<!-- add packages -->
<script type="importmap">
{
"imports": {
...
"@psytask/jspsych": "https://cdn.jsdelivr.net/npm/@psytask/jspsych@1/dist/index.min.js",
"@jspsych/plugin-cloze": "https://cdn.jsdelivr.net/npm/@jspsych/plugin-cloze@2.2.0/+esm"
}
}
</script>
For CDNer, you should add the +esm after the jspsych plugin CDN URL, because jspsych plugins do not release ESM versions.
Then use it:
import { jsPsychStim } from '@psytask/jspsych';
import Cloze from '@jspsych/plugin-cloze';
using jspsych = app.scene(jsPsychStim, { defaultProps: {} });
const data = await jspsych.show({
type: Cloze,
text: 'aba%%aba',
check_answers: true,
});
See official docs
<script src="jatos.js"></script>
// wait for jatos loading
await new Promise((r) => jatos.onLoad(r));
using dc = app.collector().on('add', (row) => {
// send data to JATOS server
jatos.appendResultData(row);
});
So, how it works.
To create a scene, we need a setup function that inputs Props and Context, and outputs a object includes Node and Data Getter:
const setup = (props, ctx) => ({
node: '',
data: () => ({}),
});
using scene = app.scene(setup);
If you don't want to generate any data, just return Node:
const setup = (props, ctx) => '';
You shouldn't modify props, as it may change the default props.
If you don't know whether you modify the default props, try to recursively freeze all its properties:
const recurFreeze =
/**
* @template {object} T
* @param {T} obj
* @returns {T}
*/
(obj) => {
for (const v of Object.values(obj))
v != null && typeof v === 'object' && recurFreeze(v);
return Object.freeze(obj);
};
const createProps =
/**
* @template {object} T
* @param {T} obj
* @returns {T}
*/
(obj) => Object.create(recurFreeze(obj));
using scene = app.scene(
/**
* @param {{
* a: string;
* b: number[];
* c: { d: string[] };
* }} props
*/
(props) => '',
{
defaultProps: createProps({
a: '',
b: [],
c: { d: [] },
}),
},
);
When you create a scene, the setup function will be called with the default Props, then the Node will be mounted. So if you want to update Node in each show, you should listen scene:show event:
const setup = (props, ctx) => {
const node = document.createElement('div');
ctx.on('scene:show', (newProps) => {
node.textContent = newProps.text;
});
return node;
};
Or, you can use VanJS power via adapter, which provides reactivity update:
import { adapter } from '@psytask/components';
import van from 'vanjs-core';
const { div } = van.tags;
const setup = adapter((props, ctx) => div(() => props.text));
graph TD
scene:show --> b[show & focus root
add listeners to root] --> d[wait timer] --> scene:frame --> d --> e[hide root] --> scene:close
Stay tuned...