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,608 @@
1
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
2
+ <!-- SPDX-FileCopyrightText: 2023-Present The Lula Authors -->
3
+
4
+ <script lang="ts">
5
+ import { goto } from '$app/navigation';
6
+ import { page } from '$app/stores';
7
+ import { Dropdown, SearchBar, Tooltip } from '$components/ui';
8
+ import type { Control, FieldSchema } from '$lib/types';
9
+ import { appState } from '$lib/websocket';
10
+ import { complianceStore, searchTerm, selectedFamily } from '$stores/compliance';
11
+ import { Filter, Information } from 'carbon-icons-svelte';
12
+ import { derived } from 'svelte/store';
13
+
14
+ // Derive controls and families from appState
15
+ const controls = derived(appState, ($state) => $state.controls || []);
16
+ const families = derived(appState, ($state) => $state.families || []);
17
+ const loading = derived(appState, ($state) => !$state.isConnected);
18
+
19
+ // Derive controls with mappings
20
+ const controlsWithMappings = derived(appState, ($state) => {
21
+ return ($state.controls || []).map((control) => ({
22
+ ...control,
23
+ mappings: ($state.mappings || []).filter((m) => m.control_id === control.id)
24
+ }));
25
+ });
26
+
27
+ // Use compliance store for search and family filter - stores are imported directly
28
+
29
+ // Track selected control based on current route
30
+ let selectedControlId: string | null = null;
31
+ $: selectedControlId = $page.params.id || null;
32
+
33
+ // Dynamic field schema for table columns
34
+ let fieldSchema: Record<string, FieldSchema> = {};
35
+ let tableColumns: Array<{ fieldName: string; field: FieldSchema }> = [];
36
+ let schemaLoading = true;
37
+
38
+ // Watch for control set changes
39
+ $: if ($appState.name && $appState.name !== 'Unknown Control Set') {
40
+ const schema = $appState.fieldSchema || $appState.field_schema;
41
+ if (schema?.fields) {
42
+ fieldSchema = schema.fields;
43
+ // Get overview tab fields for table display
44
+ const overviewFields = Object.entries(fieldSchema)
45
+ .filter(([fieldName, field]) => {
46
+ // Use overview tab fields for the table
47
+ const fieldTab = field.tab || getDefaultTabForCategory(field.category);
48
+ // Include visible overview fields, but skip family as it's redundant
49
+ return fieldTab === 'overview' && field.visible && fieldName !== 'family';
50
+ })
51
+ .sort((a, b) => a[1].display_order - b[1].display_order);
52
+
53
+ // Get the control ID field from metadata or default to 'id'
54
+ const controlIdFieldName = $appState.control_id_field || 'id';
55
+
56
+ // Always include control ID column first
57
+ const idField = overviewFields.find(([name]) => name === controlIdFieldName || name === 'id');
58
+ const otherFields = overviewFields.filter(
59
+ ([name]) => name !== controlIdFieldName && name !== 'id'
60
+ );
61
+
62
+ // If control ID field doesn't exist in schema, create a default one
63
+ const idColumn = idField
64
+ ? { fieldName: idField[0], field: idField[1] }
65
+ : {
66
+ fieldName: 'id',
67
+ field: {
68
+ type: 'string',
69
+ ui_type: 'short_text' as const,
70
+ is_array: false,
71
+ required: true,
72
+ visible: true,
73
+ editable: false,
74
+ display_order: 0,
75
+ category: 'core' as const,
76
+ display_name: 'Control ID',
77
+ original_name: 'Control ID'
78
+ } as FieldSchema
79
+ };
80
+
81
+ // Combine ID first, then other fields sorted by display_order
82
+ // Take up to 5 additional fields after the ID for a total of 6 columns
83
+ tableColumns = [
84
+ idColumn,
85
+ ...otherFields.slice(0, 5).map(([fieldName, field]) => ({ fieldName, field }))
86
+ ];
87
+ }
88
+ schemaLoading = false;
89
+ }
90
+
91
+ // Helper function to map category to tab (same logic as ControlDetailsPanel)
92
+ function getDefaultTabForCategory(category: string): 'overview' | 'implementation' | 'custom' {
93
+ switch (category) {
94
+ case 'core':
95
+ case 'metadata':
96
+ return 'overview';
97
+ case 'compliance':
98
+ case 'content':
99
+ return 'implementation';
100
+ default:
101
+ return 'custom';
102
+ }
103
+ }
104
+
105
+ // Create filtered controls with mappings
106
+ const filteredControlsWithMappings = derived(
107
+ [controlsWithMappings, selectedFamily, searchTerm],
108
+ ([$controlsWithMappings, $selectedFamily, $searchTerm]) => {
109
+ let results = $controlsWithMappings;
110
+
111
+ if ($selectedFamily) {
112
+ results = results.filter((c) => {
113
+ const family =
114
+ (c as any)?._metadata?.family ||
115
+ (c as any)?.family ||
116
+ (c as any)?.['control-acronym']?.split('-')[0] ||
117
+ '';
118
+ return family === $selectedFamily;
119
+ });
120
+ }
121
+
122
+ if ($searchTerm) {
123
+ const term = $searchTerm.toLowerCase();
124
+ results = results.filter((c) => JSON.stringify(c).toLowerCase().includes(term));
125
+ }
126
+
127
+ return results;
128
+ }
129
+ );
130
+
131
+ function selectControl(control: Control) {
132
+ goto(`/control/${encodeURIComponent(control.id)}`);
133
+ }
134
+
135
+ function getStatusBadgeClass(status: string) {
136
+ switch (status) {
137
+ case 'Implemented':
138
+ return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300';
139
+ case 'Planned':
140
+ return 'bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300';
141
+ case 'Not Implemented':
142
+ return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300';
143
+ default:
144
+ return 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-300';
145
+ }
146
+ }
147
+
148
+ function getComplianceBadgeClass(status: string) {
149
+ switch (status) {
150
+ case 'Compliant':
151
+ return 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-800 dark:text-emerald-300';
152
+ case 'Non-Compliant':
153
+ return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300';
154
+ case 'Not Assessed':
155
+ return 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-300';
156
+ default:
157
+ return 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-300';
158
+ }
159
+ }
160
+
161
+ function extractDescriptionFromNested(data: any): string {
162
+ if (typeof data === 'string') {
163
+ return data;
164
+ }
165
+ if (Array.isArray(data)) {
166
+ // Try to find the first string in the array or extract from nested objects
167
+ for (const item of data) {
168
+ if (typeof item === 'string') {
169
+ return item;
170
+ }
171
+ if (typeof item === 'object' && item !== null) {
172
+ // Extract the first text from nested objects
173
+ for (const [key, value] of Object.entries(item)) {
174
+ if (Array.isArray(value)) {
175
+ const firstText = extractDescriptionFromNested(value);
176
+ if (firstText && typeof firstText === 'string') {
177
+ return firstText;
178
+ }
179
+ }
180
+ // Return the key as it's usually meaningful text
181
+ return key.replace(/:$/, ''); // Remove trailing colon
182
+ }
183
+ }
184
+ }
185
+ }
186
+ return 'No description available';
187
+ }
188
+
189
+ // Helper to determine if a field should be truncated and show tooltip
190
+ function shouldTruncateField(field: FieldSchema | undefined, value: string): boolean {
191
+ if (!value) return false;
192
+
193
+ // Always show tooltip for textarea fields if content is long
194
+ if (field?.ui_type === 'textarea' || field?.ui_type === 'long_text') {
195
+ return value.length > 100; // Lower threshold for long text fields
196
+ }
197
+
198
+ // For short_text fields, only show if really long
199
+ if (field?.ui_type === 'short_text') {
200
+ return value.length > 200;
201
+ }
202
+
203
+ // Default for unknown field types
204
+ return value.length > 150;
205
+ }
206
+
207
+ // Get truncation length based on field type
208
+ function getTruncationLength(field: FieldSchema | undefined): number {
209
+ if (field?.ui_type === 'textarea' || field?.ui_type === 'long_text') {
210
+ return 100;
211
+ }
212
+ if (field?.ui_type === 'short_text') {
213
+ return 200;
214
+ }
215
+ return 150;
216
+ }
217
+
218
+ // Check if field should show only icon (for textarea/long_text fields)
219
+ function isLongTextField(field: FieldSchema | undefined): boolean {
220
+ return field?.ui_type === 'textarea' || field?.ui_type === 'long_text';
221
+ }
222
+ </script>
223
+
224
+ <div class="h-full flex flex-col">
225
+ {#if $loading || schemaLoading}
226
+ <!-- Loading state -->
227
+ <div class="flex-1 flex items-center justify-center">
228
+ <div class="text-center">
229
+ <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
230
+ <p class="mt-4 text-sm text-gray-600 dark:text-gray-400">Loading controls...</p>
231
+ </div>
232
+ </div>
233
+ {:else}
234
+ <!-- Compact Header with Controls and Search -->
235
+ <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 space-y-4">
236
+ <!-- Title and Count -->
237
+ <div class="flex items-center justify-between">
238
+ <h2 class="text-lg font-semibold text-gray-900 dark:text-white">
239
+ {$appState.title || $appState.name || 'Controls'}
240
+ </h2>
241
+ <span class="text-sm text-gray-600 dark:text-gray-400">
242
+ {$filteredControlsWithMappings.length} of {$controls.length}
243
+ </span>
244
+ </div>
245
+
246
+ <!-- Search Bar, Family Filter, and Export -->
247
+ <div class="flex gap-3">
248
+ <div class="flex-1">
249
+ <SearchBar />
250
+ </div>
251
+ <div class="flex-shrink-0">
252
+ <Dropdown
253
+ buttonLabel={$selectedFamily || 'All Families'}
254
+ buttonIcon={Filter}
255
+ buttonClass="bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
256
+ dropdownClass="w-64"
257
+ >
258
+ {#snippet children()}
259
+ <div class="space-y-1">
260
+ <button
261
+ onclick={() => {
262
+ complianceStore.setSelectedFamily(null);
263
+ }}
264
+ class="w-full text-left px-3 py-2 text-sm rounded-md transition-colors duration-200 flex items-center justify-between {$selectedFamily ===
265
+ null
266
+ ? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
267
+ : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
268
+ >
269
+ <span>All Families</span>
270
+ <span class="text-xs bg-gray-200 dark:bg-gray-600 px-2 py-1 rounded-full">
271
+ {$controls.length}
272
+ </span>
273
+ </button>
274
+
275
+ {#each $families as family}
276
+ {@const familyCount = $controls.filter((c) => {
277
+ const controlFamily =
278
+ (c as any)?._metadata?.family ||
279
+ (c as any)?.family ||
280
+ (c as any)?.['control-acronym']?.split('-')[0] ||
281
+ '';
282
+ return controlFamily === family;
283
+ }).length}
284
+ <button
285
+ onclick={() => {
286
+ complianceStore.setSelectedFamily(family);
287
+ }}
288
+ class="w-full text-left px-3 py-2 text-sm rounded-md transition-colors duration-200 flex items-center justify-between {$selectedFamily ===
289
+ family
290
+ ? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
291
+ : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'}"
292
+ >
293
+ <span>{family}</span>
294
+ <span class="text-xs bg-gray-200 dark:bg-gray-600 px-2 py-1 rounded-full">
295
+ {familyCount}
296
+ </span>
297
+ </button>
298
+ {/each}
299
+ </div>
300
+ {/snippet}
301
+ </Dropdown>
302
+ </div>
303
+ </div>
304
+ </div>
305
+
306
+ <!-- Controls Table -->
307
+ <div class="flex-1 flex flex-col overflow-hidden">
308
+ <!-- Fixed Table Header -->
309
+ <div
310
+ class="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-600 flex-shrink-0"
311
+ >
312
+ {#if tableColumns.length > 0}
313
+ <!-- Dynamic columns based on field schema -->
314
+ <div
315
+ class="grid gap-4 px-6 py-3"
316
+ style="grid-template-columns: repeat({tableColumns.length + 1}, minmax(0, 1fr)); max-width: 100%;"
317
+ >
318
+ {#each tableColumns as { fieldName, field }}
319
+ <div
320
+ class="text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider"
321
+ >
322
+ {field.original_name ||
323
+ (fieldName === 'id' ? 'Control ID' : fieldName.replace(/-/g, ' '))}
324
+ </div>
325
+ {/each}
326
+ <div
327
+ class="text-center text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider"
328
+ >
329
+ Mappings
330
+ </div>
331
+ </div>
332
+ {:else}
333
+ <!-- Fallback to default columns -->
334
+ <div class="grid grid-cols-5 gap-4 px-6 py-3">
335
+ <div
336
+ class="text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider"
337
+ >
338
+ Control
339
+ </div>
340
+ <div
341
+ class="text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider"
342
+ >
343
+ Title
344
+ </div>
345
+ <div
346
+ class="text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider"
347
+ >
348
+ Statement
349
+ </div>
350
+ <div
351
+ class="text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider"
352
+ >
353
+ Family
354
+ </div>
355
+ <div
356
+ class="text-center text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider"
357
+ >
358
+ Mappings
359
+ </div>
360
+ </div>
361
+ {/if}
362
+ </div>
363
+
364
+ <!-- Scrollable Table Body -->
365
+ <div class="flex-1 overflow-auto">
366
+ <div class="divide-y divide-gray-200 dark:divide-gray-700">
367
+ {#each $filteredControlsWithMappings as control}
368
+ {@const rawDescription = (() => {
369
+ // Cast control to any to allow dynamic field access
370
+ const anyControl = control as any;
371
+ // Try to find a description from any text field in the schema
372
+ if (fieldSchema) {
373
+ for (const [fieldName, field] of Object.entries(fieldSchema)) {
374
+ if ((field.ui_type === 'long_text' || field.ui_type === 'short_text') && anyControl[fieldName]) {
375
+ return anyControl[fieldName];
376
+ }
377
+ }
378
+ }
379
+ // Fallback to any field that contains text
380
+ for (const [key, value] of Object.entries(control)) {
381
+ if (typeof value === 'string' && value.length > 20 && !key.startsWith('_')) {
382
+ return value;
383
+ }
384
+ }
385
+ return 'No description available';
386
+ })()}
387
+ {@const description = extractDescriptionFromNested(rawDescription) || ''}
388
+ {@const cleanDescription = description
389
+ ? description
390
+ .replace(/^(a\.|b\.|1\.|2\.|\s|The organization:)+/, '')
391
+ .replace(/\s+/g, ' ')
392
+ .trim()
393
+ .substring(0, 200) + (description.length > 200 ? '...' : '')
394
+ : 'No description available'}
395
+ {#if tableColumns.length > 0}
396
+ <!-- Dynamic columns based on field schema -->
397
+ <div
398
+ class="grid gap-4 px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer transition-all duration-150 {selectedControlId ===
399
+ control.id
400
+ ? 'bg-blue-50 dark:bg-blue-900/20 border-l-4 border-blue-500 shadow-sm'
401
+ : ''}"
402
+ style="grid-template-columns: repeat({tableColumns.length + 1}, minmax(0, 1fr)); max-width: 100%;"
403
+ onclick={() => selectControl(control)}
404
+ onkeydown={(e) =>
405
+ e.key === 'Enter' || e.key === ' ' ? selectControl(control) : null}
406
+ role="button"
407
+ tabindex="0"
408
+ aria-label="Select control {control.id}"
409
+ >
410
+ {#each tableColumns as { fieldName, field }}
411
+ {@const value = (control as any)[fieldName]}
412
+ <div class="flex flex-col justify-center">
413
+ {#if field.ui_type === 'select' && value}
414
+ <span
415
+ class="inline-flex px-2.5 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200 w-fit"
416
+ >
417
+ {value}
418
+ </span>
419
+ {:else if field.ui_type === 'boolean'}
420
+ <span class="text-sm text-gray-900 dark:text-white">
421
+ {value ? 'Yes' : 'No'}
422
+ </span>
423
+ {:else if typeof value === 'string' && value}
424
+ {@const isLongField = isLongTextField(field)}
425
+ {@const truncLength = getTruncationLength(field)}
426
+ {@const needsTruncation = value.length > truncLength}
427
+ {@const displayValue = needsTruncation ? value.substring(0, truncLength) + '...' : value}
428
+ {@const previewText = value.substring(0, 600) + (value.length > 600 ? '...' : '')}
429
+ {#if isLongField && value}
430
+ <!-- For textarea/long_text fields, show only icon with tooltip -->
431
+ <div class="flex items-center">
432
+ <Tooltip content={previewText} placement="bottom" maxWidth="500px" multiline={true}>
433
+ <Information size={20} class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 cursor-help" />
434
+ </Tooltip>
435
+ </div>
436
+ {:else if needsTruncation}
437
+ <!-- For other fields that are truncated, show text with icon -->
438
+ <div class="flex items-start gap-1">
439
+ <div class="text-sm text-gray-900 dark:text-white line-clamp-2 flex-1">
440
+ {displayValue}
441
+ </div>
442
+ <Tooltip content={previewText} placement="bottom" maxWidth="500px" multiline={true}>
443
+ <Information size={16} class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 flex-shrink-0 mt-0.5 cursor-help" />
444
+ </Tooltip>
445
+ </div>
446
+ {:else}
447
+ <!-- For short text, show as is -->
448
+ <div class="text-sm text-gray-900 dark:text-white line-clamp-2">
449
+ {value}
450
+ </div>
451
+ {/if}
452
+ {:else if Array.isArray(value)}
453
+ {@const extractedText = extractDescriptionFromNested(value)}
454
+ {@const isLongField = isLongTextField(field)}
455
+ {@const truncLength = getTruncationLength(field)}
456
+ {@const needsTruncation = extractedText.length > truncLength}
457
+ {@const displayValue = needsTruncation ? extractedText.substring(0, truncLength) + '...' : extractedText}
458
+ {@const previewText = extractedText.substring(0, 600) + (extractedText.length > 600 ? '...' : '')}
459
+ {#if isLongField && extractedText}
460
+ <!-- For textarea/long_text fields, show only icon with tooltip -->
461
+ <div class="flex items-center">
462
+ <Tooltip content={previewText} placement="bottom" maxWidth="500px" multiline={true}>
463
+ <Information size={20} class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 cursor-help" />
464
+ </Tooltip>
465
+ </div>
466
+ {:else if needsTruncation}
467
+ <!-- For other fields that are truncated, show text with icon -->
468
+ <div class="flex items-start gap-1">
469
+ <div class="text-sm text-gray-900 dark:text-white line-clamp-2 flex-1">
470
+ {displayValue}
471
+ </div>
472
+ <Tooltip content={previewText} placement="bottom" maxWidth="500px" multiline={true}>
473
+ <Information size={16} class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 flex-shrink-0 mt-0.5 cursor-help" />
474
+ </Tooltip>
475
+ </div>
476
+ {:else}
477
+ <!-- For short text, show as is -->
478
+ <div class="text-sm text-gray-900 dark:text-white line-clamp-2">
479
+ {extractedText}
480
+ </div>
481
+ {/if}
482
+ {:else if value}
483
+ <div class="text-sm text-gray-900 dark:text-white">
484
+ {JSON.stringify(value)}
485
+ </div>
486
+ {:else}
487
+ <span class="text-sm text-gray-400 dark:text-gray-500">-</span>
488
+ {/if}
489
+ </div>
490
+ {/each}
491
+ <!-- Mappings Column -->
492
+ <div class="flex items-center justify-center">
493
+ <span
494
+ class="inline-flex px-2 py-1 text-xs font-medium rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300"
495
+ >
496
+ {control.mappings?.length || 0}
497
+ </span>
498
+ </div>
499
+ </div>
500
+ {:else}
501
+ {@const statementIsLongText = cleanDescription.length > 120}
502
+ <!-- Fallback to default columns -->
503
+ <div
504
+ class="grid grid-cols-5 gap-4 px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer transition-all duration-150 {selectedControlId ===
505
+ control.id
506
+ ? 'bg-blue-50 dark:bg-blue-900/20 border-l-4 border-blue-500 shadow-sm'
507
+ : ''}"
508
+ onclick={() => selectControl(control)}
509
+ onkeydown={(e) =>
510
+ e.key === 'Enter' || e.key === ' ' ? selectControl(control) : null}
511
+ role="button"
512
+ tabindex="0"
513
+ aria-label="Select control {control.id}"
514
+ >
515
+ <!-- Control Column -->
516
+ <div class="flex flex-col justify-center">
517
+ <div class="text-sm font-semibold text-gray-900 dark:text-white">
518
+ {control.id}
519
+ </div>
520
+ </div>
521
+ <!-- Title Column -->
522
+ <div class="flex flex-col justify-center">
523
+ <div class="text-sm text-gray-900 dark:text-white font-medium">
524
+ {control.title || 'No Title'}
525
+ </div>
526
+ </div>
527
+ <!-- Statement Column -->
528
+ <div class="flex flex-col justify-center">
529
+ {#if statementIsLongText}
530
+ <div class="flex items-start gap-1">
531
+ <div class="text-sm text-gray-900 dark:text-white line-clamp-2 flex-1">
532
+ {cleanDescription.substring(0, 120)}...
533
+ </div>
534
+ <Tooltip content={cleanDescription.substring(0, 400) + (cleanDescription.length > 400 ? '...' : '')} placement="bottom" maxWidth="400px" multiline={true}>
535
+ <Information size={16} class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 flex-shrink-0 mt-0.5 cursor-help" />
536
+ </Tooltip>
537
+ </div>
538
+ {:else}
539
+ <div class="text-sm text-gray-900 dark:text-white line-clamp-2">
540
+ {cleanDescription}
541
+ </div>
542
+ {/if}
543
+ </div>
544
+ <!-- Family Column -->
545
+ <div class="flex items-center justify-center">
546
+ <span
547
+ class="inline-flex px-2.5 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"
548
+ >
549
+ {(control.family || control.id?.split('-')[0] || '').toUpperCase()}
550
+ </span>
551
+ </div>
552
+ <!-- Mappings Column -->
553
+ <div class="flex items-center justify-center">
554
+ <span
555
+ class="inline-flex px-2 py-1 text-xs font-medium rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300"
556
+ >
557
+ {control.mappings?.length || 0}
558
+ </span>
559
+ </div>
560
+ </div>
561
+ {/if}
562
+ {/each}
563
+ </div>
564
+ </div>
565
+ </div>
566
+
567
+ {#if $filteredControlsWithMappings.length === 0}
568
+ <div class="flex items-center justify-center py-16">
569
+ <div class="text-center">
570
+ <svg
571
+ class="mx-auto h-16 w-16 text-gray-300 dark:text-gray-600"
572
+ fill="none"
573
+ viewBox="0 0 24 24"
574
+ stroke="currentColor"
575
+ >
576
+ <path
577
+ stroke-linecap="round"
578
+ stroke-linejoin="round"
579
+ stroke-width="1.5"
580
+ d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
581
+ />
582
+ </svg>
583
+ <h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No controls found</h3>
584
+ <p class="mt-2 text-sm text-gray-500 dark:text-gray-400 max-w-sm mx-auto">
585
+ {#if $searchTerm}
586
+ No controls match your search criteria. Try adjusting your search terms or clearing
587
+ filters.
588
+ {:else if $selectedFamily}
589
+ No controls available in this family. Select a different family or check your data.
590
+ {:else if $controls.length === 0}
591
+ No controls have been imported yet.
592
+ {:else}
593
+ No controls available. Select a different family or check your data.
594
+ {/if}
595
+ </p>
596
+ {#if $controls.length === 0}
597
+ <a
598
+ href="/setup"
599
+ class="mt-4 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
600
+ >
601
+ Import Controls Now
602
+ </a>
603
+ {/if}
604
+ </div>
605
+ </div>
606
+ {/if}
607
+ {/if}
608
+ </div>