@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,5 +1,10 @@
1
1
  import { BaseComponentProps, getParam, registerComponentParams } from '../utils/common';
2
2
  import Text from '../components/text';
3
+ import { registerSimulation, noopSimulate } from '../utils/simulation';
4
+ import { registerFlattener } from '../utils/upload';
5
+
6
+ registerFlattener('ProlificEnding', null);
7
+ registerSimulation('ProlificEnding', noopSimulate, {});
3
8
 
4
9
  registerComponentParams('ProlificEnding', [
5
10
  { name: 'cc', defaultValue: '', type: 'string', description: 'Completion code of the Profilic experiment (more commonly supplied via the code)' },
@@ -3,6 +3,66 @@ import { defaultCss, Model, Question, Serializer } from 'survey-core';
3
3
  import { ReactQuestionFactory, Survey, SurveyQuestionElementBase } from 'survey-react-ui';
4
4
  import { ContrastLight } from 'survey-core/themes';
5
5
  import 'survey-core/survey-core.min.css';
6
+ import { registerSimulation } from '../utils/simulation';
7
+ import { registerFlattener } from '../utils/upload';
8
+
9
+ registerFlattener('Quest', 'session');
10
+
11
+ registerSimulation('Quest', (trialProps, _experimentState, simulators, participant) => {
12
+ const responseData: Record<string, any> = {};
13
+ let totalDuration = 0;
14
+ const pages = trialProps.surveyJson?.pages || [{ elements: trialProps.surveyJson?.elements || [] }];
15
+ for (const page of pages) {
16
+ for (const el of page.elements || []) {
17
+ if (!el.name) continue;
18
+ const result = simulators.answerQuestion(el, participant);
19
+ participant = result.participantState;
20
+ responseData[el.name] = result.value;
21
+ totalDuration += result.duration ?? 0;
22
+ }
23
+ }
24
+ return { responseData, participantState: participant, duration: totalDuration };
25
+ }, {
26
+ answerQuestion: (question: any, participant: any) => {
27
+ let value;
28
+ switch (question.type) {
29
+ case 'rating': {
30
+ const min = question.rateMin ?? 1, max = question.rateMax ?? 5;
31
+ value = min + Math.floor(Math.random() * (max - min + 1));
32
+ break;
33
+ }
34
+ case 'boolean': value = Math.random() > 0.5; break;
35
+ case 'text': case 'comment': value = 'simulated_response'; break;
36
+ case 'radiogroup': case 'dropdown': {
37
+ const c = question.choices?.[Math.floor(Math.random() * (question.choices?.length || 0))];
38
+ value = c !== undefined ? (typeof c === 'object' ? c.value : c) : null;
39
+ break;
40
+ }
41
+ case 'checkbox': {
42
+ if (question.choices?.length) {
43
+ const n = 1 + Math.floor(Math.random() * question.choices.length);
44
+ value = [...question.choices]
45
+ .sort(() => Math.random() - 0.5).slice(0, n)
46
+ .map((c: any) => typeof c === 'object' ? c.value : c);
47
+ }
48
+ break;
49
+ }
50
+ case 'matrix': {
51
+ if (question.rows?.length && question.columns?.length) {
52
+ value = Object.fromEntries(
53
+ question.rows.map((r: any) => {
54
+ const col = question.columns[Math.floor(Math.random() * question.columns.length)];
55
+ return [typeof r === 'object' ? r.value : r, typeof col === 'object' ? col.value : col];
56
+ }),
57
+ );
58
+ }
59
+ break;
60
+ }
61
+ default: value = null;
62
+ }
63
+ return { value, participantState: participant, duration: 1000 + Math.random() * 4000 };
64
+ },
65
+ });
6
66
 
7
67
  type ComponentsMap = {
8
68
  [key: string]: ComponentType<any>;
@@ -1,5 +1,9 @@
1
1
  import { BaseComponentProps } from '../mod';
2
2
  import React, { useState, useEffect } from 'react';
3
+ import { registerSimulation } from '../utils/simulation';
4
+ import { registerFlattener } from '../utils/upload';
5
+
6
+ registerFlattener('StoreUI', 'storeui');
3
7
 
4
8
  interface BaseFieldConfig {
5
9
  type: 'string' | 'integer' | 'float' | 'boolean';
@@ -33,6 +37,19 @@ interface BooleanFieldConfig extends BaseFieldConfig {
33
37
 
34
38
  type FieldConfig = StringFieldConfig | NumberFieldConfig | BooleanFieldConfig;
35
39
 
40
+ function buildFieldValues(fields: FieldConfig[], store?: Record<string, any>): Record<string, any> {
41
+ const values: Record<string, any> = {};
42
+ fields.forEach(field => {
43
+ values[field.storeKey] = store?.[field.storeKey] !== undefined ? store[field.storeKey] : field.defaultValue;
44
+ });
45
+ return values;
46
+ }
47
+
48
+ registerSimulation('StoreUI', (trialProps, experimentState, _simulators, participant) => {
49
+ const values = buildFieldValues(trialProps.fields || [], experimentState.store);
50
+ return { responseData: values, participantState: participant, storeUpdates: values };
51
+ }, {});
52
+
36
53
  interface StoreUIProps extends BaseComponentProps {
37
54
  title?: string;
38
55
  description?: string;
@@ -56,17 +73,7 @@ function StoreUI({
56
73
 
57
74
 
58
75
  useEffect(() => {
59
- const initialValues: Record<string, any> = {};
60
-
61
- fields.forEach(field => {
62
-
63
- initialValues[field.storeKey] =
64
- store?.[field.storeKey] !== undefined
65
- ? store[field.storeKey]
66
- : field.defaultValue;
67
- });
68
-
69
- setValues(initialValues);
76
+ setValues(buildFieldValues(fields, store));
70
77
 
71
78
  const initialTouched: Record<string, boolean> = {};
72
79
  fields.forEach(field => {
@@ -1,5 +1,19 @@
1
1
  import React, { useEffect, useRef } from 'react';
2
2
  import { BaseComponentProps, now } from '../mod';
3
+ import { registerSimulation } from '../utils/simulation';
4
+ import { registerFlattener } from '../utils/upload';
5
+
6
+ registerFlattener('Text', 'text');
7
+
8
+ registerSimulation('Text', (trialProps, _experimentState, simulators, participant) => {
9
+ const result = simulators.respond(trialProps, participant);
10
+ return { responseData: result.value, participantState: result.participantState, duration: result.value.reactionTime };
11
+ }, {
12
+ respond: (_input: any, participant: any) => ({
13
+ value: { key: 'button', reactionTime: 500 + Math.random() * 1500 },
14
+ participantState: participant,
15
+ }),
16
+ });
3
17
 
4
18
  function Text({
5
19
  content,
@@ -13,8 +13,53 @@ import {
13
13
  TrialData,
14
14
  } from '../utils/common';
15
15
  import { BlobWriter, TextReader, ZipWriter } from '@zip.js/zip.js';
16
-
17
16
  import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
17
+ import { buildUploadFiles, convertArrayOfObjectsToCSV } from '../utils/upload';
18
+ import { registerSimulation, getBackendUrl, getInitialParticipant } from '../utils/simulation';
19
+
20
+ registerSimulation('Upload', async (trialProps, experimentState, _simulators, participant) => {
21
+ const sessionID = trialProps.sessionID || `sim_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
22
+ const files = buildUploadFiles({
23
+ sessionID,
24
+ data: experimentState.data || [],
25
+ store: experimentState.store,
26
+ generateFiles: trialProps.generateFiles,
27
+ sessionData: trialProps.sessionData,
28
+ uploadRaw: trialProps.uploadRaw ?? true,
29
+ });
30
+
31
+ const initialParticipant = getInitialParticipant();
32
+ if (initialParticipant) {
33
+ files.push({
34
+ filename: `${sessionID}_participant_initial.csv`,
35
+ content: convertArrayOfObjectsToCSV([initialParticipant]),
36
+ encoding: 'utf8',
37
+ });
38
+ files.push({
39
+ filename: `${sessionID}_participant_final.csv`,
40
+ content: convertArrayOfObjectsToCSV([participant]),
41
+ encoding: 'utf8',
42
+ });
43
+ }
44
+
45
+ const backendUrl = getBackendUrl();
46
+ if (backendUrl) {
47
+ const payload = {
48
+ sessionId: sessionID,
49
+ files: files.map((f) => ({ ...f, encoding: f.encoding ?? 'utf8' })),
50
+ };
51
+ const res = await fetch(`${backendUrl}/data`, {
52
+ method: 'POST',
53
+ headers: { 'Content-Type': 'application/json' },
54
+ body: JSON.stringify(payload),
55
+ });
56
+ if (!res.ok) {
57
+ console.error(`Simulation upload failed: ${res.status} ${res.statusText}`);
58
+ }
59
+ }
60
+
61
+ return { responseData: { files }, participantState: participant };
62
+ }, {});
18
63
 
19
64
  interface UploadPayload {
20
65
  sessionId: string;
@@ -26,195 +71,22 @@ interface UploadResponse {
26
71
  message?: string;
27
72
  }
28
73
 
29
- // TODO: deduplicate values with upload function below
30
- registerComponentParams('Upload', [
74
+ const UPLOAD_PARAMS = [
31
75
  {
32
76
  name: 'upload',
33
77
  defaultValue: true,
34
- type: 'boolean',
78
+ type: 'boolean' as const,
35
79
  description: 'Upload the data at the end of the experiment?',
36
80
  },
37
81
  {
38
82
  name: 'download',
39
83
  defaultValue: false,
40
- type: 'boolean',
84
+ type: 'boolean' as const,
41
85
  description: 'Locally download the data at the end of the experiment?',
42
86
  },
43
- ]);
44
-
45
- type DataObject = {
46
- [key: string]: string | number | boolean | null | undefined;
47
- };
48
-
49
- function escapeCsvValue(value: any): string {
50
- if (value === null || value === undefined) {
51
- return '';
52
- }
53
-
54
- const stringValue = String(value);
55
-
56
- if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
57
- const escapedValue = stringValue.replace(/"/g, '""');
58
- return `"${escapedValue}"`;
59
- }
60
-
61
- return stringValue;
62
- }
63
-
64
- function convertArrayOfObjectsToCSV(data: DataObject[]): string {
65
- if (!data || data.length === 0) {
66
- return '';
67
- }
68
-
69
- const headerSet = new Set<string>();
70
- data.forEach((obj) => {
71
- Object.keys(obj).forEach((key) => {
72
- headerSet.add(key);
73
- });
74
- });
75
- const headers = Array.from(headerSet);
76
-
77
- const headerRow = headers.map((header) => escapeCsvValue(header)).join(',');
78
-
79
- const dataRows = data.map((obj) => {
80
- return headers
81
- .map((header) => {
82
- const value = obj[header];
83
- return escapeCsvValue(value);
84
- })
85
- .join(',');
86
- });
87
-
88
- return [headerRow, ...dataRows].join('\n');
89
- }
90
-
91
- // TODO: for better cohesion move this into the components on registration
92
- const defaultFlatteningFunctions = {
93
- 'CanvasBlock': (item: TrialData) => {
94
- const responseData = item.responseData;
95
- if (Array.isArray(responseData)) {
96
- return responseData.map((i) => ({
97
- block: item.name,
98
- ...i,
99
- }));
100
- }
101
- return [];
102
- }
103
- }
104
-
105
- const transform = ({responseData, ...obj}: any) => ({ ...obj, ...Object.entries(responseData || {}).reduce((acc, [k, v]) => ({...acc, [`data_${k}`]: v}), {}) });
106
-
107
- function combineTrialsToCsv(
108
- data: any[],
109
- filename: string,
110
- names: string[],
111
- flatteningFunctions: Record<string, (item: any) => any[] | Record<string, any[]>>,
112
- fun?: (obj: any) => any,
113
- ): FileUpload | FileUpload[] {
114
-
115
- // Collect all flattener results first, filtering out completely empty results
116
- const allResults: (any[] | Record<string, any[]>)[] = names.flatMap((name) => {
117
- const matchingItems = data.filter((d) => d.name === name);
118
-
119
- return matchingItems.map((item) => {
120
- const flattener = item.type && flatteningFunctions[item.type];
121
- const result = flattener ? flattener(item) : [transform(item)];
122
-
123
- // Filter out completely empty results
124
- if (Array.isArray(result) && result.length === 0) {
125
- return null; // Signal this trial should be completely skipped
126
- }
127
-
128
- if (result && typeof result === 'object' && !Array.isArray(result)) {
129
- // Check if all arrays in the object are empty
130
- const hasAnyData = Object.values(result).some(val =>
131
- Array.isArray(val) && val.length > 0
132
- );
133
- if (!hasAnyData) {
134
- return null; // Signal this trial should be completely skipped
135
- }
136
- }
87
+ ];
88
+ registerComponentParams('Upload', UPLOAD_PARAMS);
137
89
 
138
- return result;
139
- });
140
- }).filter(result => result !== null);
141
-
142
- // Check if any result is a multi-table object (has string keys with array values)
143
- const hasMultiTable = allResults.some((result) =>
144
- result &&
145
- typeof result === 'object' &&
146
- !Array.isArray(result) &&
147
- Object.keys(result).some(key => Array.isArray(result[key]))
148
- );
149
-
150
- if (!hasMultiTable) {
151
- // all results are arrays, combine them into one CSV
152
- const processedData = allResults
153
- .flatMap((result) => Array.isArray(result) ? result : [])
154
- .map((x) => (fun ? fun(x) : x));
155
-
156
- // Skip creating CSV if all flatteners returned empty arrays
157
- if (processedData.length === 0) {
158
- return [];
159
- }
160
-
161
- return {
162
- filename,
163
- encoding: 'utf8' as const,
164
- content: convertArrayOfObjectsToCSV(processedData),
165
- };
166
- }
167
-
168
- // handle multi-table results
169
- // Collect all table keys from all results
170
- const allTableKeys = new Set<string>();
171
- allResults.forEach((result) => {
172
- if (result && typeof result === 'object' && !Array.isArray(result)) {
173
- Object.keys(result).forEach(key => {
174
- if (Array.isArray(result[key])) {
175
- allTableKeys.add(key);
176
- }
177
- });
178
- }
179
- });
180
-
181
- // Create separate CSV files for each table key
182
- const files: FileUpload[] = [];
183
-
184
- for (const tableKey of allTableKeys) {
185
- const tableData = allResults.flatMap((result) => {
186
- if (Array.isArray(result)) {
187
- // If this result is a simple array, include it in all tables for backward compatibility
188
- return result;
189
- } else if (result && typeof result === 'object' && result[tableKey]) {
190
- // If this result has data for this table key, include it
191
- return result[tableKey];
192
- }
193
- return [];
194
- }).map((x) => (fun ? fun(x) : x));
195
-
196
- // Skip creating CSV if all flatteners returned empty arrays for this table
197
- if (tableData.length === 0) {
198
- continue;
199
- }
200
-
201
- // Remove file extension from filename and add table key
202
- const baseFilename = filename.replace(/\.csv$/, '');
203
-
204
- files.push({
205
- filename: `${baseFilename}_${tableKey}.csv`,
206
- encoding: 'utf8' as const,
207
- content: convertArrayOfObjectsToCSV(tableData),
208
- });
209
- }
210
-
211
- // Return empty array if no files were created
212
- if (files.length === 0) {
213
- return [];
214
- }
215
-
216
- return files.length === 1 ? files[0] : files;
217
- }
218
90
 
219
91
  interface FileBackend {
220
92
  directoryExists(path: string): Promise<boolean>;
@@ -345,30 +217,22 @@ const getUniqueDirectoryName = async (
345
217
  return uniqueSessionID;
346
218
  };
347
219
 
348
- type CSVBuilder = {
349
- filename?: string;
350
- trials?: string[];
351
- fun?: (row: Record<string, any>) => Record<string, any>;
352
- };
353
-
354
220
  export default function Upload({
355
221
  data,
356
222
  next,
357
223
  store,
358
224
  sessionID,
359
225
  generateFiles,
360
- sessionCSVBuilder,
361
- trialCSVBuilder,
226
+ sessionData,
362
227
  uploadRaw = true,
363
228
  autoUpload = false,
364
229
  androidFolderName,
365
230
  }: BaseComponentProps & {
366
231
  sessionID?: string | null;
367
- generateFiles: (sessionID: string, data: TrialData[], store?: Store) => FileUpload[];
368
- sessionCSVBuilder: CSVBuilder;
369
- trialCSVBuilder: {flatteners: Record<string, ((item: TrialData) => Record<string, any>[] | Record<string, Record<string, any>[]>)>, builders: CSVBuilder[]};
370
- uploadRaw: boolean;
371
- autoUpload: boolean;
232
+ generateFiles?: (sessionID: string, data: TrialData[], store?: Store) => FileUpload[];
233
+ sessionData?: Record<string, any>;
234
+ uploadRaw?: boolean;
235
+ autoUpload?: boolean;
372
236
  androidFolderName?: string;
373
237
  }) {
374
238
  const [uploadState, setUploadState] = useState<'initial' | 'uploading' | 'success' | 'error'>(
@@ -376,8 +240,10 @@ export default function Upload({
376
240
  );
377
241
  const uploadInitiatedRef = useRef(false);
378
242
 
379
- const shouldUpload = getParam('upload', true, 'boolean');
380
- const shouldDownload = getParam('download', false, 'boolean');
243
+ const uploadParam = UPLOAD_PARAMS.find(p => p.name === 'upload')!;
244
+ const downloadParam = UPLOAD_PARAMS.find(p => p.name === 'download')!;
245
+ const shouldUpload = getParam(uploadParam.name, uploadParam.defaultValue, uploadParam.type);
246
+ const shouldDownload = getParam(downloadParam.name, downloadParam.defaultValue, downloadParam.type);
381
247
 
382
248
  const uploadData = useMutation({
383
249
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -427,97 +293,14 @@ export default function Upload({
427
293
 
428
294
  const sessionIDUpload = sessionID ?? uuidv4();
429
295
 
430
- const files: FileUpload[] = generateFiles ? generateFiles(sessionIDUpload, data, store) : [];
431
- if (uploadRaw) {
432
- files.push({
433
- filename: `${sessionIDUpload}.raw.json`,
434
- content: JSON.stringify(data),
435
- encoding: 'utf8',
436
- });
437
- }
438
-
439
- if (sessionCSVBuilder) {
440
- type ParamDetails = {
441
- value?: any;
442
- defaultValue: any;
443
- };
444
- let paramsDict: Record<string, any> = {};
445
- const paramsSource: Record<string, ParamDetails | any> | undefined =
446
- data?.[0]?.responseData?.params;
447
- if (paramsSource && typeof paramsSource === 'object' && paramsSource !== null) {
448
- paramsDict = Object.entries(paramsSource).reduce(
449
- (
450
- accumulator: Record<string, any>,
451
- [paramName, paramDetails]: [string, ParamDetails | any],
452
- ) => {
453
- if (
454
- paramDetails &&
455
- typeof paramDetails === 'object' &&
456
- 'defaultValue' in paramDetails
457
- ) {
458
- const chosenValue = paramDetails.value ?? paramDetails.defaultValue;
459
- accumulator[paramName] = chosenValue;
460
- }
461
- return accumulator;
462
- },
463
- {} as Record<string, any>,
464
- );
465
- }
466
-
467
- let content = {
468
- sessionID: sessionIDUpload,
469
- userAgent: data[0].responseData.userAgent,
470
- ...paramsDict,
471
- };
472
-
473
- if (
474
- sessionCSVBuilder.trials &&
475
- Array.isArray(sessionCSVBuilder.trials) &&
476
- sessionCSVBuilder.trials.length > 0
477
- ) {
478
- for (const trialName of sessionCSVBuilder.trials) {
479
- const matchingDataElement = data.find((element) => element.name === trialName);
480
-
481
- if (matchingDataElement?.responseData) {
482
- if (
483
- typeof matchingDataElement.responseData === 'object' &&
484
- matchingDataElement.responseData !== null
485
- ) {
486
- content = { ...content, ...matchingDataElement.responseData };
487
- }
488
- }
489
- }
490
- }
491
-
492
- files.push({
493
- content: convertArrayOfObjectsToCSV([
494
- sessionCSVBuilder.fun ? sessionCSVBuilder.fun(content) : content,
495
- ]),
496
- filename: `${sessionIDUpload}${sessionCSVBuilder.filename}.csv`,
497
- encoding: 'utf8' as const,
498
- });
499
- }
500
-
501
- if (trialCSVBuilder) {
502
- for (const builder of trialCSVBuilder.builders) {
503
- const result = combineTrialsToCsv(
504
- data,
505
- `${sessionIDUpload}${builder.filename}.csv`,
506
- builder.trials ?? [],
507
- {...defaultFlatteningFunctions, ...trialCSVBuilder.flatteners},
508
- builder.fun,
509
- );
510
-
511
- if (Array.isArray(result)) {
512
- // Only push files if the array is not empty
513
- if (result.length > 0) {
514
- files.push(...result);
515
- }
516
- } else {
517
- files.push(result);
518
- }
519
- }
520
- }
296
+ const files = buildUploadFiles({
297
+ sessionID: sessionIDUpload,
298
+ data,
299
+ store,
300
+ generateFiles,
301
+ sessionData,
302
+ uploadRaw,
303
+ });
521
304
 
522
305
  try {
523
306
  const payload: UploadPayload = {
@@ -616,7 +399,7 @@ export default function Upload({
616
399
  </div>
617
400
  <button
618
401
  onClick={handleUpload}
619
- className='px-4 py-2 cursor-pointer bg-blue-500 text-white rounded-sm hover:bg-blue-600 transition-colors'
402
+ className='cursor-pointer 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'
620
403
  >
621
404
  Try Again
622
405
  </button>
package/src/index.css CHANGED
@@ -31,26 +31,6 @@
31
31
  }
32
32
 
33
33
 
34
- @keyframes slideDown {
35
- 0% {
36
- transform: translateY(-10px);
37
- opacity: 0;
38
- }
39
- 100% {
40
- transform: translateY(0);
41
- opacity: 1;
42
- }
43
- }
44
-
45
- @keyframes fadeIn {
46
- 0% {
47
- opacity: 0;
48
- }
49
- 100% {
50
- opacity: 1;
51
- }
52
- }
53
-
54
34
  /*
55
35
  The default border color has changed to `currentColor` in Tailwind CSS v4,
56
36
  so we've added these compatibility styles to make sure everything still
package/src/mod.tsx CHANGED
@@ -4,6 +4,8 @@ export type { BaseComponentProps, ExperimentConfig };
4
4
 
5
5
  export * from './utils/array';
6
6
  export * from './utils/common';
7
+ export * from './utils/simulation';
8
+ export { registerFlattener } from './utils/upload';
7
9
  export * from './components';
8
10
 
9
11
  export * from 'react-toastify';