@adriansteffan/reactive 0.0.43 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.claude/settings.local.json +14 -1
  2. package/README.md +232 -3
  3. package/dist/{mod-D6W3wq3h.js → mod-D9lwPIrH.js} +6739 -6389
  4. package/dist/mod.d.ts +70 -22
  5. package/dist/reactive.es.js +46 -36
  6. package/dist/reactive.umd.js +40 -38
  7. package/dist/style.css +1 -1
  8. package/dist/{web-B1hJOwit.js → web-DUIQX1PV.js} +1 -1
  9. package/dist/{web-BYSmfdtR.js → web-DXP3LAJm.js} +1 -1
  10. package/package.json +1 -1
  11. package/src/components/canvasblock.tsx +125 -74
  12. package/src/components/checkdevice.tsx +18 -0
  13. package/src/components/enterfullscreen.tsx +7 -3
  14. package/src/components/exitfullscreen.tsx +6 -1
  15. package/src/components/experimentprovider.tsx +7 -2
  16. package/src/components/experimentrunner.tsx +85 -58
  17. package/src/components/microphonecheck.tsx +6 -1
  18. package/src/components/mobilefilepermission.tsx +3 -0
  19. package/src/components/plaininput.tsx +20 -0
  20. package/src/components/prolificending.tsx +5 -0
  21. package/src/components/quest.tsx +60 -0
  22. package/src/components/storeui.tsx +18 -11
  23. package/src/components/text.tsx +14 -0
  24. package/src/components/upload.tsx +69 -286
  25. package/src/index.css +0 -20
  26. package/src/mod.tsx +2 -0
  27. package/src/utils/bytecode.ts +61 -9
  28. package/src/utils/common.ts +4 -1
  29. package/src/utils/simulation.ts +269 -0
  30. package/src/utils/upload.ts +201 -0
  31. package/template/README.md +59 -0
  32. package/template/backend/package-lock.json +280 -156
  33. package/template/backend/src/backend.ts +1 -0
  34. package/template/package-lock.json +1693 -771
  35. package/template/package.json +2 -0
  36. package/template/simulate.ts +15 -0
  37. package/template/src/Experiment.tsx +62 -5
  38. package/template/src/main.tsx +1 -1
  39. package/template/tsconfig.json +2 -3
  40. package/tsconfig.json +1 -0
  41. package/vite.config.ts +1 -1
