jspsych-tangram 0.0.16 → 0.0.18

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 (39) hide show
  1. package/dist/construct/index.browser.js +23 -5
  2. package/dist/construct/index.browser.js.map +1 -1
  3. package/dist/construct/index.browser.min.js +8 -8
  4. package/dist/construct/index.browser.min.js.map +1 -1
  5. package/dist/construct/index.cjs +23 -5
  6. package/dist/construct/index.cjs.map +1 -1
  7. package/dist/construct/index.js +23 -5
  8. package/dist/construct/index.js.map +1 -1
  9. package/dist/grid/index.browser.js +17855 -0
  10. package/dist/grid/index.browser.js.map +1 -0
  11. package/dist/grid/index.browser.min.js +47 -0
  12. package/dist/grid/index.browser.min.js.map +1 -0
  13. package/dist/grid/index.cjs +547 -0
  14. package/dist/grid/index.cjs.map +1 -0
  15. package/dist/grid/index.d.ts +174 -0
  16. package/dist/grid/index.js +545 -0
  17. package/dist/grid/index.js.map +1 -0
  18. package/dist/index.cjs +548 -13
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.ts +187 -16
  21. package/dist/index.js +549 -15
  22. package/dist/index.js.map +1 -1
  23. package/dist/prep/index.browser.js +23 -5
  24. package/dist/prep/index.browser.js.map +1 -1
  25. package/dist/prep/index.browser.min.js +6 -6
  26. package/dist/prep/index.browser.min.js.map +1 -1
  27. package/dist/prep/index.cjs +23 -5
  28. package/dist/prep/index.cjs.map +1 -1
  29. package/dist/prep/index.js +23 -5
  30. package/dist/prep/index.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/core/components/board/GameBoard.tsx +12 -0
  33. package/src/core/io/InteractionTracker.ts +19 -7
  34. package/src/core/io/data-tracking.ts +5 -0
  35. package/src/index.ts +2 -1
  36. package/src/plugins/tangram-grid/GridApp.tsx +522 -0
  37. package/src/plugins/tangram-grid/index.ts +154 -0
  38. package/tangram-construct.min.js +8 -8
  39. package/tangram-prep.min.js +6 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jspsych-tangram",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "description": "Tangram tasks for jsPsych: prep and construct.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -378,6 +378,18 @@ export default function GameBoard(props: GameBoardProps) {
378
378
  };
379
379
  }, [sectors, layoutMode, target, maxQuickstashSlots, primitives.length]);
380
380
 
381
+ // Update tracker with sector centers whenever layout changes
382
+ React.useEffect(() => {
383
+ if (tracker && layout) {
384
+ const centers: { [sectorId: string]: { x: number; y: number } } = {};
385
+ layout.sectors.forEach(s => {
386
+ const rect = rectForBand(layout, s, "silhouette", 1.0);
387
+ centers[s.id] = { x: rect.cx, y: rect.cy };
388
+ });
389
+ tracker.setSectorCenters(centers);
390
+ }
391
+ }, [tracker, layout]);
392
+
381
393
  // Force re-render utility
382
394
  const [, force] = React.useReducer((x) => x + 1, 0);
383
395
 
