@assistkick/create 1.0.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 (178) hide show
  1. package/dist/bin/create.d.ts +2 -0
  2. package/dist/bin/create.js +25 -0
  3. package/dist/bin/create.js.map +1 -0
  4. package/dist/src/scaffolder.d.ts +22 -0
  5. package/dist/src/scaffolder.js +120 -0
  6. package/dist/src/scaffolder.js.map +1 -0
  7. package/package.json +24 -0
  8. package/templates/product-system/.env.example +8 -0
  9. package/templates/product-system/CLAUDE.md +45 -0
  10. package/templates/product-system/package.json +32 -0
  11. package/templates/product-system/packages/backend/package.json +37 -0
  12. package/templates/product-system/packages/backend/src/middleware/auth_middleware.test.ts +86 -0
  13. package/templates/product-system/packages/backend/src/middleware/auth_middleware.ts +35 -0
  14. package/templates/product-system/packages/backend/src/routes/auth.ts +463 -0
  15. package/templates/product-system/packages/backend/src/routes/coherence.ts +187 -0
  16. package/templates/product-system/packages/backend/src/routes/graph.ts +67 -0
  17. package/templates/product-system/packages/backend/src/routes/kanban.ts +201 -0
  18. package/templates/product-system/packages/backend/src/routes/pipeline.ts +41 -0
  19. package/templates/product-system/packages/backend/src/routes/projects.ts +122 -0
  20. package/templates/product-system/packages/backend/src/routes/users.ts +97 -0
  21. package/templates/product-system/packages/backend/src/server.ts +159 -0
  22. package/templates/product-system/packages/backend/src/services/auth_service.test.ts +115 -0
  23. package/templates/product-system/packages/backend/src/services/auth_service.ts +82 -0
  24. package/templates/product-system/packages/backend/src/services/coherence-review.ts +339 -0
  25. package/templates/product-system/packages/backend/src/services/email_service.ts +75 -0
  26. package/templates/product-system/packages/backend/src/services/init.ts +80 -0
  27. package/templates/product-system/packages/backend/src/services/invitation_service.test.ts +235 -0
  28. package/templates/product-system/packages/backend/src/services/invitation_service.ts +193 -0
  29. package/templates/product-system/packages/backend/src/services/password_reset_service.test.ts +151 -0
  30. package/templates/product-system/packages/backend/src/services/password_reset_service.ts +135 -0
  31. package/templates/product-system/packages/backend/src/services/project_service.test.ts +215 -0
  32. package/templates/product-system/packages/backend/src/services/project_service.ts +171 -0
  33. package/templates/product-system/packages/backend/src/services/pty_session_manager.test.ts +88 -0
  34. package/templates/product-system/packages/backend/src/services/pty_session_manager.ts +279 -0
  35. package/templates/product-system/packages/backend/src/services/terminal_ws_handler.ts +133 -0
  36. package/templates/product-system/packages/backend/src/services/user_management_service.test.ts +158 -0
  37. package/templates/product-system/packages/backend/src/services/user_management_service.ts +128 -0
  38. package/templates/product-system/packages/backend/tsconfig.json +22 -0
  39. package/templates/product-system/packages/frontend/index.html +13 -0
  40. package/templates/product-system/packages/frontend/package-lock.json +2666 -0
  41. package/templates/product-system/packages/frontend/package.json +30 -0
  42. package/templates/product-system/packages/frontend/public/favicon.svg +16 -0
  43. package/templates/product-system/packages/frontend/src/App.tsx +29 -0
  44. package/templates/product-system/packages/frontend/src/api/client.ts +386 -0
  45. package/templates/product-system/packages/frontend/src/api/client_projects.test.ts +104 -0
  46. package/templates/product-system/packages/frontend/src/api/client_refresh.test.ts +145 -0
  47. package/templates/product-system/packages/frontend/src/components/CoherenceView.tsx +414 -0
  48. package/templates/product-system/packages/frontend/src/components/GraphLegend.tsx +124 -0
  49. package/templates/product-system/packages/frontend/src/components/GraphSettings.tsx +112 -0
  50. package/templates/product-system/packages/frontend/src/components/GraphView.tsx +370 -0
  51. package/templates/product-system/packages/frontend/src/components/InviteUserDialog.tsx +85 -0
  52. package/templates/product-system/packages/frontend/src/components/KanbanView.tsx +470 -0
  53. package/templates/product-system/packages/frontend/src/components/LoginPage.tsx +116 -0
  54. package/templates/product-system/packages/frontend/src/components/ProjectSelector.tsx +187 -0
  55. package/templates/product-system/packages/frontend/src/components/QaIssueSheet.tsx +192 -0
  56. package/templates/product-system/packages/frontend/src/components/SidePanel.tsx +231 -0
  57. package/templates/product-system/packages/frontend/src/components/TerminalView.tsx +200 -0
  58. package/templates/product-system/packages/frontend/src/components/Toolbar.tsx +84 -0
  59. package/templates/product-system/packages/frontend/src/components/UsersView.tsx +249 -0
  60. package/templates/product-system/packages/frontend/src/constants/graph.ts +191 -0
  61. package/templates/product-system/packages/frontend/src/hooks/useAuth.tsx +54 -0
  62. package/templates/product-system/packages/frontend/src/hooks/useGraph.ts +27 -0
  63. package/templates/product-system/packages/frontend/src/hooks/useKanban.ts +21 -0
  64. package/templates/product-system/packages/frontend/src/hooks/useProjects.ts +86 -0
  65. package/templates/product-system/packages/frontend/src/hooks/useTheme.ts +26 -0
  66. package/templates/product-system/packages/frontend/src/hooks/useToast.tsx +62 -0
  67. package/templates/product-system/packages/frontend/src/hooks/use_projects_logic.test.ts +61 -0
  68. package/templates/product-system/packages/frontend/src/main.tsx +12 -0
  69. package/templates/product-system/packages/frontend/src/pages/accept_invitation_page.tsx +167 -0
  70. package/templates/product-system/packages/frontend/src/pages/forgot_password_page.tsx +100 -0
  71. package/templates/product-system/packages/frontend/src/pages/register_page.tsx +137 -0
  72. package/templates/product-system/packages/frontend/src/pages/reset_password_page.tsx +146 -0
  73. package/templates/product-system/packages/frontend/src/routes/ProtectedRoute.tsx +12 -0
  74. package/templates/product-system/packages/frontend/src/routes/accept_invitation.tsx +14 -0
  75. package/templates/product-system/packages/frontend/src/routes/dashboard.tsx +221 -0
  76. package/templates/product-system/packages/frontend/src/routes/forgot_password.tsx +13 -0
  77. package/templates/product-system/packages/frontend/src/routes/login.tsx +14 -0
  78. package/templates/product-system/packages/frontend/src/routes/register.tsx +14 -0
  79. package/templates/product-system/packages/frontend/src/routes/reset_password.tsx +13 -0
  80. package/templates/product-system/packages/frontend/src/styles/index.css +3358 -0
  81. package/templates/product-system/packages/frontend/src/utils/auth_validation.test.ts +51 -0
  82. package/templates/product-system/packages/frontend/src/utils/auth_validation.ts +19 -0
  83. package/templates/product-system/packages/frontend/src/utils/login_validation.test.ts +61 -0
  84. package/templates/product-system/packages/frontend/src/utils/login_validation.ts +24 -0
  85. package/templates/product-system/packages/frontend/src/utils/logout.test.ts +63 -0
  86. package/templates/product-system/packages/frontend/src/utils/node_sizing.test.ts +62 -0
  87. package/templates/product-system/packages/frontend/src/utils/node_sizing.ts +24 -0
  88. package/templates/product-system/packages/frontend/src/utils/task_status.test.ts +53 -0
  89. package/templates/product-system/packages/frontend/src/utils/task_status.ts +14 -0
  90. package/templates/product-system/packages/frontend/tsconfig.json +21 -0
  91. package/templates/product-system/packages/frontend/vite.config.ts +20 -0
  92. package/templates/product-system/packages/shared/.env.example +3 -0
  93. package/templates/product-system/packages/shared/README.md +1 -0
  94. package/templates/product-system/packages/shared/db/migrate.ts +32 -0
  95. package/templates/product-system/packages/shared/db/migrations/0000_dashing_gorgon.sql +128 -0
  96. package/templates/product-system/packages/shared/db/migrations/meta/0000_snapshot.json +819 -0
  97. package/templates/product-system/packages/shared/db/migrations/meta/_journal.json +13 -0
  98. package/templates/product-system/packages/shared/db/schema.ts +137 -0
  99. package/templates/product-system/packages/shared/drizzle.config.js +14 -0
  100. package/templates/product-system/packages/shared/lib/claude-service.ts +215 -0
  101. package/templates/product-system/packages/shared/lib/coherence.ts +278 -0
  102. package/templates/product-system/packages/shared/lib/completeness.ts +30 -0
  103. package/templates/product-system/packages/shared/lib/constants.ts +327 -0
  104. package/templates/product-system/packages/shared/lib/db.ts +81 -0
  105. package/templates/product-system/packages/shared/lib/git_workflow.ts +110 -0
  106. package/templates/product-system/packages/shared/lib/graph.ts +186 -0
  107. package/templates/product-system/packages/shared/lib/kanban.ts +161 -0
  108. package/templates/product-system/packages/shared/lib/markdown.ts +205 -0
  109. package/templates/product-system/packages/shared/lib/pipeline-state-store.ts +124 -0
  110. package/templates/product-system/packages/shared/lib/pipeline.ts +489 -0
  111. package/templates/product-system/packages/shared/lib/prompt_builder.ts +170 -0
  112. package/templates/product-system/packages/shared/lib/relevance_search.ts +159 -0
  113. package/templates/product-system/packages/shared/lib/session.ts +152 -0
  114. package/templates/product-system/packages/shared/lib/validator.ts +117 -0
  115. package/templates/product-system/packages/shared/lib/work_summary_parser.ts +130 -0
  116. package/templates/product-system/packages/shared/package.json +30 -0
  117. package/templates/product-system/packages/shared/scripts/assign-project.ts +52 -0
  118. package/templates/product-system/packages/shared/tools/add_edge.ts +61 -0
  119. package/templates/product-system/packages/shared/tools/add_node.ts +101 -0
  120. package/templates/product-system/packages/shared/tools/end_session.ts +87 -0
  121. package/templates/product-system/packages/shared/tools/get_gaps.ts +87 -0
  122. package/templates/product-system/packages/shared/tools/get_kanban.ts +125 -0
  123. package/templates/product-system/packages/shared/tools/get_node.ts +78 -0
  124. package/templates/product-system/packages/shared/tools/get_status.ts +98 -0
  125. package/templates/product-system/packages/shared/tools/migrate_to_turso.ts +385 -0
  126. package/templates/product-system/packages/shared/tools/move_card.ts +143 -0
  127. package/templates/product-system/packages/shared/tools/rebuild_index.ts +77 -0
  128. package/templates/product-system/packages/shared/tools/remove_edge.ts +59 -0
  129. package/templates/product-system/packages/shared/tools/remove_node.ts +96 -0
  130. package/templates/product-system/packages/shared/tools/resolve_question.ts +75 -0
  131. package/templates/product-system/packages/shared/tools/search_nodes.ts +106 -0
  132. package/templates/product-system/packages/shared/tools/start_session.ts +144 -0
  133. package/templates/product-system/packages/shared/tools/update_node.ts +133 -0
  134. package/templates/product-system/packages/shared/tsconfig.json +24 -0
  135. package/templates/product-system/pnpm-workspace.yaml +2 -0
  136. package/templates/product-system/smoke_test.ts +219 -0
  137. package/templates/product-system/tests/coherence_review.test.ts +562 -0
  138. package/templates/product-system/tests/db_sqlite_fallback.test.ts +75 -0
  139. package/templates/product-system/tests/edge_type_color_coding.test.ts +147 -0
  140. package/templates/product-system/tests/emit-tool-use-events.test.ts +85 -0
  141. package/templates/product-system/tests/feature_kind.test.ts +139 -0
  142. package/templates/product-system/tests/gap_indicators.test.ts +199 -0
  143. package/templates/product-system/tests/graceful_init.test.ts +142 -0
  144. package/templates/product-system/tests/graph_legend.test.ts +314 -0
  145. package/templates/product-system/tests/graph_settings_sheet.test.ts +804 -0
  146. package/templates/product-system/tests/hide_defined_filter.test.ts +205 -0
  147. package/templates/product-system/tests/kanban.test.ts +529 -0
  148. package/templates/product-system/tests/neighborhood_focus.test.ts +132 -0
  149. package/templates/product-system/tests/node_search.test.ts +340 -0
  150. package/templates/product-system/tests/node_sizing.test.ts +170 -0
  151. package/templates/product-system/tests/node_type_toggle_filters.test.ts +285 -0
  152. package/templates/product-system/tests/node_type_visual_encoding.test.ts +103 -0
  153. package/templates/product-system/tests/pipeline-state-store.test.ts +268 -0
  154. package/templates/product-system/tests/pipeline-unit.test.ts +593 -0
  155. package/templates/product-system/tests/pipeline.test.ts +195 -0
  156. package/templates/product-system/tests/pipeline_stats_all_cards.test.ts +193 -0
  157. package/templates/product-system/tests/play_all.test.ts +296 -0
  158. package/templates/product-system/tests/qa_issue_sheet.test.ts +464 -0
  159. package/templates/product-system/tests/relevance_search.test.ts +186 -0
  160. package/templates/product-system/tests/search_reorder.test.ts +88 -0
  161. package/templates/product-system/tests/serve_ui.test.ts +281 -0
  162. package/templates/product-system/tests/serve_ui_drizzle.test.ts +114 -0
  163. package/templates/product-system/tests/session_context_recall.test.ts +135 -0
  164. package/templates/product-system/tests/side_panel.test.ts +345 -0
  165. package/templates/product-system/tests/spec_completeness_label.test.ts +69 -0
  166. package/templates/product-system/tests/url_routing_test.ts +122 -0
  167. package/templates/product-system/tests/user_login.test.ts +150 -0
  168. package/templates/product-system/tests/user_registration.test.ts +205 -0
  169. package/templates/product-system/tests/web_terminal.test.ts +572 -0
  170. package/templates/product-system/tests/work_summary.test.ts +211 -0
  171. package/templates/product-system/tests/zoom_pan.test.ts +43 -0
  172. package/templates/product-system/tsconfig.json +24 -0
  173. package/templates/skills/product-bootstrap/SKILL.md +312 -0
  174. package/templates/skills/product-code-reviewer/SKILL.md +147 -0
  175. package/templates/skills/product-debugger/SKILL.md +206 -0
  176. package/templates/skills/product-debugger/references/agent-browser.md +1156 -0
  177. package/templates/skills/product-developer/SKILL.md +182 -0
  178. package/templates/skills/product-interview/SKILL.md +220 -0
