@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.
Files changed (38) hide show
  1. package/README.md +4 -3
  2. package/bin/synapse.ts +74 -2
  3. package/package.json +2 -2
  4. package/src/client/components.tsx +459 -1
  5. package/src/client/context.tsx +23 -3
  6. package/src/client/error-boundary.tsx +59 -0
  7. package/src/client/hooks.ts +100 -1
  8. package/src/client/toast.tsx +219 -0
  9. package/src/client/upload.ts +153 -0
  10. package/src/client.ts +37 -2
  11. package/src/compiler/client-bundler.ts +94 -2
  12. package/src/compiler/context-bench.ts +2 -0
  13. package/src/compiler/contract.ts +1 -1
  14. package/src/compiler/oracle-runner.ts +10 -1
  15. package/src/compiler/scaffolder.ts +113 -4
  16. package/src/compiler/slice-splitter.ts +21 -3
  17. package/src/compiler/slice-templates.ts +234 -10
  18. package/src/compiler/typescript-api.ts +33 -0
  19. package/src/core/action-context.ts +50 -1
  20. package/src/core/cron-parser.ts +167 -0
  21. package/src/core/database-client.ts +35 -0
  22. package/src/core/index.ts +2 -0
  23. package/src/core/jobs.ts +37 -0
  24. package/src/core/oauth-client.ts +322 -0
  25. package/src/core/postgres-client.ts +37 -0
  26. package/src/core/postgres-queue.ts +51 -2
  27. package/src/core/query-builder.ts +166 -34
  28. package/src/core/session-context.ts +45 -4
  29. package/src/core/session-token.ts +16 -1
  30. package/src/core/sqlite-client.ts +20 -0
  31. package/src/mcp/server.ts +257 -1
  32. package/src/mcp/tools.ts +92 -1
  33. package/src/project-status.ts +103 -0
  34. package/src/runtime/client-entry.ts +11 -5
  35. package/src/runtime/queue-engine.ts +120 -5
  36. package/src/runtime/security-headers.ts +35 -0
  37. package/src/runtime/server.ts +300 -58
  38. 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 18 native tools (11 Synapse + 7 Jev) · slice invariants under `bun:test` with per-invariant reporting · PostgreSQL parity · real concurrency & load stress benchmarks ·
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.9.0",
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": "^5.8.2"
59
+ "typescript": "~5.9.3"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/bun": "^1.4.2",
@@ -1,4 +1,13 @@
1
- import React, { type ReactNode, useMemo, useState } from '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
+ }
@@ -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({ props, session, children }: SynapseProviderProps) {
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
- return <SynapseContext.Provider value={value}>{children}</SynapseContext.Provider>;
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 {