@@ -99,7 +99,10 @@ export class InteractionTracker {
99
99
 
100
100
  // Trial timing
101
101
  private trialStartTime: number;
102
- private readonly gridStep: number = CONFIG.layout.grid.stepPx;
102
+ private completionTimes: Array<{ sectorId: string; completedAt: number }> = [];
103
+
104
+ // Sector centers (set by GameBoard after layout computation)
105
+ private sectorCenters: { [sectorId: string]: { x: number; y: number } } = {};
103
106
 
104
107
  // Interaction state
105
108
  private interactionIndex: number = 0;
@@ -113,21 +116,22 @@ export class InteractionTracker {
113
116
  // Interaction history (for TrialEndData)
114
117
  private interactions: InteractionEvent[] = [];
115
118
 
116
- // Construction-specific tracking
117
- private completionTimes: Array<{ sectorId: string; completedAt: number }> = [];
118
-
119
119
  // Prep-specific tracking
120
120
  private createdMacros: MacroSnapshot[] = [];
121
121
 
122
+ // Grid step for coordinate conversion
123
+ private gridStep: number;
124
+
122
125
  constructor(
123
126
  controller: BaseGameController,
124
127
  callbacks: DataTrackingCallbacks,
125
- trialParams?: any
128
+ trialParams: any
126
129
  ) {
127
130
  this.controller = controller;
128
131
  this.callbacks = callbacks;
129
132
  this.trialParams = trialParams;
130
133
  this.trialStartTime = Date.now();
134
+ this.gridStep = CONFIG.layout.grid.stepPx;
131
135
 
132
136
  // Register tracking callbacks with controller
133
137
  this.controller.setTrackingCallbacks({
@@ -325,7 +329,14 @@ export class InteractionTracker {
325
329
  }
326
330
 
327
331
  /**
328
- * Record sector completion
332
+ * Set sector centers (for anchor alignment)
333
+ */
334
+ setSectorCenters(centers: { [sectorId: string]: { x: number; y: number } }): void {
335
+ this.sectorCenters = centers;
336
+ }
337
+
338
+ /**
339
+ * Record a sector completion event
329
340
  */
330
341
  recordSectorCompletion(sectorId: string): void {
331
342
  this.completionTimes.push({
@@ -557,7 +568,8 @@ export class InteractionTracker {
557
568
  sectorId: sector.id,
558
569
  completed,
559
570
  pieceCount: pieces.length,
560
- pieces
571
+ pieces,
572
+ center: this.sectorCenters[sector.id] ? this.toAnchorPoint(this.sectorCenters[sector.id]) : undefined
561
573
  };
562
574
 
563
575
  if (sectorState?.completedAt !== undefined) {
@@ -146,6 +146,10 @@ export interface ConstructionTrialData extends BaseTrialData {
146
146
  completedAt: number; // timestamp in ms (Date.now())
147
147
  }>;
148
148
 
149
+ // Sector centers (computed layout positions)
150
+ // Removed in favor of SectorSnapshot.center
151
+ // sectorCenters?: { [sectorId: string]: { x: number; y: number } };
152
+
149
153
  // Final blueprint state (usage counts + definitions)
150
154
  finalBlueprintState: Array<{
151
155
  blueprintId: string;
@@ -212,6 +216,7 @@ export interface SectorSnapshot {
212
216
  completedAt?: number; // timestamp in ms (Date.now()) (undefined if not completed)
213
217
  pieceCount: number;
214
218
  pieces: PieceSnapshot[];
219
+ center?: { x: number; y: number }; // Center of sector in anchor coordinates
215
220
  }
216
221
 
217
222
  /**
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { default as TangramConstructPlugin } from "./plugins/tangram-construct";
2
2
  export { default as TangramPrepPlugin } from "./plugins/tangram-prep";
3
3
  export { default as TangramNBackPlugin } from "./plugins/tangram-nback";
4
- export { default as TangramAFCPlugin } from "./plugins/tangram-afc";
4
+ export { default as TangramAFCPlugin } from "./plugins/tangram-afc";
5
+ export { default as TangramGridPlugin } from "./plugins/tangram-grid";
@@ -0,0 +1,522 @@
1
+ /**
2
+ * GridApp.tsx - React wrapper for tangram grid display with text input
3
+ *
4
+ * This component displays a grid of tangrams with a text input field
5
+ * and submit button for collecting free-text responses.
6
+ */
7
+
8
+ import React, { useRef, useState, useMemo, useEffect } from "react";
9
+ import { createRoot } from "react-dom/client";
10
+ import { JsPsych } from "jspsych";
11
+ import type { Poly, TanKind } from "../../core/domain/types";
12
+ import { CONFIG } from "../../core/config/config";
13
+
14
+ export interface StartGridTrialParams {
15
+ tangrams: any[];
16
+ n_rows: number;
17
+ n_cols: number;
18
+ prompt_text: string;
19
+ button_text: string;
20
+ show_tangram_decomposition?: boolean;
21
+ usePrimitiveColors?: boolean;
22
+ primitiveColorIndices?: number[];
23
+ onTrialEnd?: (data: any) => void;
24
+ }
25
+
26
+ /**
27
+ * Start a grid trial by rendering the GridView component
28
+ *
29
+ * REQUIRES: display_element is a valid HTMLElement
30
+ * MODIFIES: display_element (renders React into it)
31
+ * EFFECTS: Creates a React root and renders GridView with the given params
32
+ */
33
+ export function startGridTrial(
34
+ display_element: HTMLElement,
35
+ params: StartGridTrialParams,
36
+ _jsPsych: JsPsych
37
+ ) {
38
+ const root = createRoot(display_element);
39
+ root.render(React.createElement(GridView, { params }));
40
+ return { root, display_element, jsPsych: _jsPsych };
41
+ }
42
+
43
+ interface GridViewProps {
44
+ params: StartGridTrialParams;
45
+ }
46
+
47
+ /**
48
+ * Compute bounding box for an array of polygons
49
+ *
50
+ * REQUIRES: polys is an array of polygons with {x, y} points
51
+ * MODIFIES: nothing
52
+ * EFFECTS: Returns {minX, minY, maxX, maxY, width, height} of bounding box
53
+ */
54
+ function computeBounds(polys: Poly[]) {
55
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
56
+ for (const poly of polys) {
57
+ for (const p of poly) {
58
+ minX = Math.min(minX, p.x);
59
+ minY = Math.min(minY, p.y);
60
+ maxX = Math.max(maxX, p.x);
61
+ maxY = Math.max(maxY, p.y);
62
+ }
63
+ }
64
+ return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY };
65
+ }
66
+
67
+ /**
68
+ * GridView - Main React component for the grid trial
69
+ *
70
+ * REQUIRES: params contains valid tangram specs and grid dimensions
71
+ * MODIFIES: nothing
72
+ * EFFECTS: Renders a grid of tangrams, text input, and submit button;
73
+ * calls onTrialEnd with response data when submitted
74
+ */
75
+ function GridView({ params }: GridViewProps) {
76
+ const {
77
+ tangrams,
78
+ n_rows,
79
+ n_cols,
80
+ prompt_text,
81
+ button_text,
82
+ show_tangram_decomposition = false,
83
+ usePrimitiveColors = false,
84
+ primitiveColorIndices = [0, 1, 2, 3, 4],
85
+ onTrialEnd
86
+ } = params;
87
+
88
+ const trialStartTime = useRef<number>(Date.now());
89
+ const [response, setResponse] = useState<string>("");
90
+ const [cellSize, setCellSize] = useState<number>(100);
91
+ const controlsRef = useRef<HTMLDivElement>(null);
92
+
93
+ // Layout constants
94
+ const GRID_GAP = 6;
95
+ const CONTAINER_PADDING = 8;
96
+ const CELL_MARGIN = 0.05; // 5% margin inside cell for tangram
97
+ // jsPsych progress bar: 20px height + 8px top/bottom padding + 18px margin
98
+ const PROGRESS_BAR_HEIGHT = 58;
99
+ // Border width on each cell (from CONFIG.size.stroke.bandPx)
100
+ const CELL_BORDER = CONFIG.size.stroke.bandPx ?? 1;
101
+ // Extra buffer to prevent scrollbars from appearing
102
+ const SAFETY_BUFFER = 8;
103
+
104
+ // Inject style to override jspsych-content max-width constraint
105
+ useEffect(() => {
106
+ const styleId = "tangram-grid-jspsych-override";
107
+ if (!document.getElementById(styleId)) {
108
+ const style = document.createElement("style");
109
+ style.id = styleId;
110
+ style.textContent = `
111
+ .jspsych-content {
112
+ max-width: 100% !important;
113
+ width: 100% !important;
114
+ }
115
+ `;
116
+ document.head.appendChild(style);
117
+ }
118
+ return () => {
119
+ const style = document.getElementById(styleId);
120
+ if (style) style.remove();
121
+ };
122
+ }, []);
123
+
124
+ // Canonical piece names
125
+ const CANON = new Set([
126
+ "square",
127
+ "smalltriangle",
128
+ "parallelogram",
129
+ "medtriangle",
130
+ "largetriangle"
131
+ ]);
132
+
133
+ // Convert TangramSpec to internal format with mask and decomposition
134
+ const processedTangrams = useMemo(() => {
135
+ return tangrams.map((tangramSpec) => {
136
+ const filteredTans = tangramSpec.solutionTans.filter((tan: any) => {
137
+ const tanName = tan.name ?? tan.kind;
138
+ return CANON.has(tanName);
139
+ });
140
+
141
+ const mask: Poly[] = filteredTans.map((tan: any) => {
142
+ return tan.vertices.map(([x, y]: number[]) => ({
143
+ x: x ?? 0,
144
+ y: -(y ?? 0)
145
+ }));
146
+ });
147
+
148
+ const primitiveDecomposition = filteredTans.map((tan: any) => ({
149
+ kind: (tan.name ?? tan.kind) as TanKind,
150
+ polygon: tan.vertices.map(([x, y]: number[]) => ({
151
+ x: x ?? 0,
152
+ y: -(y ?? 0)
153
+ }))
154
+ }));
155
+
156
+ return {
157
+ tangramId: tangramSpec.tangramID,
158
+ mask,
159
+ primitiveDecomposition
160
+ };
161
+ });
162
+ }, [tangrams]);
163
+
164
+ // Find the largest tangram dimensions to determine scaling
165
+ const maxTangramExtent = useMemo(() => {
166
+ let maxExtent = 0;
167
+ for (const t of processedTangrams) {
168
+ const bounds = computeBounds(t.mask);
169
+ maxExtent = Math.max(maxExtent, bounds.width, bounds.height);
170
+ }
171
+ return maxExtent || 1;
172
+ }, [processedTangrams]);
173
+
174
+ // Calculate cell size based on available space
175
+ useEffect(() => {
176
+ const calculateCellSize = () => {
177
+ // Use document.documentElement for more accurate viewport size
178
+ const viewportWidth = document.documentElement.clientWidth;
179
+ const viewportHeight = document.documentElement.clientHeight;
180
+
181
+ // Reserve space for controls (prompt + input row) and progress bar
182
+ const controlsHeight = controlsRef.current?.offsetHeight ?? 70;
183
+
184
+ // Available space for the grid (subtract progress bar, controls, padding, buffer)
185
+ const availableWidth =
186
+ viewportWidth - CONTAINER_PADDING * 2 - SAFETY_BUFFER;
187
+ const availableHeight =
188
+ viewportHeight - PROGRESS_BAR_HEIGHT - controlsHeight -
189
+ CONTAINER_PADDING * 2 - SAFETY_BUFFER;
190
+
191
+ // Account for gaps between cells AND borders on each cell
192
+ // Each cell has border on all sides, so total border per cell = 2 * CELL_BORDER
193
+ const totalHorizontalGaps = GRID_GAP * (n_cols - 1);
194
+ const totalVerticalGaps = GRID_GAP * (n_rows - 1);
195
+ const totalHorizontalBorders = CELL_BORDER * 2 * n_cols;
196
+ const totalVerticalBorders = CELL_BORDER * 2 * n_rows;
197
+
198
+ // Calculate max cell size that fits in available space
199
+ const maxCellWidth =
200
+ (availableWidth - totalHorizontalGaps - totalHorizontalBorders) / n_cols;
201
+ const maxCellHeight =
202
+ (availableHeight - totalVerticalGaps - totalVerticalBorders) / n_rows;
203
+
204
+ // Use the smaller dimension to keep cells square
205
+ const newCellSize = Math.floor(Math.min(maxCellWidth, maxCellHeight));
206
+ setCellSize(Math.max(newCellSize, 50)); // Minimum 50px
207
+ };
208
+
209
+ calculateCellSize();
210
+ window.addEventListener("resize", calculateCellSize);
211
+ return () => window.removeEventListener("resize", calculateCellSize);
212
+ }, [n_rows, n_cols]);
213
+
214
+ // Scale factor to fit largest tangram in cell with margin
215
+ const tangramScale = useMemo(() => {
216
+ const usableSize = cellSize * (1 - CELL_MARGIN * 2);
217
+ return usableSize / maxTangramExtent;
218
+ }, [cellSize, maxTangramExtent]);
219
+
220
+ // Mapping from TanKind to color index
221
+ const kindToIndex: Record<TanKind, number> = {
222
+ square: 0,
223
+ smalltriangle: 1,
224
+ parallelogram: 2,
225
+ medtriangle: 3,
226
+ largetriangle: 4
227
+ };
228
+
229
+ // Helper to convert polygon to SVG path
230
+ const pathD = (poly: Poly): string => {
231
+ if (!poly || poly.length === 0) return "";
232
+ const moves = poly.map(
233
+ (p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`
234
+ );
235
+ return moves.join(" ") + " Z";
236
+ };
237
+
238
+ // Handle submit
239
+ const handleSubmit = () => {
240
+ const rt = Date.now() - trialStartTime.current;
241
+ const trialData = {
242
+ response,
243
+ rt,
244
+ n_rows,
245
+ n_cols,
246
+ tangram_ids: processedTangrams.map((t) => t.tangramId),
247
+ show_tangram_decomposition,
248
+ use_primitive_colors: usePrimitiveColors,
249
+ primitive_color_indices: primitiveColorIndices
250
+ };
251
+ if (onTrialEnd) {
252
+ onTrialEnd(trialData);
253
+ }
254
+ };
255
+
256
+ // Render a single tangram SVG
257
+ const renderTangram = (
258
+ tangramData: (typeof processedTangrams)[0],
259
+ index: number
260
+ ) => {
261
+ const { mask, primitiveDecomposition } = tangramData;
262
+
263
+ if (show_tangram_decomposition) {
264
+ // Show individual primitives with optional coloring
265
+ // Scale each primitive and fit to viewport while preserving relative positions
266
+ const scaledPrimitives = primitiveDecomposition.map(
267
+ (prim: { kind: TanKind; polygon: Poly }) => {
268
+ const scaledPoly = prim.polygon.map((p: { x: number; y: number }) => ({
269
+ x: p.x * tangramScale,
270
+ y: p.y * tangramScale
271
+ }));
272
+ return { kind: prim.kind, polygon: scaledPoly };
273
+ }
274
+ );
275
+
276
+ // Find bounds of all primitives together
277
+ let minX = Infinity,
278
+ minY = Infinity,
279
+ maxX = -Infinity,
280
+ maxY = -Infinity;
281
+ for (const prim of scaledPrimitives) {
282
+ for (const p of prim.polygon) {
283
+ minX = Math.min(minX, p.x);
284
+ minY = Math.min(minY, p.y);
285
+ maxX = Math.max(maxX, p.x);
286
+ maxY = Math.max(maxY, p.y);
287
+ }
288
+ }
289
+
290
+ // Compute translation to center all primitives together in viewport
291
+ const width = maxX - minX;
292
+ const height = maxY - minY;
293
+ const tx = cellSize / 2 - (minX + width / 2);
294
+ const ty = cellSize / 2 - (minY + height / 2);
295
+
296
+ const translatedPrimitives = scaledPrimitives.map(
297
+ (prim: { kind: TanKind; polygon: Poly }) => ({
298
+ kind: prim.kind,
299
+ polygon: prim.polygon.map((p: { x: number; y: number }) => ({
300
+ x: p.x + tx,
301
+ y: p.y + ty
302
+ }))
303
+ })
304
+ );
305
+
306
+ return (
307
+ <svg
308
+ key={index}
309
+ width={cellSize}
310
+ height={cellSize}
311
+ viewBox={`0 0 ${cellSize} ${cellSize}`}
312
+ style={{
313
+ display: "block",
314
+ background: CONFIG.color.bands.silhouette.fillEven,
315
+ border: `${CONFIG.size.stroke.bandPx}px solid ${CONFIG.color.bands.silhouette.stroke}`,
316
+ borderRadius: "8px"
317
+ }}
318
+ >
319
+ {translatedPrimitives.map(
320
+ (prim: { kind: TanKind; polygon: Poly }, i: number) => {
321
+ let fillColor: string;
322
+
323
+ if (usePrimitiveColors) {
324
+ // Use primitive colors: map piece type to color via primitiveColorIndices
325
+ const primitiveIndex = kindToIndex[prim.kind];
326
+ if (
327
+ primitiveIndex !== undefined &&
328
+ primitiveColorIndices[primitiveIndex] !== undefined
329
+ ) {
330
+ const colorIndex = primitiveColorIndices[primitiveIndex];
331
+ const color = CONFIG.color.primitiveColors[colorIndex];
332
+ fillColor = color || CONFIG.color.piece.validFill;
333
+ } else {
334
+ fillColor = CONFIG.color.piece.validFill;
335
+ }
336
+ } else {
337
+ // Use default piece color when not using primitive colors
338
+ fillColor = CONFIG.color.piece.validFill;
339
+ }
340
+
341
+ return (
342
+ <path
343
+ key={`prim-${i}`}
344
+ d={pathD(prim.polygon)}
345
+ fill={fillColor}
346
+ opacity={CONFIG.opacity.piece.normal}
347
+ stroke={CONFIG.color.tangramDecomposition.stroke}
348
+ strokeWidth={CONFIG.size.stroke.tangramDecompositionPx}
349
+ />
350
+ );
351
+ }
352
+ )}
353
+ </svg>
354
+ );
355
+ } else {
356
+ // Show as silhouette (merged shape) - scale and center
357
+ const scaledMask = mask.map((poly) =>
358
+ poly.map((p) => ({
359
+ x: p.x * tangramScale,
360
+ y: p.y * tangramScale
361
+ }))
362
+ );
363
+
364
+ // Find bounds
365
+ let minX = Infinity,
366
+ minY = Infinity,
367
+ maxX = -Infinity,
368
+ maxY = -Infinity;
369
+ for (const poly of scaledMask) {
370
+ for (const p of poly) {
371
+ minX = Math.min(minX, p.x);
372
+ minY = Math.min(minY, p.y);
373
+ maxX = Math.max(maxX, p.x);
374
+ maxY = Math.max(maxY, p.y);
375
+ }
376
+ }
377
+
378
+ // Center in viewport
379
+ const width = maxX - minX;
380
+ const height = maxY - minY;
381
+ const tx = cellSize / 2 - (minX + width / 2);
382
+ const ty = cellSize / 2 - (minY + height / 2);
383
+
384
+ const placedMask = scaledMask.map((poly) =>
385
+ poly.map((p) => ({ x: p.x + tx, y: p.y + ty }))
386
+ );
387
+
388
+ return (
389
+ <svg
390
+ key={index}
391
+ width={cellSize}
392
+ height={cellSize}
393
+ viewBox={`0 0 ${cellSize} ${cellSize}`}
394
+ style={{
395
+ display: "block",
396
+ background: CONFIG.color.bands.silhouette.fillEven,
397
+ border: `${CONFIG.size.stroke.bandPx}px solid ${CONFIG.color.bands.silhouette.stroke}`,
398
+ borderRadius: "8px"
399
+ }}
400
+ >
401
+ {placedMask.map((poly, i) => (
402
+ <path
403
+ key={`sil-${i}`}
404
+ d={pathD(poly)}
405
+ fill={CONFIG.color.piece.validFill}
406
+ opacity={CONFIG.opacity.piece.normal}
407
+ stroke="none"
408
+ />
409
+ ))}
410
+ </svg>
411
+ );
412
+ }
413
+ };
414
+
415
+ const isSubmitDisabled = response.trim().length === 0;
416
+
417
+ return (
418
+ <div
419
+ style={{
420
+ display: "flex",
421
+ flexDirection: "column",
422
+ alignItems: "center",
423
+ justifyContent: "space-between",
424
+ background: CONFIG.color.background,
425
+ width: "100%",
426
+ height: `calc(100vh - ${PROGRESS_BAR_HEIGHT}px)`,
427
+ overflow: "hidden",
428
+ fontFamily: "Roboto, sans-serif",
429
+ boxSizing: "border-box",
430
+ padding: `${CONTAINER_PADDING}px`
431
+ }}
432
+ >
433
+ {/* Grid of tangrams - takes up available space */}
434
+ <div
435
+ style={{
436
+ flex: "1 1 auto",
437
+ display: "flex",
438
+ alignItems: "center",
439
+ justifyContent: "center",
440
+ minHeight: 0
441
+ }}
442
+ >
443
+ <div
444
+ style={{
445
+ display: "grid",
446
+ gridTemplateColumns: `repeat(${n_cols}, ${cellSize}px)`,
447
+ gridTemplateRows: `repeat(${n_rows}, ${cellSize}px)`,
448
+ gap: `${GRID_GAP}px`
449
+ }}
450
+ >
451
+ {processedTangrams.slice(0, n_rows * n_cols).map((t, i) =>
452
+ renderTangram(t, i)
453
+ )}
454
+ </div>
455
+ </div>
456
+
457
+ {/* Controls section - fixed height at bottom */}
458
+ <div
459
+ ref={controlsRef}
460
+ style={{
461
+ flex: "0 0 auto",
462
+ display: "flex",
463
+ flexDirection: "column",
464
+ alignItems: "center",
465
+ gap: "4px",
466
+ paddingTop: "4px"
467
+ }}
468
+ >
469
+ {/* Prompt text */}
470
+ <div
471
+ style={{
472
+ fontSize: "14px",
473
+ textAlign: "center",
474
+ maxWidth: "90vw"
475
+ }}
476
+ >
477
+ {prompt_text}
478
+ </div>
479
+
480
+ {/* Text input and submit button side by side */}
481
+ <div
482
+ style={{
483
+ display: "flex",
484
+ alignItems: "center",
485
+ gap: "12px"
486
+ }}
487
+ >
488
+ <input
489
+ type="text"
490
+ value={response}
491
+ onChange={(e) => setResponse(e.target.value)}
492
+ style={{
493
+ width: "min(400px, 50vw)",
494
+ padding: "6px 10px",
495
+ fontSize: "14px",
496
+ borderRadius: "6px",
497
+ border: "2px solid #ccc",
498
+ fontFamily: "inherit",
499
+ boxSizing: "border-box"
500
+ }}
501
+ placeholder="Type your response here..."
502
+ />
503
+
504
+ <button
505
+ className="jspsych-btn"
506
+ onClick={handleSubmit}
507
+ disabled={isSubmitDisabled}
508
+ style={{
509
+ padding: "6px 16px",
510
+ fontSize: "13px",
511
+ cursor: isSubmitDisabled ? "not-allowed" : "pointer",
512
+ opacity: isSubmitDisabled ? 0.5 : 1,
513
+ flexShrink: 0
514
+ }}
515
+ >
516
+ {button_text}
517
+ </button>
518
+ </div>
519
+ </div>
520
+ </div>
521
+ );
522
+ }