@ismaelsoilet/synapsejs 1.9.0 → 1.11.1
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.
- package/README.md +4 -3
- package/bin/synapse.ts +74 -2
- package/package.json +2 -2
- package/src/client/components.tsx +459 -1
- package/src/client/context.tsx +23 -3
- package/src/client/error-boundary.tsx +59 -0
- package/src/client/hooks.ts +100 -1
- package/src/client/toast.tsx +219 -0
- package/src/client/upload.ts +153 -0
- package/src/client.ts +37 -2
- package/src/compiler/client-bundler.ts +94 -2
- package/src/compiler/context-bench.ts +2 -0
- package/src/compiler/contract.ts +1 -1
- package/src/compiler/oracle-runner.ts +10 -1
- package/src/compiler/scaffolder.ts +113 -4
- package/src/compiler/slice-splitter.ts +21 -3
- package/src/compiler/slice-templates.ts +234 -10
- package/src/compiler/typescript-api.ts +33 -0
- package/src/core/action-context.ts +50 -1
- package/src/core/cron-parser.ts +167 -0
- package/src/core/database-client.ts +35 -0
- package/src/core/index.ts +2 -0
- package/src/core/jobs.ts +37 -0
- package/src/core/oauth-client.ts +322 -0
- package/src/core/postgres-client.ts +37 -0
- package/src/core/postgres-queue.ts +51 -2
- package/src/core/query-builder.ts +166 -34
- package/src/core/session-context.ts +45 -4
- package/src/core/session-token.ts +16 -1
- package/src/core/sqlite-client.ts +20 -0
- package/src/mcp/server.ts +257 -1
- package/src/mcp/tools.ts +92 -1
- package/src/project-status.ts +103 -0
- package/src/runtime/client-entry.ts +11 -5
- package/src/runtime/queue-engine.ts +120 -5
- package/src/runtime/security-headers.ts +35 -0
- package/src/runtime/server.ts +300 -58
- package/templates/starter/package.json +2 -2
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ with rawBody preservation · B2B multi-tenancy & IDOR prevention (`requireTenant
|
|
|
33
33
|
and Turbo Morphing router · explicit RBAC via `requireAuth(session, roles)` · zero-wiring routing + SSR + RPC · slice discovery
|
|
34
34
|
that never reports PASS with zero slices · isomorphic splitter with compile and leak gates · typed AST skeleton map ·
|
|
35
35
|
scaffolder with `--fields` grammar and templates (create, list, update, delete, login, oauth-github, crud) · standalone production bundler (`synapse build --standalone`) ·
|
|
36
|
-
MCP stdio server with
|
|
36
|
+
MCP stdio server with 23 native tools (16 Synapse + 7 Jev) · slice invariants under `bun:test` with per-invariant reporting · PostgreSQL parity · real concurrency & load stress benchmarks ·
|
|
37
37
|
SSRF & Network Guard (`validateExternalUrl`, `isPrivateOrReservedIp`) · anti-spoofing & bounded LRU rate limiting · bounded LRU SSR micro-cache with parameter normalization · strict payload size guards (RPC 5MB, Webhook 10MB) & malformed JSON protection · SQLite queue zombie job recovery with `locked_at` visibility timeout · HTTP 500 SSR error isolation & bundle suppression · secure HttpOnly cookies (`ctx.setCookie`) · graceful HTTP shutdown (`server.stop` with connection drain) ·
|
|
38
38
|
Prototype Pollution Shield (`setNestedProperty`, `expandNestedObject`) · Parameterized Query Operator Allowlist (`VALID_WHERE_OPERATORS`) · Reentrant SQLite Savepoints (`txDepth` + `SAVEPOINT`) · Atomic Queue Poison-Pill Neutralization (claim increment & DLQ timeout) · Strict Multi-Tenant Context Derivation (spoofing-proof token claims) · Streaming Upload & Chunked DoS Armor (`readBodyWithinLimit`) · Origin-Validated WebSocket Upgrades (`SYNAPSE_ALLOWED_ORIGINS`) · Safe SSR Serialization (`<script type="application/json">`) & Cookie CRLF Guard.
|
|
39
39
|
|
|
@@ -131,9 +131,10 @@ bun run mcp # JSON-RPC 2.0 over stdio
|
|
|
131
131
|
```
|
|
132
132
|
|
|
133
133
|
Tools: `synapse_get_repo_map`, `synapse_get_db_schema`, `synapse_check`, `synapse_split`,
|
|
134
|
-
`synapse_run_pbt`, `synapse_scaffold_slice`, `synapse_migrate`, `synapse_rollback`, `synapse_contract`,
|
|
134
|
+
`synapse_run_pbt`, `synapse_scaffold_slice`, `synapse_scaffold_domain`, `synapse_migrate`, `synapse_rollback`, `synapse_contract`,
|
|
135
135
|
`synapse_check_db_drift`, `synapse_diff_impact`, `synapse_test_gate`, `synapse_abort_check`,
|
|
136
|
-
`synapse_verify_completion`, `synapse_reasoning_effort`, `synapse_triage_error`, `synapse_route_task`, `synapse_evaluate_nudge
|
|
136
|
+
`synapse_verify_completion`, `synapse_reasoning_effort`, `synapse_triage_error`, `synapse_route_task`, `synapse_evaluate_nudge`,
|
|
137
|
+
`synapse_inspect_routes`, `synapse_test_slice`, `synapse_generate_seed`, `synapse_scaffold_component`.
|
|
137
138
|
Slice-dependent tools fail with `NO_SLICES_DIR` (listing the paths they examined) instead of reporting an empty success.
|
|
138
139
|
|
|
139
140
|
## Write-surface containment
|
package/bin/synapse.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* synapse split - Partition slices into shared/server/client modules (gated)
|
|
16
16
|
* synapse test - Run all property-based testing (PBT) oracles
|
|
17
17
|
* synapse new-slice - Scaffold a new atomic vertical slice
|
|
18
|
+
* synapse new-domain - Scaffold a domain layout, list slice and shared module
|
|
18
19
|
* synapse info - Machine metadata and framework metrics
|
|
19
20
|
*/
|
|
20
21
|
|
|
@@ -22,19 +23,20 @@ import * as fs from 'fs';
|
|
|
22
23
|
import * as path from 'path';
|
|
23
24
|
import { runMachineVerifications } from '../src/compiler/agent-diagnostic-json';
|
|
24
25
|
import { compressRepositoryAST } from '../src/compiler/ast-daemon-compressor';
|
|
25
|
-
import { buildAllClientBundles } from '../src/compiler/client-bundler';
|
|
26
|
+
import { buildAllClientBundles, buildCssBundle } from '../src/compiler/client-bundler';
|
|
26
27
|
import { renderContractJson, renderContractMarkdown } from '../src/compiler/contract';
|
|
27
28
|
import { generateDatabaseSchemaCatalog } from '../src/compiler/db-schema-generator';
|
|
28
29
|
import { analyzeImpact } from '../src/compiler/impact-analyzer';
|
|
29
30
|
import { rollbackSliceMigrations, runSliceMigrations } from '../src/compiler/migration-runner';
|
|
30
31
|
import { analyzeOptionalItemCoverage, unionCoverage } from '../src/compiler/optional-coverage';
|
|
31
32
|
import { runSliceOracles } from '../src/compiler/oracle-runner';
|
|
32
|
-
import { scaffoldCrud, scaffoldShared, scaffoldSlice } from '../src/compiler/scaffolder';
|
|
33
|
+
import { scaffoldCrud, scaffoldDomain, scaffoldShared, scaffoldSlice } from '../src/compiler/scaffolder';
|
|
33
34
|
import { checkSchemaDrift } from '../src/compiler/schema-drift';
|
|
34
35
|
import { findSliceFiles, resolveSlicesDir } from '../src/compiler/slice-discovery';
|
|
35
36
|
import { artifactDirectory, splitSlice, verifySplit, writeSplitArtifacts } from '../src/compiler/slice-splitter';
|
|
36
37
|
import { SLICE_TEMPLATES, type SliceTemplate } from '../src/compiler/slice-templates';
|
|
37
38
|
import { buildStandalone } from '../src/compiler/standalone-builder';
|
|
39
|
+
import { assertCompilerApi } from '../src/compiler/typescript-api';
|
|
38
40
|
import { isInsideBase } from '../src/core/path-guard';
|
|
39
41
|
import { SynapseMcpServer } from '../src/mcp/server';
|
|
40
42
|
import { projectStatus } from '../src/project-status';
|
|
@@ -63,6 +65,7 @@ const KNOWN_FLAGS: Record<string, string[]> = {
|
|
|
63
65
|
split: [],
|
|
64
66
|
'new-slice': ['--template', '--fields'],
|
|
65
67
|
'new-shared': [],
|
|
68
|
+
'new-domain': ['--fields'],
|
|
66
69
|
build: ['--standalone'],
|
|
67
70
|
contract: ['--markdown'],
|
|
68
71
|
test: ['--gate'],
|
|
@@ -112,6 +115,24 @@ async function main() {
|
|
|
112
115
|
process.exit(1);
|
|
113
116
|
}
|
|
114
117
|
|
|
118
|
+
const compilerCommands = new Set(['check', 'split', 'skeleton', 'db-schema', 'impact', 'test', 'build']);
|
|
119
|
+
|
|
120
|
+
if (compilerCommands.has(command)) {
|
|
121
|
+
try {
|
|
122
|
+
assertCompilerApi();
|
|
123
|
+
} catch (error) {
|
|
124
|
+
process.stdout.write(
|
|
125
|
+
`${JSON.stringify({
|
|
126
|
+
status: 'FAIL',
|
|
127
|
+
operation: 'COMPILER_API_CHECK',
|
|
128
|
+
code: 'COMPILER_API_UNAVAILABLE',
|
|
129
|
+
message: error instanceof Error ? error.message : String(error)
|
|
130
|
+
})}\n`
|
|
131
|
+
);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
115
136
|
switch (command) {
|
|
116
137
|
case 'dev': {
|
|
117
138
|
const watch = process.argv.includes('--watch');
|
|
@@ -125,6 +146,7 @@ async function main() {
|
|
|
125
146
|
}
|
|
126
147
|
|
|
127
148
|
const port = parseInt(process.argv[3] || process.env.PORT || '3000', 10);
|
|
149
|
+
await buildCssBundle(root);
|
|
128
150
|
const server = new SynapseServer(root, port);
|
|
129
151
|
await server.discoverSlices();
|
|
130
152
|
await server.start();
|
|
@@ -162,6 +184,7 @@ async function main() {
|
|
|
162
184
|
);
|
|
163
185
|
}
|
|
164
186
|
|
|
187
|
+
await buildCssBundle(root);
|
|
165
188
|
const server = new SynapseServer(root, port);
|
|
166
189
|
await server.discoverSlices();
|
|
167
190
|
await server.start();
|
|
@@ -593,7 +616,56 @@ async function main() {
|
|
|
593
616
|
break;
|
|
594
617
|
}
|
|
595
618
|
|
|
619
|
+
case 'new-domain': {
|
|
620
|
+
const domain = process.argv[3];
|
|
621
|
+
if (!domain) {
|
|
622
|
+
process.stdout.write(
|
|
623
|
+
`${JSON.stringify({
|
|
624
|
+
status: 'ERROR',
|
|
625
|
+
message: 'Nome do domínio ausente. Uso: synapse new-domain <dominio> [--fields="..."]'
|
|
626
|
+
})}\n`
|
|
627
|
+
);
|
|
628
|
+
process.exit(1);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const root = process.env.SYNAPSE_ROOT || process.cwd();
|
|
632
|
+
const fields = process.argv.find((arg) => arg.startsWith('--fields='))?.slice('--fields='.length);
|
|
633
|
+
const created = scaffoldDomain(domain, root, fields);
|
|
634
|
+
|
|
635
|
+
if (!created.ok) {
|
|
636
|
+
process.stdout.write(
|
|
637
|
+
`${JSON.stringify(
|
|
638
|
+
{
|
|
639
|
+
status: 'ERROR',
|
|
640
|
+
operation: 'SCAFFOLD_DOMAIN',
|
|
641
|
+
code: created.error.code,
|
|
642
|
+
message: created.error.message
|
|
643
|
+
},
|
|
644
|
+
null,
|
|
645
|
+
2
|
|
646
|
+
)}\n`
|
|
647
|
+
);
|
|
648
|
+
process.exit(1);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
process.stdout.write(
|
|
652
|
+
`${JSON.stringify(
|
|
653
|
+
{
|
|
654
|
+
status: 'PASS',
|
|
655
|
+
operation: 'SCAFFOLD_DOMAIN',
|
|
656
|
+
domain,
|
|
657
|
+
createdFiles: created.value.map((f) => path.relative(root, f))
|
|
658
|
+
},
|
|
659
|
+
null,
|
|
660
|
+
2
|
|
661
|
+
)}\n`
|
|
662
|
+
);
|
|
663
|
+
process.exit(0);
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
|
|
596
667
|
case 'build': {
|
|
668
|
+
await buildCssBundle(root);
|
|
597
669
|
const isStandalone = process.argv.includes('--standalone');
|
|
598
670
|
|
|
599
671
|
if (isStandalone) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ismaelsoilet/synapsejs",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.1",
|
|
4
4
|
"description": "Bun-only fullstack framework where a feature is a single contiguous file",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"postgres": "^3.4.9",
|
|
57
57
|
"react": "^19.0.0",
|
|
58
58
|
"react-dom": "^19.0.0",
|
|
59
|
-
"typescript": "
|
|
59
|
+
"typescript": "~5.9.3"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@types/bun": "^1.4.2",
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState
|
|
10
|
+
} from 'react';
|
|
2
11
|
|
|
3
12
|
// ============================================================================
|
|
4
13
|
// 1. BADGE COMPONENT
|
|
@@ -523,3 +532,452 @@ export function DataForm({
|
|
|
523
532
|
</form>
|
|
524
533
|
);
|
|
525
534
|
}
|
|
535
|
+
|
|
536
|
+
// ============================================================================
|
|
537
|
+
// 7. MODAL / DIALOG COMPONENT (WAI-ARIA COMPLIANT)
|
|
538
|
+
// ============================================================================
|
|
539
|
+
export interface ModalProps {
|
|
540
|
+
isOpen: boolean;
|
|
541
|
+
onClose: () => void;
|
|
542
|
+
title?: ReactNode;
|
|
543
|
+
description?: ReactNode;
|
|
544
|
+
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
|
545
|
+
children?: ReactNode;
|
|
546
|
+
footer?: ReactNode;
|
|
547
|
+
className?: string;
|
|
548
|
+
closeOnBackdrop?: boolean;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export function Modal({
|
|
552
|
+
isOpen,
|
|
553
|
+
onClose,
|
|
554
|
+
title,
|
|
555
|
+
description,
|
|
556
|
+
size = 'md',
|
|
557
|
+
children,
|
|
558
|
+
footer,
|
|
559
|
+
className = '',
|
|
560
|
+
closeOnBackdrop = true
|
|
561
|
+
}: ModalProps) {
|
|
562
|
+
const dialogRef = useRef<HTMLDivElement>(null);
|
|
563
|
+
|
|
564
|
+
useEffect(() => {
|
|
565
|
+
if (!isOpen) return;
|
|
566
|
+
|
|
567
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
568
|
+
if (e.key === 'Escape') {
|
|
569
|
+
e.stopPropagation();
|
|
570
|
+
onClose();
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const previousOverflow = document.body.style.overflow;
|
|
575
|
+
document.body.style.overflow = 'hidden';
|
|
576
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
577
|
+
|
|
578
|
+
return () => {
|
|
579
|
+
document.body.style.overflow = previousOverflow;
|
|
580
|
+
window.removeEventListener('keydown', handleKeyDown);
|
|
581
|
+
};
|
|
582
|
+
}, [isOpen, onClose]);
|
|
583
|
+
|
|
584
|
+
if (!isOpen) return null;
|
|
585
|
+
|
|
586
|
+
const sizeStyles = {
|
|
587
|
+
sm: 'max-w-sm',
|
|
588
|
+
md: 'max-w-lg',
|
|
589
|
+
lg: 'max-w-2xl',
|
|
590
|
+
xl: 'max-w-4xl',
|
|
591
|
+
full: 'max-w-[95vw] h-[90vh]'
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
return (
|
|
595
|
+
<div
|
|
596
|
+
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-slate-950/70 backdrop-blur-sm animate-in fade-in duration-200"
|
|
597
|
+
style={{ position: 'fixed', top: 0, right: 0, bottom: 0, left: 0, zIndex: 50 }}
|
|
598
|
+
onClick={closeOnBackdrop ? onClose : undefined}
|
|
599
|
+
onKeyDown={(e) => {
|
|
600
|
+
if (e.key === 'Escape') onClose();
|
|
601
|
+
}}
|
|
602
|
+
aria-modal="true"
|
|
603
|
+
role="dialog"
|
|
604
|
+
tabIndex={-1}
|
|
605
|
+
>
|
|
606
|
+
<div
|
|
607
|
+
ref={dialogRef}
|
|
608
|
+
onClick={(e) => e.stopPropagation()}
|
|
609
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
610
|
+
role="document"
|
|
611
|
+
className={`bg-slate-900 border border-slate-800 rounded-2xl shadow-2xl flex flex-col w-full overflow-hidden transition-all duration-200 transform scale-100 ${sizeStyles[size]} ${className}`}
|
|
612
|
+
>
|
|
613
|
+
{(title || description) && (
|
|
614
|
+
<div className="flex items-start justify-between p-5 border-b border-slate-800">
|
|
615
|
+
<div>
|
|
616
|
+
{title && <h3 className="text-lg font-bold text-slate-100 tracking-tight">{title}</h3>}
|
|
617
|
+
{description && <p className="text-xs text-slate-400 mt-1">{description}</p>}
|
|
618
|
+
</div>
|
|
619
|
+
<button
|
|
620
|
+
type="button"
|
|
621
|
+
onClick={onClose}
|
|
622
|
+
aria-label="Fechar diálogo"
|
|
623
|
+
className="text-slate-400 hover:text-white p-1 rounded-lg hover:bg-slate-800 transition-colors text-base font-mono leading-none"
|
|
624
|
+
>
|
|
625
|
+
✕
|
|
626
|
+
</button>
|
|
627
|
+
</div>
|
|
628
|
+
)}
|
|
629
|
+
<div className="p-5 overflow-y-auto flex-1 text-slate-200 text-sm leading-relaxed">{children}</div>
|
|
630
|
+
{footer && (
|
|
631
|
+
<div className="p-4 border-t border-slate-800 bg-slate-950/40 flex items-center justify-end gap-2">
|
|
632
|
+
{footer}
|
|
633
|
+
</div>
|
|
634
|
+
)}
|
|
635
|
+
</div>
|
|
636
|
+
</div>
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ============================================================================
|
|
641
|
+
// 8. DRAWER / SLIDE-OVER SHEET COMPONENT
|
|
642
|
+
// ============================================================================
|
|
643
|
+
export interface DrawerProps {
|
|
644
|
+
isOpen: boolean;
|
|
645
|
+
onClose: () => void;
|
|
646
|
+
title?: ReactNode;
|
|
647
|
+
description?: ReactNode;
|
|
648
|
+
position?: 'left' | 'right';
|
|
649
|
+
size?: 'sm' | 'md' | 'lg' | 'full';
|
|
650
|
+
children?: ReactNode;
|
|
651
|
+
footer?: ReactNode;
|
|
652
|
+
className?: string;
|
|
653
|
+
closeOnBackdrop?: boolean;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export function Drawer({
|
|
657
|
+
isOpen,
|
|
658
|
+
onClose,
|
|
659
|
+
title,
|
|
660
|
+
description,
|
|
661
|
+
position = 'right',
|
|
662
|
+
size = 'md',
|
|
663
|
+
children,
|
|
664
|
+
footer,
|
|
665
|
+
className = '',
|
|
666
|
+
closeOnBackdrop = true
|
|
667
|
+
}: DrawerProps) {
|
|
668
|
+
const drawerRef = useRef<HTMLDivElement>(null);
|
|
669
|
+
|
|
670
|
+
useEffect(() => {
|
|
671
|
+
if (!isOpen) return;
|
|
672
|
+
|
|
673
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
674
|
+
if (e.key === 'Escape') {
|
|
675
|
+
e.stopPropagation();
|
|
676
|
+
onClose();
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
const previousOverflow = document.body.style.overflow;
|
|
681
|
+
document.body.style.overflow = 'hidden';
|
|
682
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
683
|
+
|
|
684
|
+
return () => {
|
|
685
|
+
document.body.style.overflow = previousOverflow;
|
|
686
|
+
window.removeEventListener('keydown', handleKeyDown);
|
|
687
|
+
};
|
|
688
|
+
}, [isOpen, onClose]);
|
|
689
|
+
|
|
690
|
+
if (!isOpen) return null;
|
|
691
|
+
|
|
692
|
+
const sizeStyles = {
|
|
693
|
+
sm: 'max-w-sm',
|
|
694
|
+
md: 'max-w-md',
|
|
695
|
+
lg: 'max-w-2xl',
|
|
696
|
+
full: 'max-w-full'
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
const posStyles = {
|
|
700
|
+
right: 'inset-y-0 right-0',
|
|
701
|
+
left: 'inset-y-0 left-0'
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
return (
|
|
705
|
+
<div
|
|
706
|
+
className="fixed inset-0 z-50 flex bg-slate-950/70 backdrop-blur-sm animate-in fade-in duration-200"
|
|
707
|
+
style={{ position: 'fixed', top: 0, right: 0, bottom: 0, left: 0, zIndex: 50 }}
|
|
708
|
+
onClick={closeOnBackdrop ? onClose : undefined}
|
|
709
|
+
onKeyDown={(e) => {
|
|
710
|
+
if (e.key === 'Escape') onClose();
|
|
711
|
+
}}
|
|
712
|
+
aria-modal="true"
|
|
713
|
+
role="dialog"
|
|
714
|
+
tabIndex={-1}
|
|
715
|
+
>
|
|
716
|
+
<div
|
|
717
|
+
ref={drawerRef}
|
|
718
|
+
onClick={(e) => e.stopPropagation()}
|
|
719
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
720
|
+
role="document"
|
|
721
|
+
className={`fixed ${posStyles[position]} flex flex-col h-full w-full bg-slate-900 border-${position === 'right' ? 'l' : 'r'} border-slate-800 shadow-2xl overflow-hidden transition-transform duration-300 ${sizeStyles[size]} ${className}`}
|
|
722
|
+
style={{ position: 'fixed', top: 0, bottom: 0, [position]: 0 }}
|
|
723
|
+
>
|
|
724
|
+
<div className="flex items-start justify-between p-5 border-b border-slate-800">
|
|
725
|
+
<div>
|
|
726
|
+
{title && <h3 className="text-lg font-bold text-slate-100 tracking-tight">{title}</h3>}
|
|
727
|
+
{description && <p className="text-xs text-slate-400 mt-1">{description}</p>}
|
|
728
|
+
</div>
|
|
729
|
+
<button
|
|
730
|
+
type="button"
|
|
731
|
+
onClick={onClose}
|
|
732
|
+
aria-label="Fechar painel lateral"
|
|
733
|
+
className="text-slate-400 hover:text-white p-1 rounded-lg hover:bg-slate-800 transition-colors text-base font-mono leading-none"
|
|
734
|
+
>
|
|
735
|
+
✕
|
|
736
|
+
</button>
|
|
737
|
+
</div>
|
|
738
|
+
<div className="p-5 overflow-y-auto flex-1 text-slate-200 text-sm leading-relaxed">{children}</div>
|
|
739
|
+
{footer && (
|
|
740
|
+
<div className="p-4 border-t border-slate-800 bg-slate-950/40 flex items-center justify-end gap-2">
|
|
741
|
+
{footer}
|
|
742
|
+
</div>
|
|
743
|
+
)}
|
|
744
|
+
</div>
|
|
745
|
+
</div>
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ============================================================================
|
|
750
|
+
// 9. TABS ACCESSIBLE COMPONENT
|
|
751
|
+
// ============================================================================
|
|
752
|
+
interface TabsContextValue {
|
|
753
|
+
value: string;
|
|
754
|
+
setValue: (val: string) => void;
|
|
755
|
+
}
|
|
756
|
+
const TabsContext = createContext<TabsContextValue | null>(null);
|
|
757
|
+
|
|
758
|
+
export interface TabsProps {
|
|
759
|
+
defaultValue?: string;
|
|
760
|
+
value?: string;
|
|
761
|
+
onValueChange?: (val: string) => void;
|
|
762
|
+
syncQueryParam?: string;
|
|
763
|
+
children?: ReactNode;
|
|
764
|
+
className?: string;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
export function Tabs({
|
|
768
|
+
defaultValue = '',
|
|
769
|
+
value: controlledValue,
|
|
770
|
+
onValueChange,
|
|
771
|
+
syncQueryParam,
|
|
772
|
+
children,
|
|
773
|
+
className = ''
|
|
774
|
+
}: TabsProps) {
|
|
775
|
+
const [internalValue, setInternalValue] = useState<string>(() => {
|
|
776
|
+
if (typeof window !== 'undefined' && syncQueryParam) {
|
|
777
|
+
const sp = new URLSearchParams(window.location.search);
|
|
778
|
+
const fromUrl = sp.get(syncQueryParam);
|
|
779
|
+
if (fromUrl) return fromUrl;
|
|
780
|
+
}
|
|
781
|
+
return defaultValue;
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
const activeValue = controlledValue !== undefined ? controlledValue : internalValue;
|
|
785
|
+
|
|
786
|
+
const handleValueChange = useCallback(
|
|
787
|
+
(newVal: string) => {
|
|
788
|
+
if (controlledValue === undefined) {
|
|
789
|
+
setInternalValue(newVal);
|
|
790
|
+
}
|
|
791
|
+
onValueChange?.(newVal);
|
|
792
|
+
|
|
793
|
+
if (typeof window !== 'undefined' && syncQueryParam) {
|
|
794
|
+
const url = new URL(window.location.href);
|
|
795
|
+
url.searchParams.set(syncQueryParam, newVal);
|
|
796
|
+
window.history.replaceState({}, '', url.toString());
|
|
797
|
+
}
|
|
798
|
+
},
|
|
799
|
+
[controlledValue, onValueChange, syncQueryParam]
|
|
800
|
+
);
|
|
801
|
+
|
|
802
|
+
return (
|
|
803
|
+
<TabsContext.Provider value={{ value: activeValue, setValue: handleValueChange }}>
|
|
804
|
+
<div className={`space-y-4 ${className}`}>{children}</div>
|
|
805
|
+
</TabsContext.Provider>
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
export interface TabListProps {
|
|
810
|
+
children?: ReactNode;
|
|
811
|
+
className?: string;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
export function TabList({ children, className = '' }: TabListProps) {
|
|
815
|
+
return (
|
|
816
|
+
<div
|
|
817
|
+
role="tablist"
|
|
818
|
+
className={`flex items-center gap-1.5 p-1 bg-slate-900 border border-slate-800 rounded-xl overflow-x-auto ${className}`}
|
|
819
|
+
>
|
|
820
|
+
{children}
|
|
821
|
+
</div>
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
export interface TabProps {
|
|
826
|
+
value: string;
|
|
827
|
+
children?: ReactNode;
|
|
828
|
+
disabled?: boolean;
|
|
829
|
+
className?: string;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
export function Tab({ value, children, disabled = false, className = '' }: TabProps) {
|
|
833
|
+
const ctx = useContext(TabsContext);
|
|
834
|
+
if (!ctx) throw new Error('<Tab> must be used within <Tabs>');
|
|
835
|
+
|
|
836
|
+
const isSelected = ctx.value === value;
|
|
837
|
+
|
|
838
|
+
return (
|
|
839
|
+
<button
|
|
840
|
+
type="button"
|
|
841
|
+
role="tab"
|
|
842
|
+
aria-selected={isSelected}
|
|
843
|
+
disabled={disabled}
|
|
844
|
+
onClick={() => ctx.setValue(value)}
|
|
845
|
+
className={`px-3.5 py-1.5 text-xs font-semibold rounded-lg transition-all duration-150 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 whitespace-nowrap ${
|
|
846
|
+
isSelected
|
|
847
|
+
? 'bg-slate-800 text-cyan-400 shadow-sm border border-slate-700/80 font-bold'
|
|
848
|
+
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
|
849
|
+
} ${className}`}
|
|
850
|
+
>
|
|
851
|
+
{children}
|
|
852
|
+
</button>
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
export interface TabPanelProps {
|
|
857
|
+
value: string;
|
|
858
|
+
children?: ReactNode;
|
|
859
|
+
className?: string;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
export function TabPanel({ value, children, className = '' }: TabPanelProps) {
|
|
863
|
+
const ctx = useContext(TabsContext);
|
|
864
|
+
if (!ctx) throw new Error('<TabPanel> must be used within <Tabs>');
|
|
865
|
+
|
|
866
|
+
if (ctx.value !== value) return null;
|
|
867
|
+
|
|
868
|
+
return (
|
|
869
|
+
<div role="tabpanel" className={`focus:outline-none ${className}`}>
|
|
870
|
+
{children}
|
|
871
|
+
</div>
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// ============================================================================
|
|
876
|
+
// 10. DROPDOWN MENU ACCESSIBLE COMPONENT
|
|
877
|
+
// ============================================================================
|
|
878
|
+
export interface DropdownMenuProps {
|
|
879
|
+
trigger: ReactNode;
|
|
880
|
+
align?: 'left' | 'right';
|
|
881
|
+
children?: ReactNode;
|
|
882
|
+
className?: string;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
export function DropdownMenu({ trigger, align = 'right', children, className = '' }: DropdownMenuProps) {
|
|
886
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
887
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
888
|
+
|
|
889
|
+
useEffect(() => {
|
|
890
|
+
if (!isOpen) return;
|
|
891
|
+
|
|
892
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
893
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
894
|
+
setIsOpen(false);
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
899
|
+
if (e.key === 'Escape') {
|
|
900
|
+
setIsOpen(false);
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
905
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
906
|
+
|
|
907
|
+
return () => {
|
|
908
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
909
|
+
window.removeEventListener('keydown', handleKeyDown);
|
|
910
|
+
};
|
|
911
|
+
}, [isOpen]);
|
|
912
|
+
|
|
913
|
+
const alignStyles = {
|
|
914
|
+
left: 'left-0',
|
|
915
|
+
right: 'right-0'
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
return (
|
|
919
|
+
<div ref={containerRef} className={`relative inline-block text-left ${className}`}>
|
|
920
|
+
{/* biome-ignore lint/a11y/useSemanticElements: custom trigger wrapper avoids button-inside-button HTML nesting */}
|
|
921
|
+
<div
|
|
922
|
+
role="button"
|
|
923
|
+
tabIndex={0}
|
|
924
|
+
onClick={() => setIsOpen((prev) => !prev)}
|
|
925
|
+
onKeyDown={(e) => {
|
|
926
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
927
|
+
e.preventDefault();
|
|
928
|
+
setIsOpen((prev) => !prev);
|
|
929
|
+
}
|
|
930
|
+
}}
|
|
931
|
+
className="text-left bg-transparent border-0 p-0 m-0 cursor-pointer inline-block"
|
|
932
|
+
>
|
|
933
|
+
{trigger}
|
|
934
|
+
</div>
|
|
935
|
+
{isOpen && (
|
|
936
|
+
<div
|
|
937
|
+
role="menu"
|
|
938
|
+
aria-orientation="vertical"
|
|
939
|
+
tabIndex={-1}
|
|
940
|
+
className={`absolute ${alignStyles[align]} mt-2 w-52 rounded-xl bg-slate-900 border border-slate-800 shadow-2xl p-1 z-50 animate-in fade-in duration-150`}
|
|
941
|
+
>
|
|
942
|
+
{children}
|
|
943
|
+
</div>
|
|
944
|
+
)}
|
|
945
|
+
</div>
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
export interface DropdownItemProps {
|
|
950
|
+
onClick?: () => void;
|
|
951
|
+
variant?: 'default' | 'danger';
|
|
952
|
+
icon?: ReactNode;
|
|
953
|
+
disabled?: boolean;
|
|
954
|
+
children?: ReactNode;
|
|
955
|
+
className?: string;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
export function DropdownItem({
|
|
959
|
+
onClick,
|
|
960
|
+
variant = 'default',
|
|
961
|
+
icon,
|
|
962
|
+
disabled = false,
|
|
963
|
+
children,
|
|
964
|
+
className = ''
|
|
965
|
+
}: DropdownItemProps) {
|
|
966
|
+
const variantStyles = {
|
|
967
|
+
default: 'text-slate-300 hover:text-white hover:bg-slate-800',
|
|
968
|
+
danger: 'text-rose-400 hover:text-rose-300 hover:bg-rose-950/50'
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
return (
|
|
972
|
+
<button
|
|
973
|
+
type="button"
|
|
974
|
+
role="menuitem"
|
|
975
|
+
disabled={disabled}
|
|
976
|
+
onClick={onClick}
|
|
977
|
+
className={`w-full flex items-center gap-2.5 px-3 py-2 text-xs font-medium rounded-lg transition-colors text-left cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 ${variantStyles[variant]} ${className}`}
|
|
978
|
+
>
|
|
979
|
+
{icon && <span className="shrink-0">{icon}</span>}
|
|
980
|
+
<span className="flex-1 truncate">{children}</span>
|
|
981
|
+
</button>
|
|
982
|
+
);
|
|
983
|
+
}
|
package/src/client/context.tsx
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { createContext, type ReactNode, useContext } from 'react';
|
|
2
2
|
import type { SessionContext } from '../core/session-context';
|
|
3
|
+
import { SliceErrorBoundary, type SliceErrorBoundaryProps } from './error-boundary';
|
|
3
4
|
|
|
4
5
|
export interface SynapseContextValue {
|
|
5
6
|
props: Record<string, unknown>;
|
|
6
7
|
session?: SessionContext;
|
|
8
|
+
url?: string;
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
const SynapseContext = createContext<SynapseContextValue | null>(null);
|
|
@@ -11,13 +13,24 @@ const SynapseContext = createContext<SynapseContextValue | null>(null);
|
|
|
11
13
|
export interface SynapseProviderProps {
|
|
12
14
|
props?: Record<string, unknown>;
|
|
13
15
|
session?: SessionContext;
|
|
16
|
+
url?: string;
|
|
17
|
+
/** Defaults to true: a slice render error shows a fallback instead of a dead tree. */
|
|
18
|
+
errorBoundary?: boolean;
|
|
19
|
+
errorBoundaryFallback?: SliceErrorBoundaryProps['fallback'];
|
|
14
20
|
children?: ReactNode;
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
/**
|
|
18
24
|
* SynapseProvider delivers per-request SSR data isolation and seamless client hydration.
|
|
19
25
|
*/
|
|
20
|
-
export function SynapseProvider({
|
|
26
|
+
export function SynapseProvider({
|
|
27
|
+
props,
|
|
28
|
+
session,
|
|
29
|
+
url,
|
|
30
|
+
errorBoundary = true,
|
|
31
|
+
errorBoundaryFallback,
|
|
32
|
+
children
|
|
33
|
+
}: SynapseProviderProps) {
|
|
21
34
|
// On client, if props not passed directly, hydrate from window.__SYNAPSE_PROPS__
|
|
22
35
|
const clientProps =
|
|
23
36
|
props ??
|
|
@@ -27,10 +40,17 @@ export function SynapseProvider({ props, session, children }: SynapseProviderPro
|
|
|
27
40
|
|
|
28
41
|
const value: SynapseContextValue = {
|
|
29
42
|
props: clientProps,
|
|
30
|
-
session
|
|
43
|
+
session,
|
|
44
|
+
url
|
|
31
45
|
};
|
|
32
46
|
|
|
33
|
-
|
|
47
|
+
const content = errorBoundary ? (
|
|
48
|
+
<SliceErrorBoundary fallback={errorBoundaryFallback}>{children}</SliceErrorBoundary>
|
|
49
|
+
) : (
|
|
50
|
+
children
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return <SynapseContext.Provider value={value}>{content}</SynapseContext.Provider>;
|
|
34
54
|
}
|
|
35
55
|
|
|
36
56
|
export function useSynapseContext(): SynapseContextValue {
|