@@ -0,0 +1,112 @@
1
+ import React, { useState, useEffect, useRef, useCallback } from 'react';
2
+ import type { GraphViewHandle } from './GraphView';
3
+
4
+ const STORAGE_KEY = 'graph-settings';
5
+ const DEFAULTS = { edgeLabels: false, hideDefined: false, allNodeNames: false, featureNodeNames: true, absoluteSizing: false };
6
+
7
+ interface GraphSettingsProps {
8
+ isOpen: boolean;
9
+ onClose: () => void;
10
+ graphRef: React.RefObject<GraphViewHandle | null>;
11
+ }
12
+
13
+ export function GraphSettings({ isOpen, onClose, graphRef }: GraphSettingsProps) {
14
+ const sheetRef = useRef<HTMLDivElement>(null);
15
+ const [settings, setSettings] = useState(() => {
16
+ try {
17
+ const stored = localStorage.getItem(STORAGE_KEY);
18
+ if (stored) {
19
+ const parsed = JSON.parse(stored);
20
+ return { ...DEFAULTS, ...parsed };
21
+ }
22
+ } catch { /* ignore */ }
23
+ return { ...DEFAULTS };
24
+ });
25
+
26
+ const applySetting = useCallback((key: string, value: boolean) => {
27
+ const graph = graphRef.current;
28
+ if (!graph) return;
29
+ switch (key) {
30
+ case 'edgeLabels':
31
+ if (graph.edgeLabelsVisible !== value) graph.toggleEdgeLabels();
32
+ break;
33
+ case 'hideDefined':
34
+ if (graph.hideDefinedActive !== value) graph.toggleHideDefined();
35
+ break;
36
+ case 'allNodeNames':
37
+ case 'featureNodeNames':
38
+ graph.setNodeNameVisibility(
39
+ key === 'allNodeNames' ? value : settings.allNodeNames,
40
+ key === 'featureNodeNames' ? value : settings.featureNodeNames,
41
+ );
42
+ break;
43
+ case 'absoluteSizing':
44
+ graph.setAbsoluteSizing(value);
45
+ break;
46
+ }
47
+ }, [graphRef, settings]);
48
+
49
+ // Apply defaults on mount
50
+ useEffect(() => {
51
+ Object.entries(settings).forEach(([key, value]) => applySetting(key, value as boolean));
52
+ }, []);
53
+
54
+ const handleToggle = (key: string) => {
55
+ const newValue = !settings[key];
56
+ const newSettings = { ...settings, [key]: newValue };
57
+ setSettings(newSettings);
58
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(newSettings)); } catch { /* ignore */ }
59
+ applySetting(key, newValue);
60
+ };
61
+
62
+ // Close on outside click (use mousedown to avoid conflict with the toggle click)
63
+ useEffect(() => {
64
+ if (!isOpen) return;
65
+ const handleMouseDown = (e: MouseEvent) => {
66
+ const target = e.target as HTMLElement;
67
+ // Ignore clicks on the gear button itself — the toggle handler manages that
68
+ if (target.closest('.settings-gear-btn')) return;
69
+ if (sheetRef.current && !sheetRef.current.contains(target)) {
70
+ onClose();
71
+ }
72
+ };
73
+ // Defer attachment so the current click event doesn't immediately trigger close
74
+ const id = requestAnimationFrame(() => {
75
+ document.addEventListener('mousedown', handleMouseDown);
76
+ });
77
+ return () => {
78
+ cancelAnimationFrame(id);
79
+ document.removeEventListener('mousedown', handleMouseDown);
80
+ };
81
+ }, [isOpen, onClose]);
82
+
83
+ const toggleItems = [
84
+ { key: 'edgeLabels', label: 'Edge Labels' },
85
+ { key: 'hideDefined', label: 'Hide Defined' },
86
+ { key: 'allNodeNames', label: 'All Node Names' },
87
+ { key: 'featureNodeNames', label: 'Feature Node Names' },
88
+ { key: 'absoluteSizing', label: 'Absolute Node Sizing' },
89
+ ];
90
+
91
+ return (
92
+ <div className={`settings-sheet${isOpen ? ' open' : ''}`} ref={sheetRef}>
93
+ <div className="settings-header">
94
+ <span className="settings-title">Settings</span>
95
+ <button className="settings-close" onClick={onClose}>&times;</button>
96
+ </div>
97
+ <div className="settings-body">
98
+ {toggleItems.map(({ key, label }) => (
99
+ <label key={key} className="settings-row">
100
+ <span className="settings-label">{label}</span>
101
+ <input
102
+ type="checkbox"
103
+ className="settings-toggle"
104
+ checked={settings[key]}
105
+ onChange={() => handleToggle(key)}
106
+ />
107
+ </label>
108
+ ))}
109
+ </div>
110
+ </div>
111
+ );
112
+ }
@@ -0,0 +1,370 @@
1
+ /**
2
+ * D3 force-directed graph — React provides the container, D3 owns the SVG.
3
+ */
4
+ import React, { useRef, useEffect, useImperativeHandle, forwardRef } from 'react';
5
+ import * as d3 from 'd3';
6
+ import { NODE_COLORS, EDGE_COLORS, EDGE_LABELS, NODE_SHAPES, nodeShapePath } from '../constants/graph';
7
+ import { nodeRadius as calcNodeRadius } from '../utils/node_sizing';
8
+
9
+ export interface GraphViewHandle {
10
+ fitToView: () => void;
11
+ focusNode: (nodeId: string) => void;
12
+ setHiddenTypes: (types: Set<string>) => void;
13
+ highlightBySearch: (query: string) => void;
14
+ clearSelection: () => void;
15
+ getNodeById: (nodeId: string) => any;
16
+ toggleEdgeLabels: () => boolean;
17
+ toggleHideDefined: () => boolean;
18
+ setNodeNameVisibility: (all: boolean, feature: boolean) => void;
19
+ setAbsoluteSizing: (enabled: boolean) => void;
20
+ edgeLabelsVisible: boolean;
21
+ hideDefinedActive: boolean;
22
+ absoluteSizingActive: boolean;
23
+ }
24
+
25
+ interface GraphViewProps {
26
+ graphData: any;
27
+ onNodeClick: (node: any) => void;
28
+ visible: boolean;
29
+ }
30
+
31
+ export const GraphView = forwardRef<GraphViewHandle, GraphViewProps>(
32
+ ({ graphData, onNodeClick, visible }, ref) => {
33
+ const containerRef = useRef<HTMLDivElement>(null);
34
+ const stateRef = useRef<any>({
35
+ simulation: null, svg: null, zoom: null, zoomGroup: null,
36
+ nodesG: null, linksG: null, labelsG: null, edgeLabelsG: null,
37
+ gapRingsG: null, gapIconsG: null,
38
+ nodes: null, links: null, selectedNodeId: null,
39
+ edgeLabelsVisible: true, hideDefinedActive: false,
40
+ allNodeNamesVisible: true, featureNodeNamesVisible: true,
41
+ hiddenTypes: new Set(), searchQuery: null,
42
+ absoluteSizing: false,
43
+ });
44
+
45
+ const nodeRadius = (d: any) => calcNodeRadius(d.edgeCount, stateRef.current.absoluteSizing);
46
+
47
+ const renderGraph = () => {
48
+ if (!containerRef.current || !graphData) return;
49
+ const state = stateRef.current;
50
+
51
+ containerRef.current.innerHTML = '';
52
+ const width = containerRef.current.clientWidth;
53
+ const height = containerRef.current.clientHeight;
54
+
55
+ // Build nodes
56
+ const edgeCounts = new Map<string, number>();
57
+ graphData.edges.forEach((e: any) => {
58
+ edgeCounts.set(e.from, (edgeCounts.get(e.from) || 0) + 1);
59
+ edgeCounts.set(e.to, (edgeCounts.get(e.to) || 0) + 1);
60
+ });
61
+
62
+ let nodes = graphData.nodes.map((n: any) => ({
63
+ ...n, edgeCount: edgeCounts.get(n.id) || 0,
64
+ }));
65
+
66
+ if (state.hideDefinedActive) {
67
+ nodes = nodes.filter((n: any) => !(n.status === 'defined' && n.completeness === 1));
68
+ }
69
+ if (state.hiddenTypes.size > 0) {
70
+ nodes = nodes.filter((n: any) => !state.hiddenTypes.has(n.type));
71
+ }
72
+
73
+ const nodeIds = new Set(nodes.map((n: any) => n.id));
74
+ const links = graphData.edges
75
+ .filter((e: any) => nodeIds.has(e.from) && nodeIds.has(e.to))
76
+ .map((e: any) => ({ source: e.from, target: e.to, relation: e.relation }));
77
+
78
+ state.nodes = nodes;
79
+ state.links = links;
80
+
81
+ const svg = d3.select(containerRef.current)
82
+ .append('svg').attr('width', width).attr('height', height);
83
+ state.svg = svg;
84
+
85
+ // Arrow markers
86
+ const defs = svg.append('defs');
87
+ Object.entries(EDGE_COLORS).forEach(([relation, color]) => {
88
+ defs.append('marker')
89
+ .attr('id', `arrow-${relation}`).attr('viewBox', '0 0 10 6')
90
+ .attr('refX', 20).attr('refY', 3)
91
+ .attr('markerWidth', 8).attr('markerHeight', 5)
92
+ .attr('orient', 'auto')
93
+ .append('path').attr('d', 'M0,0 L10,3 L0,6 Z').attr('fill', color);
94
+ });
95
+
96
+ // Zoom
97
+ state.zoom = d3.zoom<SVGSVGElement, unknown>()
98
+ .scaleExtent([0.1, 4])
99
+ .on('zoom', (event) => {
100
+ state.zoomGroup.attr('transform', event.transform);
101
+ });
102
+ svg.call(state.zoom);
103
+ svg.on('click', (event) => {
104
+ if (event.target === svg.node()) clearSelectionFn();
105
+ });
106
+
107
+ state.zoomGroup = svg.append('g').attr('class', 'zoom-group');
108
+ state.linksG = state.zoomGroup.append('g').attr('class', 'links');
109
+ state.edgeLabelsG = state.zoomGroup.append('g').attr('class', 'edge-labels');
110
+ state.gapRingsG = state.zoomGroup.append('g').attr('class', 'gap-rings');
111
+ state.nodesG = state.zoomGroup.append('g').attr('class', 'nodes');
112
+ state.gapIconsG = state.zoomGroup.append('g').attr('class', 'gap-icons');
113
+ state.labelsG = state.zoomGroup.append('g').attr('class', 'labels');
114
+
115
+ const linkEls = state.linksG.selectAll('line').data(links).join('line')
116
+ .attr('stroke', (d: any) => EDGE_COLORS[d.relation] || '#868e96')
117
+ .attr('stroke-opacity', 0.5).attr('stroke-width', 1.5)
118
+ .attr('marker-end', (d: any) => `url(#arrow-${d.relation})`);
119
+
120
+ const edgeLabelEls = state.edgeLabelsG.selectAll('text').data(links).join('text')
121
+ .text((d: any) => EDGE_LABELS[d.relation] || d.relation)
122
+ .attr('font-size', 9)
123
+ .attr('font-family', 'ui-monospace, "SF Mono", "Cascadia Code", monospace')
124
+ .attr('fill', (d: any) => EDGE_COLORS[d.relation] || '#868e96')
125
+ .attr('text-anchor', 'middle').attr('dominant-baseline', 'central')
126
+ .attr('pointer-events', 'none')
127
+ .attr('opacity', state.edgeLabelsVisible ? 0.8 : 0);
128
+
129
+ const nodeEls = state.nodesG.selectAll('.node-shape').data(nodes).join('path')
130
+ .attr('class', 'node-shape')
131
+ .attr('d', (d: any) => nodeShapePath(NODE_SHAPES[d.type] || 'circle', nodeRadius(d)))
132
+ .attr('fill', (d: any) => NODE_COLORS[d.type] || '#adb5bd')
133
+ .attr('stroke', (d: any) => {
134
+ if (d.completeness < 1 && d.open_questions_count > 0) return '#ff6b6b';
135
+ if (d.completeness < 1) return '#ffd43b';
136
+ return 'transparent';
137
+ })
138
+ .attr('stroke-width', (d: any) => d.completeness < 1 ? 3 : 0)
139
+ .attr('cursor', 'pointer')
140
+ .on('click', (event: any, d: any) => {
141
+ event.stopPropagation();
142
+ selectNodeFn(d.id);
143
+ onNodeClick(d);
144
+ })
145
+ .call(d3.drag<SVGPathElement, any>()
146
+ .on('start', (event, d) => {
147
+ if (!event.active) state.simulation.alphaTarget(0.3).restart();
148
+ d.fx = d.x; d.fy = d.y;
149
+ })
150
+ .on('drag', (event, d) => {
151
+ const transform = d3.zoomTransform(svg.node()!);
152
+ d.fx = (event.sourceEvent.offsetX - transform.x) / transform.k;
153
+ d.fy = (event.sourceEvent.offsetY - transform.y) / transform.k;
154
+ })
155
+ .on('end', (event, d) => {
156
+ if (!event.active) state.simulation.alphaTarget(0);
157
+ d.fx = null; d.fy = null;
158
+ })
159
+ );
160
+
161
+ const gapNodes = nodes.filter((d: any) => d.open_questions_count > 0);
162
+ const gapRingEls = state.gapRingsG.selectAll('circle').data(gapNodes, (d: any) => d.id).join('circle')
163
+ .attr('r', (d: any) => nodeRadius(d) + 5)
164
+ .attr('fill', 'none').attr('stroke', '#ff6b6b').attr('stroke-width', 2)
165
+ .attr('class', 'gap-pulse-ring').attr('pointer-events', 'none');
166
+
167
+ const gapIconEls = state.gapIconsG.selectAll('text').data(gapNodes, (d: any) => d.id).join('text')
168
+ .text('\u26A0')
169
+ .attr('font-size', (d: any) => Math.max(10, nodeRadius(d) * 0.7))
170
+ .attr('text-anchor', 'middle').attr('dominant-baseline', 'central')
171
+ .attr('dx', (d: any) => nodeRadius(d) * 0.7)
172
+ .attr('dy', (d: any) => -nodeRadius(d) * 0.7)
173
+ .attr('pointer-events', 'none').attr('class', 'gap-warning-icon');
174
+
175
+ const allNames = state.allNodeNamesVisible;
176
+ const featNames = state.featureNodeNamesVisible;
177
+ const labelEls = state.labelsG.selectAll('text').data(nodes).join('text')
178
+ .text((d: any) => d.name).attr('font-size', 11)
179
+ .attr('font-family', 'ui-monospace, "SF Mono", "Cascadia Code", monospace')
180
+ .attr('fill', 'var(--text-primary)').attr('text-anchor', 'middle')
181
+ .attr('dy', (d: any) => nodeRadius(d) + 14).attr('pointer-events', 'none')
182
+ .attr('opacity', (d: any) => {
183
+ if (allNames) return 1;
184
+ if (featNames && d.type === 'feature') return 1;
185
+ return 0;
186
+ });
187
+
188
+ state.simulation = d3.forceSimulation(nodes)
189
+ .force('link', d3.forceLink(links).id((d: any) => d.id).distance(80))
190
+ .force('charge', d3.forceManyBody().strength(-200))
191
+ .force('center', d3.forceCenter(width / 2, height / 2))
192
+ .force('x', d3.forceX(width / 2).strength(0.05))
193
+ .force('y', d3.forceY(height / 2).strength(0.05))
194
+ .force('collision', d3.forceCollide().radius((d: any) => nodeRadius(d) + 10))
195
+ .on('tick', () => {
196
+ linkEls.attr('x1', (d: any) => d.source.x).attr('y1', (d: any) => d.source.y)
197
+ .attr('x2', (d: any) => d.target.x).attr('y2', (d: any) => d.target.y);
198
+ edgeLabelEls.attr('x', (d: any) => (d.source.x + d.target.x) / 2)
199
+ .attr('y', (d: any) => (d.source.y + d.target.y) / 2);
200
+ nodeEls.attr('transform', (d: any) => `translate(${d.x},${d.y})`);
201
+ gapRingEls.attr('cx', (d: any) => d.x).attr('cy', (d: any) => d.y);
202
+ gapIconEls.attr('x', (d: any) => d.x).attr('y', (d: any) => d.y);
203
+ labelEls.attr('x', (d: any) => d.x).attr('y', (d: any) => d.y);
204
+ });
205
+
206
+ state.simulation.on('end', () => fitToViewFn());
207
+ setTimeout(() => fitToViewFn(), 1500);
208
+ };
209
+
210
+ const fitToViewFn = () => {
211
+ const state = stateRef.current;
212
+ if (!state.nodes || state.nodes.length === 0 || !state.svg || !state.zoom) return;
213
+ const width = containerRef.current!.clientWidth;
214
+ const height = containerRef.current!.clientHeight;
215
+
216
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
217
+ state.nodes.forEach((d: any) => {
218
+ const r = nodeRadius(d) + 20;
219
+ if (d.x - r < minX) minX = d.x - r;
220
+ if (d.y - r < minY) minY = d.y - r;
221
+ if (d.x + r > maxX) maxX = d.x + r;
222
+ if (d.y + r > maxY) maxY = d.y + r;
223
+ });
224
+
225
+ const gw = maxX - minX;
226
+ const gh = maxY - minY;
227
+ if (gw <= 0 || gh <= 0) return;
228
+
229
+ const scale = Math.min(width / gw, height / gh, 1.5) * 0.9;
230
+ const cx = (minX + maxX) / 2;
231
+ const cy = (minY + maxY) / 2;
232
+
233
+ const transform = d3.zoomIdentity.translate(width / 2, height / 2).scale(scale).translate(-cx, -cy);
234
+ state.svg.transition().duration(500).call(state.zoom.transform, transform);
235
+ };
236
+
237
+ const selectNodeFn = (nodeId: string) => {
238
+ const state = stateRef.current;
239
+ state.selectedNodeId = nodeId;
240
+ const neighborIds = new Set<string>();
241
+ if (state.links) {
242
+ for (const link of state.links) {
243
+ const src = typeof link.source === 'object' ? link.source.id : link.source;
244
+ const tgt = typeof link.target === 'object' ? link.target.id : link.target;
245
+ if (src === nodeId) neighborIds.add(tgt);
246
+ if (tgt === nodeId) neighborIds.add(src);
247
+ }
248
+ }
249
+ const isInFocus = (id: string) => id === nodeId || neighborIds.has(id);
250
+
251
+ state.nodesG.selectAll('.node-shape').attr('opacity', (d: any) => isInFocus(d.id) ? 1 : 0.15);
252
+ state.linksG.selectAll('line').attr('stroke-opacity', (d: any) =>
253
+ d.source.id === nodeId || d.target.id === nodeId ? 0.8 : 0.05);
254
+ if (state.edgeLabelsVisible) {
255
+ state.edgeLabelsG.selectAll('text').attr('opacity', (d: any) =>
256
+ d.source.id === nodeId || d.target.id === nodeId ? 1 : 0.05);
257
+ }
258
+ state.labelsG.selectAll('text').attr('opacity', (d: any) => isInFocus(d.id) ? 1 : 0.1);
259
+ state.gapRingsG.selectAll('circle').attr('opacity', (d: any) => isInFocus(d.id) ? 1 : 0.1);
260
+ state.gapIconsG.selectAll('text').attr('opacity', (d: any) => isInFocus(d.id) ? 1 : 0.1);
261
+ };
262
+
263
+ const clearSelectionFn = () => {
264
+ const state = stateRef.current;
265
+ state.selectedNodeId = null;
266
+ state.nodesG?.selectAll('.node-shape').attr('opacity', 1);
267
+ state.linksG?.selectAll('line').attr('stroke-opacity', 0.5);
268
+ if (state.edgeLabelsVisible) {
269
+ state.edgeLabelsG?.selectAll('text').attr('opacity', 0.8);
270
+ }
271
+ state.labelsG?.selectAll('text').attr('opacity', (d: any) => {
272
+ if (state.allNodeNamesVisible) return 1;
273
+ if (state.featureNodeNamesVisible && d.type === 'feature') return 1;
274
+ return 0;
275
+ });
276
+ state.gapRingsG?.selectAll('circle').attr('opacity', 1);
277
+ state.gapIconsG?.selectAll('text').attr('opacity', 1);
278
+ };
279
+
280
+ useImperativeHandle(ref, () => ({
281
+ fitToView: fitToViewFn,
282
+ focusNode: (nodeId: string) => {
283
+ const state = stateRef.current;
284
+ if (!state.nodes || !state.svg || !state.zoom) return;
285
+ const node = state.nodes.find((n: any) => n.id === nodeId);
286
+ if (!node || node.x == null) return;
287
+ const width = containerRef.current!.clientWidth;
288
+ const height = containerRef.current!.clientHeight;
289
+ const transform = d3.zoomIdentity.translate(width / 2, height / 2).scale(1.2).translate(-node.x, -node.y);
290
+ state.svg.transition().duration(500).call(state.zoom.transform, transform);
291
+ selectNodeFn(nodeId);
292
+ },
293
+ setHiddenTypes: (types: Set<string>) => {
294
+ stateRef.current.hiddenTypes = new Set(types);
295
+ renderGraph();
296
+ },
297
+ highlightBySearch: (query: string) => {
298
+ const state = stateRef.current;
299
+ if (!query || query.trim().length === 0) {
300
+ state.searchQuery = null;
301
+ clearSelectionFn();
302
+ return;
303
+ }
304
+ state.searchQuery = query.toLowerCase();
305
+ const matches = (d: any) => d.name.toLowerCase().includes(state.searchQuery);
306
+ state.nodesG.selectAll('.node-shape').attr('opacity', (d: any) => matches(d) ? 1 : 0.15);
307
+ state.linksG.selectAll('line').attr('stroke-opacity', (d: any) => {
308
+ const src = state.nodes.find((n: any) => n.id === (typeof d.source === 'object' ? d.source.id : d.source));
309
+ const tgt = state.nodes.find((n: any) => n.id === (typeof d.target === 'object' ? d.target.id : d.target));
310
+ return (src && matches(src)) || (tgt && matches(tgt)) ? 0.5 : 0.05;
311
+ });
312
+ state.labelsG.selectAll('text').attr('opacity', (d: any) => matches(d) ? 1 : 0.1);
313
+ },
314
+ clearSelection: clearSelectionFn,
315
+ getNodeById: (nodeId: string) => stateRef.current.nodes?.find((n: any) => n.id === nodeId) || null,
316
+ toggleEdgeLabels: () => {
317
+ const state = stateRef.current;
318
+ state.edgeLabelsVisible = !state.edgeLabelsVisible;
319
+ state.edgeLabelsG?.selectAll('text').attr('opacity', state.edgeLabelsVisible ? 0.8 : 0);
320
+ return state.edgeLabelsVisible;
321
+ },
322
+ toggleHideDefined: () => {
323
+ const state = stateRef.current;
324
+ state.hideDefinedActive = !state.hideDefinedActive;
325
+ renderGraph();
326
+ return state.hideDefinedActive;
327
+ },
328
+ setNodeNameVisibility: (all: boolean, feature: boolean) => {
329
+ const state = stateRef.current;
330
+ state.allNodeNamesVisible = all;
331
+ state.featureNodeNamesVisible = feature;
332
+ state.labelsG?.selectAll('text').attr('opacity', (d: any) => {
333
+ if (all) return 1;
334
+ if (feature && d.type === 'feature') return 1;
335
+ return 0;
336
+ });
337
+ },
338
+ setAbsoluteSizing: (enabled: boolean) => {
339
+ stateRef.current.absoluteSizing = enabled;
340
+ renderGraph();
341
+ },
342
+ get edgeLabelsVisible() { return stateRef.current.edgeLabelsVisible; },
343
+ get hideDefinedActive() { return stateRef.current.hideDefinedActive; },
344
+ get absoluteSizingActive() { return stateRef.current.absoluteSizing; },
345
+ }));
346
+
347
+ useEffect(() => {
348
+ if (visible && graphData) renderGraph();
349
+ }, [graphData, visible]);
350
+
351
+ useEffect(() => {
352
+ if (!visible) return;
353
+ const handleResize = () => {
354
+ const state = stateRef.current;
355
+ if (!state.svg) return;
356
+ const width = containerRef.current!.clientWidth;
357
+ const height = containerRef.current!.clientHeight;
358
+ state.svg.attr('width', width).attr('height', height);
359
+ if (state.simulation) {
360
+ state.simulation.force('center', d3.forceCenter(width / 2, height / 2));
361
+ state.simulation.alpha(0.3).restart();
362
+ }
363
+ };
364
+ window.addEventListener('resize', handleResize);
365
+ return () => window.removeEventListener('resize', handleResize);
366
+ }, [visible]);
367
+
368
+ return <div id="graph-container" ref={containerRef} style={{ display: visible ? 'block' : 'none' }} />;
369
+ }
370
+ );
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Invite User Dialog — admin-only modal for sending email invitations.
3
+ */
4
+
5
+ import React, { useState, useCallback } from 'react';
6
+ import { apiClient } from '../api/client';
7
+ import { validateEmail } from '../utils/auth_validation';
8
+
9
+ interface InviteUserDialogProps {
10
+ isOpen: boolean;
11
+ onClose: () => void;
12
+ }
13
+
14
+ export function InviteUserDialog({ isOpen, onClose }: InviteUserDialogProps) {
15
+ const [email, setEmail] = useState('');
16
+ const [error, setError] = useState('');
17
+ const [success, setSuccess] = useState('');
18
+ const [submitting, setSubmitting] = useState(false);
19
+
20
+ const handleSubmit = useCallback(async (e: React.FormEvent) => {
21
+ e.preventDefault();
22
+ setError('');
23
+ setSuccess('');
24
+
25
+ const emailErr = validateEmail(email);
26
+ if (emailErr) {
27
+ setError(emailErr);
28
+ return;
29
+ }
30
+
31
+ setSubmitting(true);
32
+ try {
33
+ const data = await apiClient.sendInvitation(email);
34
+ setSuccess(data.message);
35
+ setEmail('');
36
+ } catch (err: any) {
37
+ setError(err.message || 'Failed to send invitation');
38
+ } finally {
39
+ setSubmitting(false);
40
+ }
41
+ }, [email]);
42
+
43
+ const handleClose = useCallback(() => {
44
+ setEmail('');
45
+ setError('');
46
+ setSuccess('');
47
+ onClose();
48
+ }, [onClose]);
49
+
50
+ if (!isOpen) return null;
51
+
52
+ return (
53
+ <div className="invite-dialog-overlay" onClick={handleClose}>
54
+ <div className="invite-dialog" onClick={e => e.stopPropagation()}>
55
+ <div className="invite-dialog-header">
56
+ <h2>Invite User</h2>
57
+ <button className="invite-dialog-close" onClick={handleClose} type="button">&times;</button>
58
+ </div>
59
+
60
+ <form onSubmit={handleSubmit} noValidate>
61
+ {error && <div className="auth-error-banner">{error}</div>}
62
+ {success && <div className="invite-dialog-success">{success}</div>}
63
+
64
+ <div className="auth-field">
65
+ <label className="auth-label" htmlFor="invite-email">Email address</label>
66
+ <input
67
+ id="invite-email"
68
+ className={`auth-input${error ? ' auth-input-error' : ''}`}
69
+ type="email"
70
+ value={email}
71
+ onChange={e => setEmail(e.target.value)}
72
+ placeholder="user@example.com"
73
+ autoFocus
74
+ disabled={submitting}
75
+ />
76
+ </div>
77
+
78
+ <button className="auth-submit" type="submit" disabled={submitting}>
79
+ {submitting ? 'Sending...' : 'Send Invitation'}
80
+ </button>
81
+ </form>
82
+ </div>
83
+ </div>
84
+ );
85
+ }