diff --git a/components/blockTargetKind.js b/components/blockTargetKind.js
index a894a47d..f31445ba 100644
--- a/components/blockTargetKind.js
+++ b/components/blockTargetKind.js
@@ -15,6 +15,7 @@ export const switchKind = (targetKind, mappings) => {
rsvpReading,
movie,
vernier,
+ readlab,
} = mappings;
switch (targetKind) {
case "reading":
@@ -47,6 +48,9 @@ export const switchKind = (targetKind, mappings) => {
case "vernier":
safeExecuteFunc(vernier);
break;
+ case "readlab":
+ safeExecuteFunc(readlab);
+ break;
default:
break;
}
diff --git a/components/readlabBlock.js b/components/readlabBlock.js
new file mode 100644
index 00000000..02c3006f
--- /dev/null
+++ b/components/readlabBlock.js
@@ -0,0 +1,423 @@
+import { Scheduler } from "../psychojs/src/util";
+import { phrases } from "./i18n";
+import { thisExperimentInfo } from "./global";
+import { psychoJS } from "./globalPsychoJS";
+import {
+ loadRecruitmentServiceConfig,
+ recruitmentServiceData,
+} from "./recruitmentService";
+
+const READLAB_BASE_URL = "https://readlab.net/";
+const READLAB_CONFIG_DEFAULT = "config/sample-experiment.csv";
+const READLAB_BUTTON_ID = "readlab-continue-button";
+const READLAB_OVERLAY_ID = "readlab-overlay";
+
+const DEFAULT_TEXT_EN = {
+ heading: "Continue on ReadLab",
+ body:
+ "For this part of the experiment, you will be redirected to ReadLab " +
+ "(readlab.net) to complete a reading task. When you finish there, " +
+ "ReadLab will bring you back here automatically. Please do not close " +
+ "this tab. Click the button below to continue.",
+ button: "Continue to ReadLab",
+};
+
+const phraseOrFallback = (phraseName, language, fallback) => {
+ try {
+ if (
+ phrases &&
+ Object.prototype.hasOwnProperty.call(phrases, phraseName) &&
+ Object.prototype.hasOwnProperty.call(phrases[phraseName], language)
+ ) {
+ return phrases[phraseName][language];
+ }
+ if (
+ phrases &&
+ Object.prototype.hasOwnProperty.call(phrases, phraseName) &&
+ Object.prototype.hasOwnProperty.call(phrases[phraseName], "en")
+ ) {
+ return phrases[phraseName]["en"];
+ }
+ } catch (_) {}
+ return fallback;
+};
+
+const safeReadParam = (paramReader, name, blockOrCondition, fallback) => {
+ try {
+ if (!paramReader || typeof paramReader.read !== "function") return fallback;
+ const value = paramReader.read(name, blockOrCondition);
+ if (value === undefined || value === null || value === "") return fallback;
+ return value;
+ } catch (_) {
+ return fallback;
+ }
+};
+
+export const buildReadlabRedirectUrl = ({
+ config = READLAB_CONFIG_DEFAULT,
+ participantID = "",
+ returnUrl = window.location.href,
+} = {}) => {
+ const params = new URLSearchParams();
+ params.set("config", config);
+ params.set("participant", participantID || "");
+ const url = `${READLAB_BASE_URL}?${params.toString()}`;
+ return `${url}&redirectUrl=${encodeURIComponent(returnUrl)}`;
+};
+
+export const buildEasyEyesReturnUrl = ({
+ blockNumber,
+ experimentFilename,
+ participantID,
+ prolificParticipantID,
+ prolificStudyID,
+ prolificSessionID,
+ session,
+} = {}) => {
+ const u = new URL(window.location.href);
+ u.searchParams.set("EE_resume", "1");
+ if (typeof blockNumber === "number")
+ u.searchParams.set("EE_block", String(blockNumber));
+ if (experimentFilename) u.searchParams.set("EE_experiment", experimentFilename);
+ if (participantID) u.searchParams.set("EE_participant", participantID);
+ if (session) u.searchParams.set("EE_session", String(session));
+ if (prolificParticipantID)
+ u.searchParams.set("PROLIFIC_PID", prolificParticipantID);
+ if (prolificStudyID) u.searchParams.set("STUDY_ID", prolificStudyID);
+ if (prolificSessionID) u.searchParams.set("SESSION_ID", prolificSessionID);
+ u.searchParams.set("EE_t", String(Date.now()));
+ return u.toString();
+};
+
+const removeOverlay = () => {
+ const existing = document.getElementById(READLAB_OVERLAY_ID);
+ if (existing) existing.remove();
+};
+
+const renderReadlabOverlay = ({ heading, body, buttonLabel, onClick }) => {
+ removeOverlay();
+
+ const overlay = document.createElement("div");
+ overlay.id = READLAB_OVERLAY_ID;
+ Object.assign(overlay.style, {
+ position: "fixed",
+ inset: "0",
+ background: "#ffffff",
+ color: "#222",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ zIndex: "10000",
+ padding: "2rem",
+ fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
+ });
+
+ const card = document.createElement("div");
+ Object.assign(card.style, {
+ maxWidth: "640px",
+ width: "100%",
+ textAlign: "left",
+ lineHeight: "1.5",
+ });
+
+ const h1 = document.createElement("h1");
+ h1.textContent = heading;
+ Object.assign(h1.style, {
+ fontSize: "1.75rem",
+ margin: "0 0 1rem 0",
+ });
+
+ const p = document.createElement("p");
+ p.textContent = body;
+ Object.assign(p.style, {
+ fontSize: "1.1rem",
+ margin: "0 0 1.5rem 0",
+ });
+
+ const button = document.createElement("button");
+ button.id = READLAB_BUTTON_ID;
+ button.type = "button";
+ Object.assign(button.style, {
+ display: "inline-flex",
+ alignItems: "center",
+ gap: "0.5rem",
+ padding: "0.75rem 1.25rem",
+ fontSize: "1.05rem",
+ fontWeight: "600",
+ color: "#fff",
+ background: "#1a73e8",
+ border: "none",
+ borderRadius: "6px",
+ cursor: "pointer",
+ });
+ button.addEventListener("mouseenter", () => {
+ button.style.background = "#1557b0";
+ });
+ button.addEventListener("mouseleave", () => {
+ button.style.background = "#1a73e8";
+ });
+
+ const label = document.createElement("span");
+ label.textContent = buttonLabel;
+
+ // External-link SVG icon
+ const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ icon.setAttribute("width", "18");
+ icon.setAttribute("height", "18");
+ icon.setAttribute("viewBox", "0 0 24 24");
+ icon.setAttribute("fill", "none");
+ icon.setAttribute("stroke", "currentColor");
+ icon.setAttribute("stroke-width", "2");
+ icon.setAttribute("stroke-linecap", "round");
+ icon.setAttribute("stroke-linejoin", "round");
+ icon.setAttribute("aria-hidden", "true");
+ icon.innerHTML =
+ '' +
+ '' +
+ '';
+
+ button.appendChild(label);
+ button.appendChild(icon);
+ button.addEventListener("click", onClick);
+
+ card.appendChild(h1);
+ card.appendChild(p);
+ card.appendChild(button);
+ overlay.appendChild(card);
+ document.body.appendChild(overlay);
+};
+
+export const isReadlabResume = () => {
+ try {
+ const params = new URLSearchParams(window.location.search);
+ return params.get("EE_resume") === "1";
+ } catch (_) {
+ return false;
+ }
+};
+
+const restoreExperimentInfoFromResumeUrl = () => {
+ try {
+ const p = new URLSearchParams(window.location.search);
+ const eeParticipant = p.get("EE_participant");
+ if (eeParticipant) {
+ thisExperimentInfo.participant = eeParticipant;
+ thisExperimentInfo.EasyEyesID = eeParticipant;
+ thisExperimentInfo.PavloviaSessionID = eeParticipant;
+ }
+ const prolificPID = p.get("PROLIFIC_PID");
+ const studyID = p.get("STUDY_ID");
+ const sessionID = p.get("SESSION_ID");
+ if (prolificPID) thisExperimentInfo.ProlificParticipantID = prolificPID;
+ if (studyID) thisExperimentInfo.ProlificStudyID = studyID;
+ if (sessionID) thisExperimentInfo.ProlificSessionID = sessionID;
+ } catch (_) {}
+};
+
+const renderReadlabReturnEnding = ({
+ heading,
+ body,
+ buttonLabel,
+ buttonUrl,
+}) => {
+ removeOverlay();
+ const overlay = document.createElement("div");
+ overlay.id = READLAB_OVERLAY_ID;
+ Object.assign(overlay.style, {
+ position: "fixed",
+ inset: "0",
+ background: "#ffffff",
+ color: "#222",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ zIndex: "10000",
+ padding: "2rem",
+ fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
+ });
+
+ const card = document.createElement("div");
+ Object.assign(card.style, {
+ maxWidth: "640px",
+ width: "100%",
+ textAlign: "left",
+ lineHeight: "1.5",
+ });
+
+ const h1 = document.createElement("h1");
+ h1.textContent = heading;
+ Object.assign(h1.style, { fontSize: "1.75rem", margin: "0 0 1rem 0" });
+
+ const p = document.createElement("p");
+ p.textContent = body;
+ Object.assign(p.style, { fontSize: "1.1rem", margin: "0 0 1.5rem 0" });
+
+ card.appendChild(h1);
+ card.appendChild(p);
+
+ if (buttonUrl) {
+ const button = document.createElement("button");
+ button.id = READLAB_BUTTON_ID;
+ button.type = "button";
+ button.textContent = buttonLabel;
+ Object.assign(button.style, {
+ display: "inline-flex",
+ alignItems: "center",
+ gap: "0.5rem",
+ padding: "0.75rem 1.25rem",
+ fontSize: "1.05rem",
+ fontWeight: "600",
+ color: "#fff",
+ background: "#1a73e8",
+ border: "none",
+ borderRadius: "6px",
+ cursor: "pointer",
+ });
+ button.addEventListener("click", () => {
+ button.disabled = true;
+ window.location.href = buttonUrl;
+ });
+ card.appendChild(button);
+ }
+
+ overlay.appendChild(card);
+ document.body.appendChild(overlay);
+};
+
+export const showReadlabReturnEnding = async (language = "en") => {
+ restoreExperimentInfoFromResumeUrl();
+
+ // Make sure recruitment service config is loaded so we know whether to
+ // build a Prolific completion link.
+ try {
+ await loadRecruitmentServiceConfig();
+ } catch (_) {}
+
+ const heading = phraseOrFallback(
+ "EE_readlabReturnHeading",
+ language,
+ "Thank you!",
+ );
+ const body = phraseOrFallback(
+ "EE_readlabReturnBody",
+ language,
+ "Your responses have been recorded. The experiment is complete.",
+ );
+
+ let buttonLabel = "";
+ let buttonUrl = "";
+ if (
+ recruitmentServiceData &&
+ recruitmentServiceData.name === "Prolific" &&
+ recruitmentServiceData.url
+ ) {
+ buttonLabel = phraseOrFallback(
+ "EE_OKToTakeCompletionCodeToProlific",
+ language,
+ "Take me to Prolific to submit my completion code",
+ );
+ buttonUrl = recruitmentServiceData.url;
+ }
+
+ renderReadlabReturnEnding({ heading, body, buttonLabel, buttonUrl });
+};
+
+export const readlabBlockBegin = (snapshot, paramReader) => {
+ return async function () {
+ const blockN =
+ snapshot && typeof snapshot.block === "number" ? snapshot.block + 1 : 1;
+ const language = "en";
+
+ const config = safeReadParam(
+ paramReader,
+ "readlabConfig",
+ blockN,
+ READLAB_CONFIG_DEFAULT,
+ );
+ const customBody = safeReadParam(
+ paramReader,
+ "readlabInstructions",
+ blockN,
+ "",
+ );
+
+ const heading = phraseOrFallback(
+ "EE_readlabHeading",
+ language,
+ DEFAULT_TEXT_EN.heading,
+ );
+ const body =
+ customBody ||
+ phraseOrFallback("EE_readlabBody", language, DEFAULT_TEXT_EN.body);
+ const buttonLabel = phraseOrFallback(
+ "EE_readlabContinueButton",
+ language,
+ DEFAULT_TEXT_EN.button,
+ );
+
+ const returnUrl = buildEasyEyesReturnUrl({
+ blockNumber: blockN,
+ experimentFilename: thisExperimentInfo.experimentFilename,
+ participantID: thisExperimentInfo.participant,
+ prolificParticipantID: thisExperimentInfo.ProlificParticipantID,
+ prolificStudyID: thisExperimentInfo.ProlificStudyID,
+ prolificSessionID: thisExperimentInfo.ProlificSessionID,
+ session: thisExperimentInfo.session,
+ });
+
+ const readlabUrl = buildReadlabRedirectUrl({
+ config,
+ participantID: thisExperimentInfo.participant || "",
+ returnUrl,
+ });
+
+ renderReadlabOverlay({
+ heading,
+ body,
+ buttonLabel,
+ onClick: async () => {
+ const btn = document.getElementById(READLAB_BUTTON_ID);
+ if (btn) btn.disabled = true;
+
+ // Mark this navigation as intentional so the saveDataOnWindowClose
+ // beforeunload handler skips its preventDefault() (no "Leave site?"
+ // browser dialog).
+ window.__easyeyesIntentionalNavigation = true;
+
+ // Remove PsychoJS's own beforeunload guard if present.
+ try {
+ if (psychoJS && psychoJS.beforeunloadCallback) {
+ window.removeEventListener(
+ "beforeunload",
+ psychoJS.beforeunloadCallback,
+ );
+ }
+ } catch (_) {}
+
+ // Save any pending experiment data before unloading the page.
+ try {
+ if (psychoJS && psychoJS.experiment) {
+ await psychoJS.experiment.save({ sync: true });
+ }
+ } catch (_) {}
+
+ window.location.href = readlabUrl;
+ },
+ });
+
+ return Scheduler.Event.NEXT;
+ };
+};
+
+export const readlabBlockEachFrame = () => {
+ return async function () {
+ return Scheduler.Event.FLIP_REPEAT;
+ };
+};
+
+export const readlabBlockEnd = () => {
+ return async function () {
+ removeOverlay();
+ return Scheduler.Event.NEXT;
+ };
+};
diff --git a/components/utils.js b/components/utils.js
index c1a074a9..a9227c80 100644
--- a/components/utils.js
+++ b/components/utils.js
@@ -1522,6 +1522,10 @@ export const saveDataOnWindowClose = (experiment) => {
experiment.save({ sync: true });
if (eyeTrackingStimulusRecords.length)
experiment.saveCSV(eyeTrackingStimulusRecords);
+ // When EasyEyes itself is navigating the participant away on purpose
+ // (e.g. handing off to ReadLab), don't show the browser "Leave site?"
+ // dialog — but still save data above.
+ if (window.__easyeyesIntentionalNavigation) return;
e.preventDefault();
return null;
});
diff --git a/parameters/glossary.ts b/parameters/glossary.ts
index 4a745c3d..0ced34dd 100644
--- a/parameters/glossary.ts
+++ b/parameters/glossary.ts
@@ -5054,6 +5054,7 @@ export const GLOSSARY: Glossary = {
"reading",
"rsvpReading",
"repeatedLetters",
+ "readlab",
],
},
targetLengthDeg: {
diff --git a/threshold.js b/threshold.js
index 1dc86250..74f18f05 100644
--- a/threshold.js
+++ b/threshold.js
@@ -393,6 +393,13 @@ import {
/* ---------------------------------- */
import { switchKind, switchTask } from "./components/blockTargetKind.js";
+import {
+ readlabBlockBegin,
+ readlabBlockEachFrame,
+ readlabBlockEnd,
+ isReadlabResume,
+ showReadlabReturnEnding,
+} from "./components/readlabBlock.js";
import {
addSkipTrialButton,
handleEscapeKey,
@@ -652,6 +659,19 @@ const paramReaderInitialized = async (reader) => {
// Fails gracefully if not actually prolific experiment, so run always
saveProlificInfo(thisExperimentInfo);
+ // ReadLab return: participant has finished the readlab block and the
+ // browser has navigated back to this experiment URL with EE_resume=1.
+ // Skip the normal experiment flow (no calibration, no blocks) and show a
+ // thank-you with a Prolific completion link. PsychoJS is intentionally
+ // not started in this branch.
+ if (isReadlabResume()) {
+ const rootElement = document.getElementById("root");
+ if (rootElement) rootElement.classList.add("initialized");
+ if (window.removeLoadingScreen) window.removeLoadingScreen();
+ await showReadlabReturnEnding(rc.language.value);
+ return;
+ }
+
setCurrentFn("paramReaderInitialized");
// ! avoid opening windows twice
if (typeof psychoJS._window !== "undefined") return;
@@ -2928,6 +2948,20 @@ const experiment = (howManyBlocksAreThereInTotal) => {
blocksLoopScheduler.add(filterRoutineEachFrame());
blocksLoopScheduler.add(filterRoutineEnd());
+ // ReadLab handoff block: render explanatory page + button, then
+ // navigate same-tab to readlab.net. No trials are scheduled.
+ // Intended to be the LAST block; the redirectUrl returns the
+ // participant to this experiment's URL with EE_resume params.
+ if (_thisBlock.targetKind === "readlab") {
+ blocksLoopScheduler.add(readlabBlockBegin(snapshot, paramReader));
+ blocksLoopScheduler.add(readlabBlockEachFrame());
+ blocksLoopScheduler.add(readlabBlockEnd());
+ blocksLoopScheduler.add(
+ endLoopIteration(blocksLoopScheduler, snapshot),
+ );
+ continue;
+ }
+
// DELETE
// if (
// conditions.every(