@ismaelsoilet/synapsejs 1.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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/bin/synapse.ts +919 -0
  4. package/package.json +70 -0
  5. package/src/client/components.tsx +508 -0
  6. package/src/client/context.tsx +62 -0
  7. package/src/client/hooks.ts +152 -0
  8. package/src/client/i18n.ts +40 -0
  9. package/src/client.ts +66 -0
  10. package/src/compiler/agent-diagnostic-json.ts +113 -0
  11. package/src/compiler/ast-daemon-compressor.ts +190 -0
  12. package/src/compiler/client-bundler.ts +356 -0
  13. package/src/compiler/context-bench.ts +191 -0
  14. package/src/compiler/contract.ts +260 -0
  15. package/src/compiler/db-schema-generator.ts +306 -0
  16. package/src/compiler/fields-parser.ts +267 -0
  17. package/src/compiler/impact-analyzer.ts +279 -0
  18. package/src/compiler/migration-runner.ts +555 -0
  19. package/src/compiler/oracle-runner.ts +293 -0
  20. package/src/compiler/scaffolder.ts +528 -0
  21. package/src/compiler/schema-dag.ts +137 -0
  22. package/src/compiler/schema-drift.ts +184 -0
  23. package/src/compiler/slice-discovery.ts +154 -0
  24. package/src/compiler/slice-splitter.ts +838 -0
  25. package/src/compiler/slice-templates.ts +1527 -0
  26. package/src/compiler/standalone-builder.ts +117 -0
  27. package/src/core/action-context.ts +152 -0
  28. package/src/core/config.ts +77 -0
  29. package/src/core/database-client.ts +246 -0
  30. package/src/core/database-factory.ts +46 -0
  31. package/src/core/image-optimizer.ts +83 -0
  32. package/src/core/index.ts +14 -0
  33. package/src/core/jobs.ts +53 -0
  34. package/src/core/machine-types.ts +75 -0
  35. package/src/core/oracle.ts +20 -0
  36. package/src/core/postgres-client.ts +164 -0
  37. package/src/core/postgres-queue.ts +288 -0
  38. package/src/core/query-builder.ts +343 -0
  39. package/src/core/rpc-client.ts +70 -0
  40. package/src/core/session-context.ts +87 -0
  41. package/src/core/session-cookie.ts +81 -0
  42. package/src/core/session-token.ts +170 -0
  43. package/src/core/sqlite-client.ts +201 -0
  44. package/src/core/storage.ts +317 -0
  45. package/src/index.ts +133 -0
  46. package/src/mcp/server.ts +553 -0
  47. package/src/runtime/client-entry.ts +65 -0
  48. package/src/runtime/discovery-rules.ts +63 -0
  49. package/src/runtime/event-hub.ts +97 -0
  50. package/src/runtime/postgres-event-hub.ts +148 -0
  51. package/src/runtime/queue-engine.ts +255 -0
  52. package/src/runtime/rate-limiter.ts +152 -0
  53. package/src/runtime/server.ts +1550 -0
  54. package/src/runtime/uploads.ts +192 -0
  55. package/templates/starter/.dockerignore +7 -0
  56. package/templates/starter/Dockerfile +40 -0
  57. package/templates/starter/README.md +40 -0
  58. package/templates/starter/gitignore +6 -0
  59. package/templates/starter/package.json +34 -0
  60. package/templates/starter/src/slices/welcome/hello-world.slice.tsx +172 -0
  61. package/templates/starter/tsconfig.json +21 -0
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@ismaelsoilet/synapsejs",
3
+ "version": "1.1.0",
4
+ "description": "Bun-only fullstack framework where a feature is a single contiguous file",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "type": "module",
8
+ "license": "MIT",
9
+ "author": "Ismael Soilet <ismael.soilet@hotmail.com>",
10
+ "keywords": [
11
+ "ai",
12
+ "framework",
13
+ "agentic",
14
+ "fullstack",
15
+ "bun",
16
+ "mcp",
17
+ "machine-centric",
18
+ "pbt",
19
+ "typescript"
20
+ ],
21
+ "bin": {
22
+ "synapse": "./bin/synapse.ts"
23
+ },
24
+ "files": [
25
+ "bin",
26
+ "src",
27
+ "templates",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/ismaelsoilet/synapsejs.git",
34
+ "directory": "packages/synapse"
35
+ },
36
+ "homepage": "https://github.com/ismaelsoilet/synapsejs#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/ismaelsoilet/synapsejs/issues"
39
+ },
40
+ "scripts": {
41
+ "check": "bun run bin/synapse.ts check",
42
+ "mcp": "bun run bin/synapse.ts mcp",
43
+ "info": "bun run bin/synapse.ts info",
44
+ "test": "bun test test"
45
+ },
46
+ "engines": {
47
+ "bun": ">=1.2.0"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ },
52
+ "dependencies": {
53
+ "@sinclair/typebox": "^0.34.40",
54
+ "fast-check": "^3.23.2",
55
+ "postgres": "^3.4.9",
56
+ "react": "^19.0.0",
57
+ "react-dom": "^19.0.0",
58
+ "typescript": "^5.8.2"
59
+ },
60
+ "devDependencies": {
61
+ "@types/bun": "^1.4.2",
62
+ "@types/react": "^19.0.10",
63
+ "@types/react-dom": "^19.0.4"
64
+ },
65
+ "exports": {
66
+ ".": "./src/index.ts",
67
+ "./client": "./src/client.ts",
68
+ "./package.json": "./package.json"
69
+ }
70
+ }
@@ -0,0 +1,508 @@
1
+ import React, { type ReactNode, useMemo, useState } from 'react';
2
+
3
+ // ============================================================================
4
+ // 1. BADGE COMPONENT
5
+ // ============================================================================
6
+ export interface BadgeProps {
7
+ variant?: 'neutral' | 'success' | 'warning' | 'danger' | 'info';
8
+ children?: ReactNode;
9
+ className?: string;
10
+ }
11
+
12
+ export function Badge({ variant = 'neutral', children, className = '' }: BadgeProps) {
13
+ const variantStyles = {
14
+ neutral: 'bg-slate-700/60 text-slate-300 border-slate-600',
15
+ success: 'bg-emerald-950/70 text-emerald-300 border-emerald-800',
16
+ warning: 'bg-amber-950/70 text-amber-300 border-amber-800',
17
+ danger: 'bg-rose-950/70 text-rose-300 border-rose-800',
18
+ info: 'bg-sky-950/70 text-sky-300 border-sky-800'
19
+ };
20
+
21
+ return (
22
+ <span
23
+ className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${variantStyles[variant]} ${className}`}
24
+ >
25
+ {children}
26
+ </span>
27
+ );
28
+ }
29
+
30
+ // ============================================================================
31
+ // 2. BUTTON COMPONENT
32
+ // ============================================================================
33
+ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
34
+ variant?: 'primary' | 'secondary' | 'danger' | 'outline';
35
+ size?: 'sm' | 'md' | 'lg';
36
+ loading?: boolean;
37
+ }
38
+
39
+ export function Button({
40
+ variant = 'primary',
41
+ size = 'md',
42
+ loading = false,
43
+ disabled,
44
+ children,
45
+ className = '',
46
+ ...props
47
+ }: ButtonProps) {
48
+ const variantStyles = {
49
+ primary: 'bg-emerald-600 hover:bg-emerald-500 text-white font-semibold shadow-sm disabled:opacity-50',
50
+ secondary: 'bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 disabled:opacity-50',
51
+ danger: 'bg-rose-600 hover:bg-rose-500 text-white font-semibold disabled:opacity-50',
52
+ outline: 'border border-slate-600 hover:bg-slate-800 text-slate-300 disabled:opacity-50'
53
+ };
54
+
55
+ const sizeStyles = {
56
+ sm: 'px-2.5 py-1 text-xs rounded',
57
+ md: 'px-3.5 py-2 text-sm rounded-md',
58
+ lg: 'px-4 py-2.5 text-base rounded-md'
59
+ };
60
+
61
+ return (
62
+ <button
63
+ disabled={disabled || loading}
64
+ className={`inline-flex items-center justify-center transition-colors cursor-pointer disabled:cursor-not-allowed ${variantStyles[variant]} ${sizeStyles[size]} ${className}`}
65
+ {...props}
66
+ >
67
+ {loading && (
68
+ <svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-current" fill="none" viewBox="0 0 24 24">
69
+ <title>Carregando</title>
70
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
71
+ <path
72
+ className="opacity-75"
73
+ fill="currentColor"
74
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
75
+ />
76
+ </svg>
77
+ )}
78
+ {children}
79
+ </button>
80
+ );
81
+ }
82
+
83
+ // ============================================================================
84
+ // 3. CARD COMPONENT
85
+ // ============================================================================
86
+ export interface CardProps {
87
+ title?: ReactNode;
88
+ subtitle?: ReactNode;
89
+ actions?: ReactNode;
90
+ children?: ReactNode;
91
+ className?: string;
92
+ }
93
+
94
+ export function Card({ title, subtitle, actions, children, className = '' }: CardProps) {
95
+ return (
96
+ <div className={`bg-slate-900 border border-slate-800 rounded-lg p-5 shadow-sm ${className}`}>
97
+ {(title || subtitle || actions) && (
98
+ <div className="flex items-center justify-between pb-4 mb-4 border-b border-slate-800">
99
+ <div>
100
+ {title && <h3 className="text-base font-semibold text-slate-100">{title}</h3>}
101
+ {subtitle && <p className="text-xs text-slate-400 mt-0.5">{subtitle}</p>}
102
+ </div>
103
+ {actions && <div>{actions}</div>}
104
+ </div>
105
+ )}
106
+ {children}
107
+ </div>
108
+ );
109
+ }
110
+
111
+ // ============================================================================
112
+ // 4. PAGINATION COMPONENT
113
+ // ============================================================================
114
+ export interface PaginationProps {
115
+ currentPage?: number;
116
+ totalPages?: number;
117
+ total?: number;
118
+ limit?: number;
119
+ offset?: number;
120
+ onPageChange?: (page: number) => void;
121
+ className?: string;
122
+ }
123
+
124
+ export function Pagination({
125
+ currentPage,
126
+ totalPages,
127
+ total,
128
+ limit,
129
+ offset,
130
+ onPageChange,
131
+ className = ''
132
+ }: PaginationProps) {
133
+ const current = currentPage ?? (limit && offset !== undefined ? Math.floor(offset / limit) + 1 : 1);
134
+ const pages = totalPages ?? (total !== undefined && limit ? Math.max(1, Math.ceil(total / limit)) : 1);
135
+ if (pages <= 1) return null;
136
+
137
+ return (
138
+ <div className={`flex items-center justify-between pt-4 text-xs text-slate-400 ${className}`}>
139
+ <span>{`Página ${current} de ${pages}`}</span>
140
+ <div className="flex gap-1.5">
141
+ <Button size="sm" variant="secondary" disabled={current <= 1} onClick={() => onPageChange?.(current - 1)}>
142
+ Anterior
143
+ </Button>
144
+ <Button size="sm" variant="secondary" disabled={current >= pages} onClick={() => onPageChange?.(current + 1)}>
145
+ Próxima
146
+ </Button>
147
+ </div>
148
+ </div>
149
+ );
150
+ }
151
+
152
+ // ============================================================================
153
+ // 5. DATATABLE COMPONENT
154
+ // ============================================================================
155
+ // biome-ignore lint/suspicious/noExplicitAny: generic boundary for arbitrary row objects
156
+ export interface Column<T = any> {
157
+ key: keyof T | string;
158
+ label?: string;
159
+ header?: string;
160
+ sortable?: boolean;
161
+ // biome-ignore lint/suspicious/noExplicitAny: render callback accepts any cell value
162
+ render?: (value: any, row: T) => ReactNode;
163
+ width?: string;
164
+ }
165
+
166
+ // biome-ignore lint/suspicious/noExplicitAny: generic boundary for arbitrary row records
167
+ export interface DataTableProps<T = any> {
168
+ data: T[];
169
+ columns: Column<T>[];
170
+ searchable?: Array<keyof T | string>;
171
+ pagination?: {
172
+ pageSize?: number;
173
+ };
174
+ emptyMessage?: string;
175
+ onRowClick?: (row: T) => void;
176
+ className?: string;
177
+ }
178
+
179
+ // biome-ignore lint/suspicious/noExplicitAny: table data rows are arbitrary records
180
+ export function DataTable<T extends Record<string, any>>({
181
+ data,
182
+ columns,
183
+ searchable,
184
+ pagination,
185
+ emptyMessage = 'Nenhum registro encontrado.',
186
+ onRowClick,
187
+ className = ''
188
+ }: DataTableProps<T>) {
189
+ const [search, setSearch] = useState('');
190
+ const [sortKey, setSortKey] = useState<string | null>(null);
191
+ const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
192
+ const [page, setPage] = useState(1);
193
+
194
+ const pageSize = pagination?.pageSize ?? (pagination ? 10 : Infinity);
195
+
196
+ // 1. Filter
197
+ const filteredData = useMemo(() => {
198
+ if (!search || !searchable || searchable.length === 0) {
199
+ return data;
200
+ }
201
+ const q = search.toLowerCase();
202
+ return data.filter((row) =>
203
+ searchable.some((k) => {
204
+ const val = row[k as keyof T];
205
+ return val !== undefined && val !== null && String(val).toLowerCase().includes(q);
206
+ })
207
+ );
208
+ }, [data, search, searchable]);
209
+
210
+ // 2. Sort
211
+ const sortedData = useMemo(() => {
212
+ if (!sortKey) return filteredData;
213
+ return [...filteredData].sort((a, b) => {
214
+ const aVal = a[sortKey];
215
+ const bVal = b[sortKey];
216
+ if (aVal === bVal) return 0;
217
+ if (aVal === null || aVal === undefined) return 1;
218
+ if (bVal === null || bVal === undefined) return -1;
219
+ const cmp = aVal < bVal ? -1 : 1;
220
+ return sortDir === 'asc' ? cmp : -cmp;
221
+ });
222
+ }, [filteredData, sortKey, sortDir]);
223
+
224
+ // 3. Paginate
225
+ const totalPages = Math.ceil(sortedData.length / pageSize);
226
+ const paginatedData = useMemo(() => {
227
+ if (pageSize === Infinity) return sortedData;
228
+ const start = (page - 1) * pageSize;
229
+ return sortedData.slice(start, start + pageSize);
230
+ }, [sortedData, page, pageSize]);
231
+
232
+ const handleSort = (key: string) => {
233
+ if (sortKey === key) {
234
+ setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
235
+ } else {
236
+ setSortKey(key);
237
+ setSortDir('asc');
238
+ }
239
+ };
240
+
241
+ return (
242
+ <div className={`space-y-3 ${className}`}>
243
+ {searchable && searchable.length > 0 && (
244
+ <div className="flex justify-between items-center">
245
+ <input
246
+ type="text"
247
+ placeholder="Filtrar registros..."
248
+ value={search}
249
+ onChange={(e) => {
250
+ setSearch(e.target.value);
251
+ setPage(1);
252
+ }}
253
+ className="w-full max-w-xs px-3 py-1.5 text-xs bg-slate-800 border border-slate-700 rounded-md text-slate-100 placeholder-slate-400 focus:outline-none focus:border-emerald-500"
254
+ />
255
+ </div>
256
+ )}
257
+
258
+ <div className="overflow-x-auto rounded-lg border border-slate-800">
259
+ <table className="w-full text-left text-xs text-slate-300">
260
+ <thead className="bg-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold border-b border-slate-700/60">
261
+ <tr>
262
+ {columns.map((col) => {
263
+ const keyStr = String(col.key);
264
+ return (
265
+ <th
266
+ key={keyStr}
267
+ style={col.width ? { width: col.width } : undefined}
268
+ onClick={() => col.sortable && handleSort(keyStr)}
269
+ className={`px-4 py-3 ${col.sortable ? 'cursor-pointer select-none hover:text-slate-200' : ''}`}
270
+ >
271
+ <div className="flex items-center gap-1">
272
+ <span>{col.label ?? col.header ?? keyStr}</span>
273
+ {col.sortable && sortKey === keyStr && <span>{sortDir === 'asc' ? '▲' : '▼'}</span>}
274
+ </div>
275
+ </th>
276
+ );
277
+ })}
278
+ </tr>
279
+ </thead>
280
+ <tbody className="divide-y divide-slate-800/60">
281
+ {paginatedData.length === 0 ? (
282
+ <tr>
283
+ <td colSpan={columns.length} className="px-4 py-8 text-center text-slate-500">
284
+ {emptyMessage}
285
+ </td>
286
+ </tr>
287
+ ) : (
288
+ paginatedData.map((row, idx) => (
289
+ <tr
290
+ key={row.id !== undefined && row.id !== null ? String(row.id) : idx}
291
+ onClick={() => onRowClick?.(row)}
292
+ className={`hover:bg-slate-800/40 transition-colors ${onRowClick ? 'cursor-pointer' : ''}`}
293
+ >
294
+ {columns.map((col) => {
295
+ const val = row[col.key as keyof T];
296
+ return (
297
+ <td key={String(col.key)} className="px-4 py-3">
298
+ {col.render ? col.render(val, row) : val !== null && val !== undefined ? String(val) : '—'}
299
+ </td>
300
+ );
301
+ })}
302
+ </tr>
303
+ ))
304
+ )}
305
+ </tbody>
306
+ </table>
307
+ </div>
308
+
309
+ {pagination && totalPages > 1 && <Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />}
310
+ </div>
311
+ );
312
+ }
313
+
314
+ // ============================================================================
315
+ // 6. DATAFORM COMPONENT WITH NESTED DOT-NOTATION & FILE SUPPORT
316
+ // ============================================================================
317
+ export interface FormField {
318
+ name: string;
319
+ label: string;
320
+ type?: 'text' | 'email' | 'number' | 'password' | 'select' | 'textarea' | 'checkbox' | 'file';
321
+ options?: Array<{ label: string; value: string }>;
322
+ required?: boolean;
323
+ defaultValue?: unknown;
324
+ placeholder?: string;
325
+ helpText?: string;
326
+ }
327
+
328
+ export interface DataFormProps {
329
+ fields: FormField[];
330
+ onSubmit: (formData: Record<string, unknown>) => Promise<unknown>;
331
+ submitLabel?: string;
332
+ isSubmitting?: boolean;
333
+ error?: string | null;
334
+ fieldErrors?: Record<string, string>;
335
+ successMessage?: string | null;
336
+ className?: string;
337
+ }
338
+
339
+ export function setNestedProperty(obj: Record<string, any>, path: string, value: unknown): void {
340
+ const parts = path.split('.');
341
+ let cur = obj;
342
+ for (let i = 0; i < parts.length - 1; i++) {
343
+ const part = parts[i];
344
+ if (!cur[part] || typeof cur[part] !== 'object') {
345
+ cur[part] = {};
346
+ }
347
+ cur = cur[part];
348
+ }
349
+ cur[parts[parts.length - 1]] = value;
350
+ }
351
+
352
+ export function getNestedProperty(obj: Record<string, any>, path: string): unknown {
353
+ const parts = path.split('.');
354
+ let cur: any = obj;
355
+ for (const part of parts) {
356
+ if (cur === null || cur === undefined) return undefined;
357
+ cur = cur[part];
358
+ }
359
+ return cur;
360
+ }
361
+
362
+ export function expandNestedObject(flat: Record<string, unknown>): Record<string, unknown> {
363
+ const nested: Record<string, any> = {};
364
+ for (const [key, value] of Object.entries(flat)) {
365
+ if (key.includes('.')) {
366
+ setNestedProperty(nested, key, value);
367
+ } else {
368
+ nested[key] = value;
369
+ }
370
+ }
371
+ return nested;
372
+ }
373
+
374
+ export function DataForm({
375
+ fields,
376
+ onSubmit,
377
+ submitLabel = 'Salvar',
378
+ isSubmitting = false,
379
+ error,
380
+ fieldErrors,
381
+ successMessage,
382
+ className = ''
383
+ }: DataFormProps) {
384
+ const [formValues, setFormValues] = useState<Record<string, unknown>>(() => {
385
+ const initial: Record<string, unknown> = {};
386
+ for (const f of fields) {
387
+ initial[f.name] = f.defaultValue ?? (f.type === 'checkbox' ? false : f.type === 'file' ? null : '');
388
+ }
389
+ return initial;
390
+ });
391
+
392
+ const handleChange = (name: string, value: unknown) => {
393
+ setFormValues((prev) => ({ ...prev, [name]: value }));
394
+ };
395
+
396
+ const handleSubmit = async (e: React.FormEvent) => {
397
+ e.preventDefault();
398
+ const payload = expandNestedObject(formValues);
399
+ await onSubmit(payload);
400
+ };
401
+
402
+ return (
403
+ <form onSubmit={handleSubmit} className={`space-y-4 ${className}`}>
404
+ {error && <div className="p-3 bg-rose-950/60 border border-rose-800 text-rose-300 rounded text-xs">{error}</div>}
405
+
406
+ {successMessage && (
407
+ <div className="p-3 bg-emerald-950/60 border border-emerald-800 text-emerald-300 rounded text-xs">
408
+ {successMessage}
409
+ </div>
410
+ )}
411
+
412
+ {fields.map((field) => {
413
+ const fieldError =
414
+ fieldErrors?.[field.name] || (getNestedProperty(fieldErrors || {}, field.name) as string | undefined);
415
+ const borderClass = fieldError
416
+ ? 'border-rose-500 focus:border-rose-400'
417
+ : 'border-slate-700 focus:border-emerald-500';
418
+
419
+ return (
420
+ <div key={field.name} className="space-y-1">
421
+ {field.type !== 'checkbox' && (
422
+ <label htmlFor={field.name} className="block text-xs font-medium text-slate-300">
423
+ {field.label} {field.required && <span className="text-rose-400">*</span>}
424
+ </label>
425
+ )}
426
+
427
+ {field.type === 'select' ? (
428
+ <select
429
+ id={field.name}
430
+ value={
431
+ formValues[field.name] !== undefined && formValues[field.name] !== null
432
+ ? String(formValues[field.name])
433
+ : ''
434
+ }
435
+ onChange={(e) => handleChange(field.name, e.target.value)}
436
+ required={field.required}
437
+ className={`w-full px-3 py-2 text-xs bg-slate-800 border rounded text-slate-100 focus:outline-none ${borderClass}`}
438
+ >
439
+ <option value="">Selecione...</option>
440
+ {field.options?.map((opt) => (
441
+ <option key={opt.value} value={opt.value}>
442
+ {opt.label}
443
+ </option>
444
+ ))}
445
+ </select>
446
+ ) : field.type === 'textarea' ? (
447
+ <textarea
448
+ id={field.name}
449
+ rows={3}
450
+ value={
451
+ formValues[field.name] !== undefined && formValues[field.name] !== null
452
+ ? String(formValues[field.name])
453
+ : ''
454
+ }
455
+ onChange={(e) => handleChange(field.name, e.target.value)}
456
+ placeholder={field.placeholder}
457
+ required={field.required}
458
+ className={`w-full px-3 py-2 text-xs bg-slate-800 border rounded text-slate-100 placeholder-slate-500 focus:outline-none ${borderClass}`}
459
+ />
460
+ ) : field.type === 'checkbox' ? (
461
+ <label htmlFor={field.name} className="flex items-center gap-2 cursor-pointer text-xs text-slate-300">
462
+ <input
463
+ id={field.name}
464
+ type="checkbox"
465
+ checked={Boolean(formValues[field.name])}
466
+ onChange={(e) => handleChange(field.name, e.target.checked)}
467
+ className="rounded border-slate-700 bg-slate-800 text-emerald-600 focus:ring-0"
468
+ />
469
+ <span>{field.placeholder || field.label}</span>
470
+ </label>
471
+ ) : field.type === 'file' ? (
472
+ <input
473
+ id={field.name}
474
+ type="file"
475
+ onChange={(e) => handleChange(field.name, e.target.files?.[0] ?? null)}
476
+ required={field.required}
477
+ className={`w-full px-3 py-2 text-xs bg-slate-800 border rounded text-slate-300 file:mr-2 file:py-1 file:px-2 file:rounded file:border-0 file:text-xs file:bg-slate-700 file:text-slate-200 hover:file:bg-slate-600 ${borderClass}`}
478
+ />
479
+ ) : (
480
+ <input
481
+ id={field.name}
482
+ type={field.type || 'text'}
483
+ value={
484
+ formValues[field.name] !== undefined && formValues[field.name] !== null
485
+ ? String(formValues[field.name])
486
+ : ''
487
+ }
488
+ onChange={(e) =>
489
+ handleChange(field.name, field.type === 'number' ? Number(e.target.value) : e.target.value)
490
+ }
491
+ placeholder={field.placeholder}
492
+ required={field.required}
493
+ className={`w-full px-3 py-2 text-xs bg-slate-800 border rounded text-slate-100 placeholder-slate-500 focus:outline-none ${borderClass}`}
494
+ />
495
+ )}
496
+
497
+ {fieldError && <p className="text-[11px] text-rose-400">{fieldError}</p>}
498
+ {field.helpText && !fieldError && <p className="text-[10px] text-slate-500">{field.helpText}</p>}
499
+ </div>
500
+ );
501
+ })}
502
+
503
+ <Button type="submit" loading={isSubmitting} variant="primary" className="w-full mt-2">
504
+ {submitLabel}
505
+ </Button>
506
+ </form>
507
+ );
508
+ }
@@ -0,0 +1,62 @@
1
+ import { createContext, type ReactNode, useContext } from 'react';
2
+ import type { SessionContext } from '../core/session-context';
3
+
4
+ export interface SynapseContextValue {
5
+ props: Record<string, unknown>;
6
+ session?: SessionContext;
7
+ }
8
+
9
+ const SynapseContext = createContext<SynapseContextValue | null>(null);
10
+
11
+ export interface SynapseProviderProps {
12
+ props?: Record<string, unknown>;
13
+ session?: SessionContext;
14
+ children?: ReactNode;
15
+ }
16
+
17
+ /**
18
+ * SynapseProvider delivers per-request SSR data isolation and seamless client hydration.
19
+ */
20
+ export function SynapseProvider({ props, session, children }: SynapseProviderProps) {
21
+ // On client, if props not passed directly, hydrate from window.__SYNAPSE_PROPS__
22
+ const clientProps =
23
+ props ??
24
+ (typeof window !== 'undefined'
25
+ ? ((window as unknown as { __SYNAPSE_PROPS__?: Record<string, unknown> }).__SYNAPSE_PROPS__ ?? {})
26
+ : {});
27
+
28
+ const value: SynapseContextValue = {
29
+ props: clientProps,
30
+ session
31
+ };
32
+
33
+ return <SynapseContext.Provider value={value}>{children}</SynapseContext.Provider>;
34
+ }
35
+
36
+ export function useSynapseContext(): SynapseContextValue {
37
+ const ctx = useContext(SynapseContext);
38
+ if (!ctx) {
39
+ const props =
40
+ typeof window !== 'undefined'
41
+ ? ((window as unknown as { __SYNAPSE_PROPS__?: Record<string, unknown> }).__SYNAPSE_PROPS__ ?? {})
42
+ : {};
43
+ return { props };
44
+ }
45
+ return ctx;
46
+ }
47
+
48
+ /**
49
+ * Hook to retrieve loader data in SSR components with strict typing.
50
+ */
51
+ export function useLoaderData<T = Record<string, unknown>>(): T {
52
+ const ctx = useSynapseContext();
53
+ return ctx.props as T;
54
+ }
55
+
56
+ /**
57
+ * Hook to retrieve the active user session context.
58
+ */
59
+ export function useSession(): SessionContext | undefined {
60
+ const ctx = useSynapseContext();
61
+ return ctx.session;
62
+ }