@@ -1,4 +1,4 @@
1
- import { W as P, b as x, E } from "./mod-D6W3wq3h.js";
1
+ import { W as P, b as x, E } from "./mod-D9lwPIrH.js";
2
2
  function m(w) {
3
3
  const e = w.split("/").filter((t) => t !== "."), r = [];
4
4
  return e.forEach((t) => {
@@ -1,4 +1,4 @@
1
- import { W as e } from "./mod-D6W3wq3h.js";
1
+ import { W as e } from "./mod-D9lwPIrH.js";
2
2
  class s extends e {
3
3
  async enable() {
4
4
  console.log("Immersive mode is only available on Android");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adriansteffan/reactive",
3
- "version": "0.0.43",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "vite",
@@ -4,14 +4,37 @@ import { useRef, useEffect, useMemo, useCallback } from 'react';
4
4
 
5
5
  import {
6
6
  compileTimeline,
7
+ advanceToNextContent,
8
+ applyMetadata,
7
9
  TimelineItem,
8
10
  UnifiedBytecodeInstruction,
9
11
  ExecuteContentInstruction,
10
12
  Store,
11
- RefinedTrialData,
13
+ TrialResult,
12
14
  CanvasResultData,
13
15
  } from '../utils/bytecode';
14
16
  import { BaseComponentProps, isFullscreen, now } from '../utils/common';
17
+ import { registerSimulation, ParticipantState } from '../utils/simulation';
18
+ import { registerFlattener } from '../utils/upload';
19
+
20
+ registerFlattener('CanvasBlock', 'canvas', (item) => {
21
+ const responseData = item.responseData;
22
+ if (Array.isArray(responseData)) {
23
+ return responseData.map((i) => ({ block: item.name, ...i }));
24
+ }
25
+ return [];
26
+ });
27
+
28
+ export type SlideSimulatorResult = {
29
+ key: string | null;
30
+ reactionTime: number | null;
31
+ participantState: ParticipantState;
32
+ };
33
+
34
+ export type SlideSimulator = (
35
+ slide: Record<string, any>,
36
+ participant: ParticipantState,
37
+ ) => SlideSimulatorResult;
15
38
 
16
39
  interface CanvasSlide {
17
40
  draw: (ctx: CanvasRenderingContext2D, width: number, height: number) => void;
@@ -23,11 +46,12 @@ interface CanvasSlide {
23
46
  allowedKeys?: string[] | boolean;
24
47
  metadata?:
25
48
  | Record<string, any>
26
- | ((data?: RefinedTrialData[], store?: Store) => Record<string, any>);
49
+ | ((data?: TrialResult[], store?: Store) => Record<string, any>);
27
50
  nestMetadata?: boolean;
51
+ simulate?: SlideSimulator;
28
52
  }
29
53
 
30
- type DynamicCanvasSlideGenerator = (data: RefinedTrialData[], store: Store) => CanvasSlide;
54
+ type DynamicCanvasSlideGenerator = (data: TrialResult[], store: Store) => CanvasSlide;
31
55
 
32
56
  function isDynamicCanvasSlideGenerator(content: any): content is DynamicCanvasSlideGenerator {
33
57
  return typeof content === 'function';
@@ -62,7 +86,7 @@ export default function CanvasBlock({
62
86
  const isDrawingVisibleRef = useRef<boolean>(true);
63
87
  const responseRegisteredRef = useRef<null | Record<string, any>>(null);
64
88
  const canvasRef = useRef<HTMLCanvasElement>(null);
65
- const dataRef = useRef<RefinedTrialData[]>([]);
89
+ const dataRef = useRef<TrialResult[]>([]);
66
90
  const storeRef = useRef<Store>(store ?? {});
67
91
  const animationFrameRef = useRef<number | null>(null);
68
92
  const contentInstructionsCompletedRef = useRef(0);
@@ -227,74 +251,41 @@ export default function CanvasBlock({
227
251
  }, [instructions, resolveSlideContent, clearCanvas]);
228
252
 
229
253
  const processControlFlow = useCallback(() => {
230
- let foundContentToExecute = false;
231
254
  if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
232
255
  animationFrameRef.current = null;
233
256
 
234
- while (!foundContentToExecute && instructionPointerRef.current < instructions.length) {
235
- const currentInstruction = instructions[instructionPointerRef.current];
236
-
237
- switch (currentInstruction.type) {
238
- case 'IfGoto':
239
- if (currentInstruction.cond(storeRef.current, dataRef.current)) {
240
- const markerIndex = markers[currentInstruction.marker];
241
- if (markerIndex !== undefined) {
242
- instructionPointerRef.current = markerIndex;
243
- } else {
244
- console.error(`Marker ${currentInstruction.marker} not found`);
245
- instructionPointerRef.current++;
246
- }
247
- } else {
248
- instructionPointerRef.current++;
249
- }
250
- continue;
251
-
252
- case 'UpdateStore': {
253
- storeRef.current = {
254
- ...storeRef.current,
255
- ...currentInstruction.fun(storeRef.current, dataRef.current),
256
- };
257
- instructionPointerRef.current++;
258
- continue;
259
- }
260
- case 'ExecuteContent': {
261
- foundContentToExecute = true;
262
- if (canvasRef.current) {
263
- const currentSlide = resolveSlideContent(currentInstruction);
264
- if (currentSlide) {
265
- drawSlideInternal(currentSlide);
266
- const displayDuration = currentSlide.displayDuration ?? Infinity;
267
- const responseTimeLimit = currentSlide.responseTimeLimit ?? Infinity;
268
- if (displayDuration !== Infinity || responseTimeLimit !== Infinity) {
269
- animationFrameRef.current = requestAnimationFrame(tick);
270
- }
271
- } else {
272
- console.error(
273
- 'Failed to resolve slide content during control flow:',
274
- currentInstruction,
275
- );
276
- instructionPointerRef.current++;
277
- foundContentToExecute = false;
278
- continue;
279
- }
280
- } else {
281
- console.error('Canvas element not found during control flow.');
282
- return;
283
- }
284
- break;
285
- }
286
- default:
287
- console.warn('Unknown instruction type during control flow:', currentInstruction);
288
- instructionPointerRef.current++;
289
- continue;
290
- }
291
- }
257
+ const pointer = advanceToNextContent(
258
+ { instructions, markers },
259
+ instructionPointerRef.current,
260
+ () => storeRef.current,
261
+ () => dataRef.current,
262
+ (s) => { storeRef.current = s; },
263
+ );
264
+ instructionPointerRef.current = pointer;
292
265
 
293
- if (!foundContentToExecute && instructionPointerRef.current >= instructions.length) {
294
- if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
295
- animationFrameRef.current = null;
266
+ if (pointer >= instructions.length) {
296
267
  updateStore(storeRef.current);
297
268
  next(dataRef.current);
269
+ return;
270
+ }
271
+
272
+ if (!canvasRef.current) {
273
+ console.error('Canvas element not found during control flow.');
274
+ return;
275
+ }
276
+
277
+ const currentSlide = resolveSlideContent(instructions[pointer]);
278
+ if (currentSlide) {
279
+ drawSlideInternal(currentSlide);
280
+ const displayDuration = currentSlide.displayDuration ?? Infinity;
281
+ const responseTimeLimit = currentSlide.responseTimeLimit ?? Infinity;
282
+ if (displayDuration !== Infinity || responseTimeLimit !== Infinity) {
283
+ animationFrameRef.current = requestAnimationFrame(tick);
284
+ }
285
+ } else {
286
+ console.error('Failed to resolve slide content during control flow:', instructions[pointer]);
287
+ instructionPointerRef.current++;
288
+ processControlFlow();
298
289
  }
299
290
  }, [instructions, markers, resolveSlideContent, drawSlideInternal, tick, next]);
300
291
 
@@ -326,14 +317,7 @@ export default function CanvasBlock({
326
317
  reactionTime: responseData ? responseData.reactionTime : null,
327
318
  } as CanvasResultData;
328
319
 
329
- const metadata =
330
- typeof slide.metadata === 'function'
331
- ? slide.metadata(dataRef.current, storeRef.current)
332
- : slide.metadata;
333
-
334
- if (slide.nestMetadata) {
335
- trialData = { ...trialData, metadata: metadata };
336
- } else trialData = { ...metadata, ...trialData };
320
+ trialData = applyMetadata(trialData, slide, dataRef.current, storeRef.current);
337
321
 
338
322
  dataRef.current.push(trialData);
339
323
  }
@@ -524,3 +508,70 @@ export default function CanvasBlock({
524
508
  </div>
525
509
  );
526
510
  }
511
+
512
+ // --- Default simulator ---
513
+
514
+ registerSimulation('CanvasBlock', (trialProps, experimentState, simulators, participant) => {
515
+ const timeline = trialProps.timeline;
516
+ if (!timeline) return { responseData: [], participantState: participant };
517
+
518
+ const bytecode = compileTimeline(timeline);
519
+ const innerData: CanvasResultData[] = [];
520
+ let innerStore: Store = { ...(experimentState.store || {}) };
521
+ let currentTime = 0;
522
+ let slideNumber = 0;
523
+
524
+ const getStore = () => innerStore;
525
+ const getData = () => innerData as TrialResult[];
526
+ const onUpdateStore = (s: Store) => { innerStore = s; };
527
+
528
+ let pointer = advanceToNextContent(bytecode, 0, getStore, getData, onUpdateStore);
529
+
530
+ while (pointer < bytecode.instructions.length) {
531
+ let slide = (bytecode.instructions[pointer] as ExecuteContentInstruction).content;
532
+ if (typeof slide === 'function') slide = slide(innerData, innerStore);
533
+
534
+ const sim = slide.simulate || simulators.respondToSlide;
535
+ const result = sim(slide, participant);
536
+ participant = result.participantState;
537
+
538
+ if (!slide.ignoreData) {
539
+ const duration = slide.displayDuration || result.reactionTime || 1000;
540
+
541
+ let td: CanvasResultData = {
542
+ index: pointer,
543
+ trialNumber: slideNumber,
544
+ start: currentTime,
545
+ end: currentTime + duration,
546
+ duration,
547
+ key: result.key,
548
+ reactionTime: result.reactionTime,
549
+ };
550
+
551
+ td = applyMetadata(td, slide, innerData, innerStore);
552
+
553
+ innerData.push(td);
554
+ currentTime += duration;
555
+ }
556
+
557
+ slideNumber++;
558
+ pointer = advanceToNextContent(bytecode, pointer + 1, getStore, getData, onUpdateStore);
559
+ }
560
+
561
+ return { responseData: innerData, participantState: participant, storeUpdates: innerStore, duration: currentTime };
562
+ }, {
563
+ respondToSlide: (slide: any, participant: any) => {
564
+ const keys = slide.allowedKeys;
565
+ if (keys === true) {
566
+ return { key: ' ', reactionTime: 200 + Math.random() * 600, participantState: participant };
567
+ }
568
+ if (Array.isArray(keys) && keys.length > 0) {
569
+ return {
570
+ key: keys[Math.floor(Math.random() * keys.length)],
571
+ reactionTime: 200 + Math.random() * 600,
572
+ participantState: participant,
573
+ };
574
+ }
575
+ return { key: null, reactionTime: null, participantState: participant };
576
+ },
577
+ });
@@ -1,5 +1,23 @@
1
1
  import { BaseComponentProps } from '../mod';
2
2
  import { useEffect, useRef, useState } from 'react';
3
+ import { registerSimulation } from '../utils/simulation';
4
+ import { registerFlattener } from '../utils/upload';
5
+
6
+ registerFlattener('CheckDevice', 'session');
7
+
8
+ registerSimulation('CheckDevice', (_trialProps, _experimentState, _simulators, participant) => {
9
+ const deviceInfo = {
10
+ windowWidth: 1920, windowHeight: 1080, screenWidth: 1920, screenHeight: 1080,
11
+ browser: 'Simulated', browserVersion: '1.0', isMobile: false,
12
+ operatingSystem: 'Simulated', hasWebAudio: true, hasFullscreen: true,
13
+ hasWebcam: true, hasMicrophone: true,
14
+ };
15
+ return {
16
+ responseData: deviceInfo,
17
+ participantState: participant,
18
+ storeUpdates: { _reactiveDeviceInfo: deviceInfo },
19
+ };
20
+ }, {});
3
21
 
4
22
  interface DeviceInfo {
5
23
  windowWidth: number;
@@ -1,11 +1,15 @@
1
- import React, { useState, useCallback, useRef, useEffect } from 'react'; // Import useState
1
+ import React, { useState, useCallback, useRef, useEffect } from 'react';
2
2
  import { BaseComponentProps, getPlatform, isFullscreen } from '../utils/common';
3
+ import { registerSimulation, noopSimulate } from '../utils/simulation';
4
+ import { registerFlattener } from '../utils/upload';
3
5
  import Text from '../components/text';
4
-
5
6
  import { StatusBar } from '@capacitor/status-bar';
6
7
  import { ImmersiveMode } from '@adriansteffan/immersive-mode';
7
8
  import { Capacitor } from '@capacitor/core';
8
9
 
10
+ registerFlattener('EnterFullscreen', 'session');
11
+ registerSimulation('EnterFullscreen', noopSimulate, {});
12
+
9
13
  export default function EnterFullscreen({
10
14
  content,
11
15
  buttonText,
@@ -28,7 +32,7 @@ export default function EnterFullscreen({
28
32
 
29
33
  const [isWaiting, setIsWaiting] = useState(false);
30
34
  const listenerFallbackActive = useRef(false);
31
- const timeoutId = useRef<NodeJS.Timeout | null>(null);
35
+ const timeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);
32
36
 
33
37
  const cancelPendingTimeout = useCallback(() => {
34
38
  if (timeoutId.current) {
@@ -1,9 +1,14 @@
1
1
  import { useCallback, useRef, useEffect } from 'react';
2
2
  import { BaseComponentProps, getPlatform, isFullscreen } from '../utils/common';
3
+ import { registerSimulation, noopSimulate } from '../utils/simulation';
4
+ import { registerFlattener } from '../utils/upload';
3
5
  import { StatusBar } from '@capacitor/status-bar';
4
6
  import { ImmersiveMode } from '@adriansteffan/immersive-mode';
5
7
  import { Capacitor } from '@capacitor/core';
6
8
 
9
+ registerFlattener('ExitFullscreen', 'session');
10
+ registerSimulation('ExitFullscreen', noopSimulate, {});
11
+
7
12
  export default function ExitFullscreen({
8
13
  next,
9
14
  delayMs = 0,
@@ -12,7 +17,7 @@ export default function ExitFullscreen({
12
17
  } & BaseComponentProps) {
13
18
 
14
19
  const listenerFallbackActive = useRef(false);
15
- const timeoutId = useRef<NodeJS.Timeout | null>(null);
20
+ const timeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);
16
21
 
17
22
  const cancelPendingTimeout = useCallback(() => {
18
23
  if (timeoutId.current) {
@@ -3,11 +3,14 @@ import { ToastContainer } from 'react-toastify';
3
3
 
4
4
  const queryClient = new QueryClient();
5
5
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
6
- import { ReactNode } from 'react';
6
+ import { createContext, ReactNode, useContext } from 'react';
7
7
  import { SettingsScreen } from './settingsscreen';
8
8
  import { Param } from '../utils/common';
9
9
 
10
- export default function ExperimentProvider({ children, disableSettings }: { children: ReactNode, disableSettings?: boolean }) {
10
+ const HybridSimulationContext = createContext(false);
11
+ export const useHybridSimulationDisabled = () => useContext(HybridSimulationContext);
12
+
13
+ export default function ExperimentProvider({ children, disableSettings, disableHybridSimulation }: { children: ReactNode, disableSettings?: boolean, disableHybridSimulation?: boolean }) {
11
14
 
12
15
  if (window.location.pathname.endsWith('/settings') && !disableSettings) {
13
16
  return (
@@ -20,7 +23,9 @@ export default function ExperimentProvider({ children, disableSettings }: { chil
20
23
 
21
24
  return (
22
25
  <QueryClientProvider client={queryClient}>
26
+ <HybridSimulationContext.Provider value={!!disableHybridSimulation}>
23
27
  {children}
28
+ </HybridSimulationContext.Provider>
24
29
  <ToastContainer
25
30
  position='top-center'
26
31
  autoClose={3000}
@@ -1,14 +1,22 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
 
3
3
  import '../index.css';
4
- import { ExperimentConfig, Store, Param, now } from '../utils/common';
4
+ import { ExperimentConfig, Store, Param, now, getParam } from '../utils/common';
5
5
  import { useCallback, useEffect, useMemo, useRef, useState, ComponentType } from 'react';
6
6
  import {
7
7
  compileTimeline,
8
+ advanceToNextContent,
9
+ applyMetadata,
8
10
  TimelineItem,
9
- RefinedTrialData,
11
+ TrialResult,
10
12
  ComponentResultData,
11
13
  } from '../utils/bytecode';
14
+ import {
15
+ ParticipantState,
16
+ SimulateFunction,
17
+ resolveSimulation,
18
+ } from '../utils/simulation';
19
+ import { useHybridSimulationDisabled } from './experimentprovider';
12
20
 
13
21
  import Upload from './upload';
14
22
  import Text from './text';
@@ -52,13 +60,16 @@ const defaultCustomQuestions: ComponentsMap = {
52
60
  interface RuntimeComponentContent {
53
61
  name?: string;
54
62
  type: string;
63
+ csv?: string | string[];
55
64
  collectRefreshRate?: boolean;
56
65
  hideSettings?: string[] | boolean;
57
66
  metadata?:
58
67
  | Record<string, any>
59
- | ((data: RefinedTrialData[], store: Store) => Record<string, any>);
68
+ | ((data: TrialResult[], store: Store) => Record<string, any>);
60
69
  nestMetadata?: boolean;
61
- props?: Record<string, any> | ((data: RefinedTrialData[], store: Store) => Record<string, any>);
70
+ props?: Record<string, any> | ((data: TrialResult[], store: Store) => Record<string, any>);
71
+ simulate?: SimulateFunction | boolean;
72
+ simulators?: Record<string, any>;
62
73
  }
63
74
 
64
75
  function isRuntimeComponentContent(content: any): content is RuntimeComponentContent {
@@ -72,18 +83,20 @@ export default function ExperimentRunner({
72
83
  },
73
84
  components = {},
74
85
  questions = {},
86
+ hybridParticipant,
75
87
  }: {
76
88
  timeline: TimelineItem[];
77
89
  config?: ExperimentConfig;
78
90
  components?: ComponentsMap;
79
91
  questions?: ComponentsMap;
92
+ hybridParticipant?: ParticipantState;
80
93
  }) {
94
+ const disableHybridSimulation = useHybridSimulationDisabled();
81
95
  const trialByteCode = useMemo(() => {
82
96
  return compileTimeline(timeline);
83
97
  }, [timeline]);
84
98
 
85
- const [instructionPointer, setInstructionPointer] = useState(0);
86
- const dataRef = useRef<RefinedTrialData[]>((() => {
99
+ const dataRef = useRef<TrialResult[]>((() => {
87
100
  const urlParams: Record<string, any> = {};
88
101
  const searchParams = new URLSearchParams(window.location.search);
89
102
  for (const [key, value] of searchParams.entries()) {
@@ -137,6 +150,25 @@ export default function ExperimentRunner({
137
150
  const lastTrialEndTimeRef = useRef(now());
138
151
  const experimentStoreRef = useRef<Store>({});
139
152
 
153
+ const [instructionPointer, setInstructionPointer] = useState(() =>
154
+ advanceToNextContent(
155
+ trialByteCode,
156
+ 0,
157
+ () => experimentStoreRef.current,
158
+ () => dataRef.current,
159
+ (s) => { experimentStoreRef.current = { ...experimentStoreRef.current, ...s }; },
160
+ )
161
+ );
162
+
163
+ const simulationMode =
164
+ (!disableHybridSimulation && getParam('hybridSimulation', false, 'boolean'))
165
+ ? 'hybrid' as const
166
+ : 'none' as const;
167
+
168
+ const participantRef = useRef<ParticipantState>(hybridParticipant || {});
169
+ // Guards against duplicate simulation in React strict mode
170
+ const lastSimulatedPointerRef = useRef(-1);
171
+
140
172
  const componentsMap = { ...defaultComponents, ...components };
141
173
  const customQuestionsMap: ComponentsMap = { ...defaultCustomQuestions, ...questions };
142
174
 
@@ -177,17 +209,11 @@ export default function ExperimentRunner({
177
209
  duration: endTime - startTime,
178
210
  type: content.type,
179
211
  name: content.name ?? '',
212
+ ...(content.csv !== undefined ? { csv: content.csv } : {}),
180
213
  responseData: componentResponseData,
181
214
  };
182
215
 
183
- const metadata =
184
- typeof content.metadata === 'function'
185
- ? content.metadata(dataRef.current, experimentStoreRef.current)
186
- : content.metadata;
187
-
188
- if (content.nestMetadata) {
189
- trialData = { ...trialData, metadata: metadata };
190
- } else trialData = { ...metadata, ...trialData };
216
+ trialData = applyMetadata(trialData, content, dataRef.current, experimentStoreRef.current);
191
217
 
192
218
  dataRef.current = [...dataRef.current, trialData];
193
219
  setTotalTrialsCompleted((prevCount) => prevCount + 1);
@@ -199,49 +225,17 @@ export default function ExperimentRunner({
199
225
  }
200
226
  }
201
227
 
202
- let nextPointer = instructionPointer + 1;
203
- let foundNextContent = false;
204
-
205
- while (nextPointer < trialByteCode.instructions.length) {
206
- const nextInstruction = trialByteCode.instructions[nextPointer];
207
-
208
- switch (nextInstruction.type) {
209
- case 'IfGoto':
210
- if (nextInstruction.cond(experimentStoreRef.current, dataRef.current)) {
211
- const markerIndex = trialByteCode.markers[nextInstruction.marker];
212
- if (markerIndex !== undefined) {
213
- nextPointer = markerIndex;
214
- continue;
215
- } else {
216
- console.error(`Marker ${nextInstruction.marker} not found`);
217
- nextPointer++;
218
- }
219
- } else {
220
- nextPointer++;
221
- }
222
- break;
223
-
224
- case 'UpdateStore':
225
- updateStore(nextInstruction.fun(experimentStoreRef.current, dataRef.current));
226
- nextPointer++;
227
- break;
228
-
229
- case 'ExecuteContent':
230
- foundNextContent = true;
231
- lastTrialEndTimeRef.current = now();
232
- setInstructionPointer(nextPointer);
233
- return;
234
-
235
- default:
236
- console.error('Unknown instruction type encountered:', nextInstruction);
237
- nextPointer++;
238
- break;
239
- }
240
- }
241
-
242
- if (!foundNextContent) {
243
- setInstructionPointer(nextPointer);
228
+ const nextPointer = advanceToNextContent(
229
+ trialByteCode,
230
+ instructionPointer + 1,
231
+ () => experimentStoreRef.current,
232
+ () => dataRef.current,
233
+ (s) => updateStore(s),
234
+ );
235
+ if (nextPointer < trialByteCode.instructions.length) {
236
+ lastTrialEndTimeRef.current = now();
244
237
  }
238
+ setInstructionPointer(nextPointer);
245
239
  }
246
240
 
247
241
  const collectRefreshRate = useCallback((callback: (refreshRate: number | null) => void) => {
@@ -341,8 +335,39 @@ export default function ExperimentRunner({
341
335
 
342
336
  const currentInstruction = trialByteCode.instructions[instructionPointer];
343
337
 
338
+ const shouldSimulate = useMemo(() => {
339
+ if (simulationMode === 'none') return false;
340
+ if (currentInstruction?.type !== 'ExecuteContent') return false;
341
+ const content = currentInstruction.content;
342
+ if (!isRuntimeComponentContent(content)) return false;
343
+ if (content.simulate === false) return false;
344
+ return !!content.simulate || !!content.simulators;
345
+ }, [instructionPointer, simulationMode, currentInstruction]);
346
+
347
+ useEffect(() => {
348
+ if (!shouldSimulate) return;
349
+ if (lastSimulatedPointerRef.current === instructionPointer) return;
350
+ lastSimulatedPointerRef.current = instructionPointer;
351
+
352
+ (async () => {
353
+ const content = (currentInstruction as any).content;
354
+ const { trialProps, simulateFn, simulators } = resolveSimulation(content, dataRef.current, experimentStoreRef.current);
355
+
356
+ const result = await simulateFn(
357
+ trialProps,
358
+ { data: dataRef.current, store: experimentStoreRef.current },
359
+ simulators,
360
+ participantRef.current,
361
+ );
362
+
363
+ participantRef.current = result.participantState;
364
+ if (result.storeUpdates) updateStore(result.storeUpdates);
365
+ next(result.responseData);
366
+ })();
367
+ }, [shouldSimulate, instructionPointer]);
368
+
344
369
  let componentToRender = null;
345
- if (currentInstruction?.type === 'ExecuteContent') {
370
+ if (currentInstruction?.type === 'ExecuteContent' && !shouldSimulate) {
346
371
  const content = currentInstruction.content;
347
372
  if (isRuntimeComponentContent(content)) {
348
373
  const Component = componentsMap[content.type];
@@ -382,8 +407,9 @@ export default function ExperimentRunner({
382
407
  <div
383
408
  className={` ${
384
409
  config.showProgressBar ? '' : 'hidden '
385
- } px-4 mt-4 sm:mt-12 max-w-2xl mx-auto flex-1 h-6 bg-gray-200 rounded-full overflow-hidden`}
410
+ } px-4 mt-4 sm:mt-12 max-w-2xl mx-auto`}
386
411
  >
412
+ <div className='flex-1 h-6 bg-gray-200 rounded-full overflow-hidden'>
387
413
  <div
388
414
  className={`h-full bg-gray-200 rounded-full duration-300 ${
389
415
  progress > 0 ? ' border-black border-2' : ''
@@ -396,6 +422,7 @@ export default function ExperimentRunner({
396
422
  transition: 'width 300ms ease-in-out',
397
423
  }}
398
424
  />
425
+ </div>
399
426
  </div>
400
427
  {componentToRender}
401
428
  </div>
@@ -1,5 +1,10 @@
1
1
  import { useState, useEffect } from 'react';
2
2
  import { VoiceRecorder } from './voicerecorder';
3
+ import { registerSimulation, noopSimulate } from '../utils/simulation';
4
+ import { registerFlattener } from '../utils/upload';
5
+
6
+ registerFlattener('MicrophoneCheck', 'session');
7
+ registerSimulation('MicrophoneCheck', noopSimulate, {});
3
8
 
4
9
  interface MicrophoneDevice {
5
10
  deviceId: string;
@@ -153,7 +158,7 @@ const MicrophoneCheck = ({ next }: { next: (data: object) => void }) => {
153
158
  <div className='mt-16 flex justify-center'>
154
159
  <button
155
160
  onClick={() => next({})}
156
- className='bg-white px-8 py-3 border-2 border-black font-bold text-black text-lg rounded-xl shadow-[2px_2px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-none'
161
+ className='bg-white cursor-pointer px-8 py-3 border-2 border-black font-bold text-black text-lg rounded-xl shadow-[2px_2px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-none'
157
162
  >
158
163
  Next
159
164
  </button>
@@ -1,8 +1,11 @@
1
1
  import { BaseComponentProps, getPlatform } from '../utils/common';
2
2
  import { useEffect, useState } from 'react';
3
+ import { registerSimulation, noopSimulate } from '../utils/simulation';
3
4
  import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
4
5
  import { Capacitor } from '@capacitor/core';
5
6
 
7
+ registerSimulation('RequestFilePermission', noopSimulate, {});
8
+
6
9
  export default function RequestFilePermission({ next }: BaseComponentProps) {
7
10
  const [permissionStatus, setPermissionStatus] = useState('checking');
8
11
 
@@ -1,5 +1,25 @@
1
1
  import { BaseComponentProps } from '../mod';
2
2
  import { useState } from 'react';
3
+ import { registerSimulation } from '../utils/simulation';
4
+ import { registerFlattener } from '../utils/upload';
5
+
6
+ registerFlattener('PlainInput', 'session');
7
+
8
+ registerSimulation('PlainInput', (trialProps, _experimentState, simulators, participant) => {
9
+ const result = simulators.respond(trialProps, participant);
10
+ const typingDuration = String(result.value).length * (50 + Math.random() * 100);
11
+ return {
12
+ responseData: { value: result.value },
13
+ participantState: result.participantState,
14
+ storeUpdates: trialProps.storeupdate ? trialProps.storeupdate(result.value) : undefined,
15
+ duration: typingDuration,
16
+ };
17
+ }, {
18
+ respond: (_input: any, participant: any) => ({
19
+ value: 'simulated_input',
20
+ participantState: participant,
21
+ }),
22
+ });
3
23
 
4
24
  function PlainInput({
5
25
  content,