lula2 0.0.5 → 0.0.6

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 (108) hide show
  1. package/README.md +291 -8
  2. package/dist/_app/env.js +1 -0
  3. package/dist/_app/immutable/assets/0.DtiRW3lO.css +1 -0
  4. package/dist/_app/immutable/assets/DynamicControlEditor.BkVTzFZ-.css +1 -0
  5. package/dist/_app/immutable/chunks/7x_q-1ab.js +1 -0
  6. package/dist/_app/immutable/chunks/B19gt6-g.js +2 -0
  7. package/dist/_app/immutable/chunks/BR-0Dorr.js +1 -0
  8. package/dist/_app/immutable/chunks/B_3ksxz5.js +2 -0
  9. package/dist/_app/immutable/chunks/Bg_R1qWi.js +3 -0
  10. package/dist/_app/immutable/chunks/D3aNP_lg.js +1 -0
  11. package/dist/_app/immutable/chunks/D4Q_ObIy.js +1 -0
  12. package/dist/_app/immutable/chunks/DsnmJJEf.js +1 -0
  13. package/dist/_app/immutable/chunks/XY2j_owG.js +66 -0
  14. package/dist/_app/immutable/chunks/rzN25oDf.js +1 -0
  15. package/dist/_app/immutable/entry/app.r0uOd9qg.js +2 -0
  16. package/dist/_app/immutable/entry/start.DvoqR0rc.js +1 -0
  17. package/dist/_app/immutable/nodes/0.Ct6FAss_.js +1 -0
  18. package/dist/_app/immutable/nodes/1.DLoKuy8Q.js +1 -0
  19. package/dist/_app/immutable/nodes/2.IRkwSmiB.js +1 -0
  20. package/dist/_app/immutable/nodes/3.BrTg-ZHv.js +1 -0
  21. package/dist/_app/immutable/nodes/4.Blq-4WQS.js +9 -0
  22. package/dist/_app/version.json +1 -0
  23. package/dist/cli/commands/crawl.js +128 -0
  24. package/dist/cli/commands/ui.js +2769 -0
  25. package/dist/cli/commands/version.js +30 -0
  26. package/dist/cli/server/index.js +2713 -0
  27. package/dist/cli/server/server.js +2702 -0
  28. package/dist/cli/server/serverState.js +1199 -0
  29. package/dist/cli/server/spreadsheetRoutes.js +788 -0
  30. package/dist/cli/server/types.js +0 -0
  31. package/dist/cli/server/websocketServer.js +2625 -0
  32. package/dist/cli/utils/debug.js +24 -0
  33. package/dist/favicon.svg +1 -0
  34. package/dist/index.html +38 -0
  35. package/dist/index.js +2924 -37
  36. package/dist/lula.png +0 -0
  37. package/dist/lula2 +2 -0
  38. package/package.json +120 -72
  39. package/src/app.css +192 -0
  40. package/src/app.d.ts +13 -0
  41. package/src/app.html +13 -0
  42. package/src/lib/actions/fadeWhenScrollable.ts +39 -0
  43. package/src/lib/actions/modal.ts +230 -0
  44. package/src/lib/actions/tooltip.ts +82 -0
  45. package/src/lib/components/control-sets/ControlSetInfo.svelte +20 -0
  46. package/src/lib/components/control-sets/ControlSetSelector.svelte +46 -0
  47. package/src/lib/components/control-sets/index.ts +5 -0
  48. package/src/lib/components/controls/ControlDetailsPanel.svelte +235 -0
  49. package/src/lib/components/controls/ControlsList.svelte +608 -0
  50. package/src/lib/components/controls/DynamicControlEditor.svelte +298 -0
  51. package/src/lib/components/controls/MappingCard.svelte +105 -0
  52. package/src/lib/components/controls/MappingForm.svelte +188 -0
  53. package/src/lib/components/controls/index.ts +9 -0
  54. package/src/lib/components/controls/renderers/EditableFieldRenderer.svelte +103 -0
  55. package/src/lib/components/controls/renderers/FieldRenderer.svelte +49 -0
  56. package/src/lib/components/controls/renderers/index.ts +5 -0
  57. package/src/lib/components/controls/tabs/CustomFieldsTab.svelte +130 -0
  58. package/src/lib/components/controls/tabs/ImplementationTab.svelte +127 -0
  59. package/src/lib/components/controls/tabs/MappingsTab.svelte +182 -0
  60. package/src/lib/components/controls/tabs/OverviewTab.svelte +151 -0
  61. package/src/lib/components/controls/tabs/TimelineTab.svelte +41 -0
  62. package/src/lib/components/controls/tabs/index.ts +8 -0
  63. package/src/lib/components/controls/utils/ProcessedTextRenderer.svelte +63 -0
  64. package/src/lib/components/controls/utils/textProcessor.ts +164 -0
  65. package/src/lib/components/forms/DynamicControlForm.svelte +340 -0
  66. package/src/lib/components/forms/DynamicField.svelte +494 -0
  67. package/src/lib/components/forms/FormField.svelte +107 -0
  68. package/src/lib/components/forms/index.ts +6 -0
  69. package/src/lib/components/setup/ExistingControlSets.svelte +284 -0
  70. package/src/lib/components/setup/SpreadsheetImport.svelte +968 -0
  71. package/src/lib/components/setup/index.ts +5 -0
  72. package/src/lib/components/ui/Dropdown.svelte +107 -0
  73. package/src/lib/components/ui/EmptyState.svelte +80 -0
  74. package/src/lib/components/ui/FeatureToggle.svelte +50 -0
  75. package/src/lib/components/ui/SearchBar.svelte +73 -0
  76. package/src/lib/components/ui/StatusBadge.svelte +79 -0
  77. package/src/lib/components/ui/TabNavigation.svelte +48 -0
  78. package/src/lib/components/ui/Tooltip.svelte +120 -0
  79. package/src/lib/components/ui/index.ts +10 -0
  80. package/src/lib/components/version-control/DiffViewer.svelte +292 -0
  81. package/src/lib/components/version-control/TimelineItem.svelte +107 -0
  82. package/src/lib/components/version-control/YamlDiffViewer.svelte +428 -0
  83. package/src/lib/components/version-control/index.ts +6 -0
  84. package/src/lib/form-types.ts +57 -0
  85. package/src/lib/formatUtils.ts +17 -0
  86. package/src/lib/index.ts +5 -0
  87. package/src/lib/types.ts +180 -0
  88. package/src/lib/websocket.ts +359 -0
  89. package/src/routes/+layout.svelte +236 -0
  90. package/src/routes/+page.svelte +38 -0
  91. package/src/routes/control/[id]/+page.svelte +112 -0
  92. package/src/routes/setup/+page.svelte +241 -0
  93. package/src/stores/compliance.ts +95 -0
  94. package/src/styles/highlightjs.css +20 -0
  95. package/src/styles/modal.css +58 -0
  96. package/src/styles/tables.css +111 -0
  97. package/src/styles/tooltip.css +65 -0
  98. package/dist/controls/index.d.ts +0 -18
  99. package/dist/controls/index.d.ts.map +0 -1
  100. package/dist/controls/index.js +0 -18
  101. package/dist/crawl.d.ts +0 -62
  102. package/dist/crawl.d.ts.map +0 -1
  103. package/dist/crawl.js +0 -172
  104. package/dist/index.d.ts +0 -8
  105. package/dist/index.d.ts.map +0 -1
  106. package/src/controls/index.ts +0 -19
  107. package/src/crawl.ts +0 -227
  108. package/src/index.ts +0 -46
@@ -0,0 +1,230 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2023-Present The Lula Authors
3
+
4
+ /**
5
+ * Modal action for Svelte components
6
+ * Creates a modal dialog that appears when the trigger element is clicked
7
+ */
8
+
9
+ // Types for the modal action
10
+ type ModalOptions = {
11
+ openOnInit?: boolean; // Whether to open the modal immediately when component mounts
12
+ closeOnEscape?: boolean; // Whether to close on Escape key
13
+ closeOnOutsideClick?: boolean; // Whether to close when clicking outside
14
+ onOpen?: () => void; // Callback for when modal opens
15
+ onClose?: () => void; // Callback for when modal closes
16
+ };
17
+
18
+ /**
19
+ * Creates a click-to-open modal from an element with a .modal-content child
20
+ * @param node The HTML element to attach the modal trigger to
21
+ * @param options Configuration options
22
+ * @returns Svelte action object
23
+ */
24
+ // Store a map of modal nodes to their closeModal functions for external access
25
+ const modalRegistry = new Map<string, () => void>();
26
+
27
+ /**
28
+ * Closes a modal by its ID
29
+ * @param modalId The ID of the modal to close
30
+ */
31
+ export function closeModalById(modalId: string): void {
32
+ const closeFunction = modalRegistry.get(modalId);
33
+ if (closeFunction) {
34
+ closeFunction();
35
+ } else {
36
+ console.warn(`No modal with ID ${modalId} found in registry`);
37
+ }
38
+ }
39
+
40
+ export function modal(node: HTMLElement, options: ModalOptions = {}) {
41
+ const defaults: ModalOptions = {
42
+ openOnInit: false,
43
+ closeOnEscape: true,
44
+ closeOnOutsideClick: true
45
+ };
46
+
47
+ // Merge defaults with provided options
48
+ const settings = { ...defaults, ...options };
49
+
50
+ // Find modal content element
51
+ const modalContent = node.querySelector('.modal-content') as HTMLElement;
52
+ // Use the parent element of the node as the trigger element
53
+ const triggerElement = node.parentElement as HTMLElement;
54
+
55
+ if (!modalContent) {
56
+ return {};
57
+ }
58
+
59
+ if (!triggerElement) {
60
+ return {};
61
+ }
62
+
63
+ // Create backdrop/overlay element
64
+ const backdrop = document.createElement('div');
65
+ backdrop.className = 'modal-backdrop';
66
+ backdrop.style.display = 'none';
67
+ document.body.appendChild(backdrop);
68
+
69
+ // Set initial ARIA attributes
70
+ modalContent.setAttribute('role', 'dialog');
71
+ modalContent.setAttribute('aria-modal', 'true');
72
+ modalContent.setAttribute('aria-hidden', 'true');
73
+
74
+ // State to track if modal is open
75
+ let isOpen = false;
76
+
77
+ // Function to open the modal
78
+ const openModal = () => {
79
+ if (isOpen) return;
80
+
81
+ isOpen = true;
82
+ // Move modal content to body to avoid stacking context issues
83
+ document.body.appendChild(modalContent);
84
+ modalContent.setAttribute('aria-hidden', 'false');
85
+ backdrop.style.display = 'block';
86
+
87
+ // Prevent scrolling on the body
88
+ document.body.style.overflow = 'hidden';
89
+
90
+ // Call onOpen callback if provided
91
+ if (settings.onOpen) settings.onOpen();
92
+
93
+ // Focus first focusable element in modal
94
+ const focusableElements = modalContent.querySelectorAll(
95
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
96
+ );
97
+ if (focusableElements.length > 0) {
98
+ (focusableElements[0] as HTMLElement).focus();
99
+ }
100
+ };
101
+
102
+ // Function to close the modal
103
+ const closeModal = () => {
104
+ if (!isOpen) return;
105
+
106
+ isOpen = false;
107
+ modalContent.setAttribute('aria-hidden', 'true');
108
+ backdrop.style.display = 'none';
109
+
110
+ // Move modal back to original position
111
+ if (document.body.contains(modalContent)) {
112
+ node.appendChild(modalContent);
113
+ }
114
+
115
+ // Restore scrolling on body
116
+ document.body.style.overflow = '';
117
+
118
+ // Call onClose callback if provided
119
+ if (settings.onClose) settings.onClose();
120
+
121
+ // Return focus to trigger element
122
+ if (triggerElement) {
123
+ triggerElement.focus();
124
+ }
125
+ };
126
+
127
+ // Register the modal in our registry if it has an ID
128
+ const modalId = node.getAttribute('id');
129
+ if (modalId) {
130
+ modalRegistry.set(modalId, closeModal);
131
+ }
132
+
133
+ // Handle click on trigger to open modal
134
+ const handleTriggerClick = (e: Event) => {
135
+ e.preventDefault();
136
+ e.stopPropagation();
137
+ openModal();
138
+ };
139
+
140
+ // Handle click on backdrop to close modal if closeOnOutsideClick is true
141
+ const handleBackdropClick = (e: MouseEvent) => {
142
+ if (!settings.closeOnOutsideClick) return;
143
+
144
+ // Only close if the click was directly on the backdrop, not on modal content
145
+ if (e.target === backdrop) {
146
+ closeModal();
147
+ }
148
+ };
149
+
150
+ // Handle escape key to close modal if closeOnEscape is true
151
+ const handleKeyDown = (e: KeyboardEvent) => {
152
+ if (!settings.closeOnEscape) return;
153
+
154
+ if (e.key === 'Escape' && isOpen) {
155
+ closeModal();
156
+ }
157
+ };
158
+
159
+ // Add close button to modal
160
+ const closeButton = document.createElement('button');
161
+ closeButton.className = 'modal-close';
162
+ closeButton.innerHTML = '×';
163
+ closeButton.setAttribute('aria-label', 'Close modal');
164
+ modalContent.appendChild(closeButton);
165
+
166
+ // Handle click on close button
167
+ const handleCloseClick = (e: Event) => {
168
+ e.preventDefault();
169
+ e.stopPropagation();
170
+ closeModal();
171
+ };
172
+
173
+ // Set up event listeners
174
+ triggerElement.addEventListener('click', handleTriggerClick);
175
+
176
+ closeButton.addEventListener('click', handleCloseClick);
177
+ backdrop.addEventListener('click', handleBackdropClick);
178
+ document.addEventListener('keydown', handleKeyDown);
179
+
180
+ // Open modal on init if specified
181
+ if (settings.openOnInit) {
182
+ setTimeout(openModal, 0);
183
+ }
184
+
185
+ return {
186
+ update(newOptions: ModalOptions) {
187
+ // Update settings
188
+ Object.assign(settings, newOptions);
189
+ },
190
+ destroy() {
191
+ // Clean up event listeners
192
+ triggerElement.removeEventListener('click', handleTriggerClick);
193
+
194
+ closeButton.removeEventListener('click', handleCloseClick);
195
+ backdrop.removeEventListener('click', handleBackdropClick);
196
+ document.removeEventListener('keydown', handleKeyDown);
197
+
198
+ // Remove backdrop from DOM
199
+ if (document.body.contains(backdrop)) {
200
+ document.body.removeChild(backdrop);
201
+ }
202
+
203
+ // Return modal to original position if needed
204
+ if (document.body.contains(modalContent)) {
205
+ node.appendChild(modalContent);
206
+ }
207
+
208
+ // Remove close button from modal
209
+ if (modalContent.contains(closeButton)) {
210
+ modalContent.removeChild(closeButton);
211
+ }
212
+
213
+ // Reset attributes
214
+ modalContent.removeAttribute('role');
215
+ modalContent.removeAttribute('aria-modal');
216
+ modalContent.removeAttribute('aria-hidden');
217
+
218
+ // If modal is open when destroyed, restore body scrolling
219
+ if (isOpen) {
220
+ document.body.style.overflow = '';
221
+ }
222
+
223
+ // Remove from registry if it was registered
224
+ const modalId = node.getAttribute('id');
225
+ if (modalId && modalRegistry.has(modalId)) {
226
+ modalRegistry.delete(modalId);
227
+ }
228
+ }
229
+ };
230
+ }
@@ -0,0 +1,82 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2023-Present The Lula Authors
3
+
4
+ /**
5
+ * Tooltip positioning action for Svelte components
6
+ * Adds dynamic positioning based on viewport and element bounds
7
+ */
8
+
9
+ /**
10
+ * Applies tooltip positioning to an element with a .tooltip child
11
+ * @param node The HTML element to attach the tooltip to
12
+ * @returns Svelte action object
13
+ */
14
+ export function tooltip(node: HTMLElement) {
15
+ const tooltipEl = node.querySelector('.tooltip') as HTMLElement;
16
+
17
+ // Add data attribute to allow CSS targeting
18
+ node.setAttribute('data-tooltip-trigger', 'true');
19
+
20
+ function updatePosition() {
21
+ if (!tooltipEl) return;
22
+
23
+ // Get element positions
24
+ const rect = node.getBoundingClientRect();
25
+
26
+ // Set position variables as CSS custom properties
27
+ Object.entries({
28
+ '--group-top': `${rect.top}px`,
29
+ '--group-right': `${rect.right}px`,
30
+ '--group-left': `${rect.left}px`,
31
+ '--group-width': `${rect.width}px`,
32
+ '--group-bottom': `${rect.bottom}px`,
33
+ '--group-height': `${rect.height}px`
34
+ }).forEach(([prop, val]) => tooltipEl.style.setProperty(prop, val));
35
+
36
+ // Check if tooltip already has a position class
37
+ const hasPositionClass = /tooltip-(left|right|top|bottom)/.test(tooltipEl.className);
38
+
39
+ // Only calculate and apply position if no position class is already specified
40
+ if (!hasPositionClass) {
41
+ // Determine tooltip positioning based on available space
42
+ const spaceOnRight = window.innerWidth - rect.right;
43
+ const spaceBelow = window.innerHeight - rect.bottom;
44
+ const tooltipWidth = 288; // Approximate tooltip min-width
45
+ const tooltipHeight = 100; // Approximate tooltip height
46
+
47
+ // Default horizontal position (right or left)
48
+ const horizontalPosition = spaceOnRight < tooltipWidth + 20 ? 'left' : 'right';
49
+
50
+ // Default vertical position (bottom or top)
51
+ const verticalPosition = spaceBelow < tooltipHeight + 20 ? 'top' : 'bottom';
52
+
53
+ // Determine if we should prioritize vertical positioning
54
+ const useVerticalPosition =
55
+ (horizontalPosition === 'left' && rect.left < tooltipWidth) ||
56
+ (spaceBelow < tooltipHeight && rect.top > tooltipHeight) ||
57
+ (rect.top < tooltipHeight && spaceBelow > tooltipHeight);
58
+
59
+ // Add appropriate positioning class
60
+ const position = useVerticalPosition
61
+ ? `tooltip-${verticalPosition}`
62
+ : `tooltip-${horizontalPosition}`;
63
+
64
+ tooltipEl.className =
65
+ tooltipEl.className.replace(/tooltip-(left|right|top|bottom)/g, '') + ' ' + position;
66
+ }
67
+ }
68
+
69
+ // Create a real function reference for event listener removal
70
+ const handleMouseEnter = () => window.requestAnimationFrame(updatePosition);
71
+
72
+ // Only update position on hover
73
+ node.addEventListener('mouseenter', handleMouseEnter);
74
+
75
+ return {
76
+ update: updatePosition,
77
+ destroy() {
78
+ node.removeEventListener('mouseenter', handleMouseEnter);
79
+ node.removeAttribute('data-tooltip-trigger');
80
+ }
81
+ };
82
+ }
@@ -0,0 +1,20 @@
1
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
2
+ <!-- SPDX-FileCopyrightText: 2023-Present The Lula Authors -->
3
+
4
+ <script lang="ts">
5
+ import { appState } from '$lib/websocket';
6
+ import { ArrowsHorizontal } from 'carbon-icons-svelte';
7
+ </script>
8
+
9
+ {#if $appState.isConnected}
10
+ <a
11
+ href="/setup"
12
+ class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors"
13
+ title="Switch control set"
14
+ >
15
+ <ArrowsHorizontal size={16} />
16
+ Switch
17
+ </a>
18
+ {:else}
19
+ <div class="text-sm text-gray-500 dark:text-gray-400">Loading...</div>
20
+ {/if}
@@ -0,0 +1,46 @@
1
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
2
+ <!-- SPDX-FileCopyrightText: 2023-Present The Lula Authors -->
3
+
4
+ <script lang="ts">
5
+ import { createEventDispatcher } from 'svelte';
6
+ import type { ControlSet } from '$lib/types';
7
+
8
+ interface Props {
9
+ currentSet: ControlSet;
10
+ availableSets: ControlSet[];
11
+ }
12
+
13
+ let { currentSet, availableSets }: Props = $props();
14
+
15
+ const dispatch = createEventDispatcher<{ change: string }>();
16
+
17
+ function handleChange(event: Event) {
18
+ const target = event.target as HTMLSelectElement;
19
+ dispatch('change', target.value);
20
+ }
21
+ </script>
22
+
23
+ <div class="control-set-selector">
24
+ <label for="control-set" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
25
+ Control Set
26
+ </label>
27
+ <select
28
+ id="control-set"
29
+ value={currentSet.id}
30
+ onchange={handleChange}
31
+ class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-800 dark:text-white"
32
+ >
33
+ {#each availableSets as set}
34
+ <option value={set.id}>{set.name} {set.version}</option>
35
+ {/each}
36
+ </select>
37
+ <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
38
+ {currentSet.description}
39
+ </p>
40
+ </div>
41
+
42
+ <style>
43
+ .control-set-selector {
44
+ margin-bottom: 1rem;
45
+ }
46
+ </style>
@@ -0,0 +1,5 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2023-Present The Lula Authors
3
+
4
+ export { default as ControlSetSelector } from './ControlSetSelector.svelte';
5
+ export { default as ControlSetInfo } from './ControlSetInfo.svelte';
@@ -0,0 +1,235 @@
1
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
2
+ <!-- SPDX-FileCopyrightText: 2023-Present The Lula Authors -->
3
+
4
+ <script lang="ts">
5
+ import type { Control } from '$lib/types';
6
+ import { appState, wsClient } from '$lib/websocket';
7
+ import {
8
+ CheckmarkFilled,
9
+ Connect,
10
+ Edit,
11
+ Information,
12
+ InProgress,
13
+ Time,
14
+ WarningFilled
15
+ } from 'carbon-icons-svelte';
16
+ import { TabNavigation } from '../ui';
17
+ import {
18
+ CustomFieldsTab,
19
+ ImplementationTab,
20
+ MappingsTab,
21
+ OverviewTab,
22
+ TimelineTab
23
+ } from './tabs';
24
+
25
+ interface Props {
26
+ control: Control;
27
+ }
28
+
29
+ let { control }: Props = $props();
30
+
31
+ // Component state
32
+ let editedControl = $state({ ...control });
33
+ let originalControl = $state({ ...control });
34
+ let activeTab = $state<'details' | 'narrative' | 'custom' | 'mappings' | 'history'>('details');
35
+ let saveDebounceTimeout: ReturnType<typeof setTimeout> | null = null;
36
+ let isSaving = $state(false);
37
+ let showSavedMessage = $state(false);
38
+ let savedMessageTimeout: ReturnType<typeof setTimeout> | null = null;
39
+
40
+ // Derived values
41
+ const fieldSchema = $derived($appState.fieldSchema.fields);
42
+ const hasChanges = $derived(JSON.stringify(editedControl) !== JSON.stringify(originalControl));
43
+ const associatedMappings = $derived(
44
+ $appState.mappings.filter((m) => m.control_id === control.id)
45
+ );
46
+ const saveStatus = $derived(
47
+ isSaving ? 'saving' : (hasChanges ? 'unsaved' : (showSavedMessage ? 'just-saved' : 'clean'))
48
+ );
49
+
50
+ // Check if tabs have any fields
51
+ const hasCustomFields = $derived(() => {
52
+ if (!fieldSchema) return false;
53
+ return Object.values(fieldSchema).some((field: any) => field.tab === 'custom');
54
+ });
55
+
56
+ const hasImplementationFields = $derived(() => {
57
+ if (!fieldSchema) return false;
58
+ return Object.values(fieldSchema).some((field: any) => field.tab === 'implementation');
59
+ });
60
+
61
+ // Watch for control changes - only reset when ID changes
62
+ $effect(() => {
63
+ if (control.id !== editedControl?.id) {
64
+ if (saveDebounceTimeout) {
65
+ clearTimeout(saveDebounceTimeout);
66
+ }
67
+ editedControl = { ...control };
68
+ originalControl = { ...control };
69
+ activeTab = 'details';
70
+ }
71
+ });
72
+
73
+ // Manual trigger for auto-save when field changes
74
+ function triggerAutoSave() {
75
+ if (!hasChanges) return;
76
+
77
+ // Clear any existing save timeout
78
+ if (saveDebounceTimeout) {
79
+ clearTimeout(saveDebounceTimeout);
80
+ }
81
+
82
+ // Debounce the save by 500ms to avoid too many saves while typing
83
+ saveDebounceTimeout = setTimeout(() => {
84
+ performSave();
85
+ }, 500);
86
+ }
87
+
88
+ // Perform the actual save
89
+ async function performSave() {
90
+ if (!hasChanges || isSaving) return;
91
+
92
+ isSaving = true;
93
+ try {
94
+ // Only send the fields that have actually changed
95
+ const changes: Record<string, any> = { id: editedControl.id };
96
+
97
+ // Compare each field and only include changed ones
98
+ for (const [key, value] of Object.entries(editedControl)) {
99
+ // Skip runtime fields
100
+ if (key === 'timeline' || key === 'unifiedHistory' || key === '_metadata') {
101
+ continue;
102
+ }
103
+
104
+ // Only include if value has changed
105
+ if (JSON.stringify(value) !== JSON.stringify(originalControl[key])) {
106
+ changes[key] = value;
107
+ }
108
+ }
109
+
110
+ // Only send if there are actual changes beyond the ID
111
+ if (Object.keys(changes).length > 1) {
112
+ await wsClient.updateControl(changes as Control);
113
+ originalControl = { ...editedControl };
114
+ showTemporarySavedMessage();
115
+ console.log('Saved changes:', Object.keys(changes).filter(k => k !== 'id').join(', '));
116
+ }
117
+ } catch (error) {
118
+ console.error('Save failed:', error);
119
+ } finally {
120
+ isSaving = false;
121
+ }
122
+ }
123
+
124
+ // Show the saved message temporarily
125
+ function showTemporarySavedMessage() {
126
+ // Clear any existing timeout
127
+ if (savedMessageTimeout) {
128
+ clearTimeout(savedMessageTimeout);
129
+ }
130
+
131
+ // Show the message
132
+ showSavedMessage = true;
133
+
134
+ // Hide it after 3 seconds
135
+ savedMessageTimeout = setTimeout(() => {
136
+ showSavedMessage = false;
137
+ savedMessageTimeout = null;
138
+ }, 3000);
139
+ }
140
+
141
+ // Handle field changes from custom tab
142
+ function handleFieldChange(fieldName: string, value: any) {
143
+ triggerAutoSave();
144
+ }
145
+ </script>
146
+
147
+ <!-- Header outside of any card -->
148
+ <header class="flex-shrink-0">
149
+ <div class="py-5">
150
+ <div class="flex items-center justify-between">
151
+ <div class="flex items-center space-x-4">
152
+ <div>
153
+ <h1 class="text-xl font-bold text-gray-900 dark:text-white tracking-tight">
154
+ {control.id}
155
+ </h1>
156
+ <p class="text-sm text-gray-500 dark:text-gray-400 font-medium mt-1">
157
+ {control.title}
158
+ </p>
159
+ </div>
160
+ </div>
161
+ <div class="flex items-center">
162
+ <!-- Save status icon indicator -->
163
+ {#if saveStatus === 'saving'}
164
+ <div
165
+ class="w-8 h-8 flex items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30"
166
+ title="Saving..."
167
+ >
168
+ <InProgress class="w-5 h-5 text-blue-600 dark:text-blue-400" />
169
+ </div>
170
+ {:else if saveStatus === 'unsaved'}
171
+ <div
172
+ class="w-8 h-8 flex items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/30"
173
+ title="Unsaved changes"
174
+ >
175
+ <WarningFilled class="w-5 h-5 text-amber-600 dark:text-amber-400" />
176
+ </div>
177
+ {:else if saveStatus === 'just-saved'}
178
+ <div
179
+ class="w-8 h-8 flex items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30 animate-fade-in"
180
+ title="Saved"
181
+ >
182
+ <CheckmarkFilled class="w-5 h-5 text-green-600 dark:text-green-400" />
183
+ </div>
184
+ {/if}
185
+ </div>
186
+ </div>
187
+ </div>
188
+ </header>
189
+
190
+ <!-- Tab Navigation outside of any card -->
191
+ <div class="mb-2">
192
+ <TabNavigation
193
+ active={activeTab}
194
+ tabs={[
195
+ { id: 'details', label: 'Overview', icon: Information },
196
+ ...(hasImplementationFields() ? [{ id: 'narrative', label: 'Implementation', icon: Edit }] : []),
197
+ ...(hasCustomFields() ? [{ id: 'custom', label: 'Custom', icon: Edit }] : []),
198
+ { id: 'mappings', label: 'Mappings', icon: Connect, count: associatedMappings.length },
199
+ {
200
+ id: 'history',
201
+ label: 'Timeline',
202
+ icon: Time,
203
+ count: control.timeline?.totalCommits
204
+ }
205
+ ]}
206
+ onSelect={(tabId) => (activeTab = tabId as typeof activeTab)}
207
+ />
208
+ </div>
209
+
210
+ <!-- Tab content without card wrapper -->
211
+ <main class="flex-1 overflow-auto pt-4">
212
+ <div class="">
213
+ {#if activeTab === 'details'}
214
+ <OverviewTab control={editedControl} {fieldSchema} />
215
+ {:else if activeTab === 'narrative'}
216
+ <ImplementationTab control={editedControl} {fieldSchema} />
217
+ {:else if activeTab === 'custom'}
218
+ <CustomFieldsTab
219
+ control={editedControl}
220
+ {fieldSchema}
221
+ onFieldChange={handleFieldChange}
222
+ />
223
+ {:else if activeTab === 'mappings'}
224
+ <MappingsTab
225
+ {control}
226
+ mappings={associatedMappings}
227
+ />
228
+ {:else if activeTab === 'history'}
229
+ <TimelineTab
230
+ {control}
231
+ timeline={control.timeline}
232
+ />
233
+ {/if}
234
+ </div>
235
+ </main>