@cat-factory/app 0.79.2 → 0.81.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 (30) hide show
  1. package/app/components/documents/TaskContextDocs.vue +10 -7
  2. package/app/components/fragments/FragmentLibraryManager.vue +2 -10
  3. package/app/components/github/AddServiceFromRepoModal.vue +2 -1
  4. package/app/components/panels/InspectorPanel.vue +35 -18
  5. package/app/components/panels/inspector/ContainerSummary.vue +8 -2
  6. package/app/components/panels/inspector/EpicChildren.vue +9 -7
  7. package/app/components/panels/inspector/FrontendConfig.vue +175 -248
  8. package/app/components/panels/inspector/InspectorSection.vue +60 -0
  9. package/app/components/panels/inspector/ServiceConnections.vue +10 -10
  10. package/app/components/panels/inspector/ServiceFragments.vue +14 -9
  11. package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +8 -7
  12. package/app/components/panels/inspector/ServiceTestConfig.vue +45 -57
  13. package/app/components/panels/inspector/TaskAgentConfig.vue +8 -5
  14. package/app/components/panels/inspector/TaskDependencies.vue +12 -8
  15. package/app/components/panels/inspector/TaskEstimateBadge.vue +9 -8
  16. package/app/components/panels/inspector/TaskExecution.vue +9 -2
  17. package/app/components/panels/inspector/TaskRunSettings.vue +13 -2
  18. package/app/components/panels/inspector/TaskStructure.vue +9 -2
  19. package/app/components/requirements/RequirementsReviewWindow.vue +126 -42
  20. package/app/components/tasks/TaskContextIssues.vue +10 -7
  21. package/app/stores/fragmentLibrary.ts +4 -4
  22. package/i18n/locales/en.json +28 -3
  23. package/i18n/locales/es.json +28 -3
  24. package/i18n/locales/fr.json +28 -3
  25. package/i18n/locales/he.json +28 -3
  26. package/i18n/locales/ja.json +28 -3
  27. package/i18n/locales/pl.json +28 -3
  28. package/i18n/locales/tr.json +28 -3
  29. package/i18n/locales/uk.json +28 -3
  30. package/package.json +2 -2
@@ -11,6 +11,7 @@ import type {
11
11
  PreviewStatus,
12
12
  } from '~/types/domain'
13
13
  import FrontendBindingsResolved from '~/components/panels/inspector/FrontendBindingsResolved.vue'
14
+ import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
14
15
 
15
16
  // Frontend-frame (`type: 'frontend'`) configuration: how to build, serve, and mock this
16
17
  // frontend for a self-contained UI test (+ an optional browsable preview on local/node),
@@ -96,6 +97,7 @@ function setBindingSource(index: number, value: string) {
96
97
  }
97
98
 
98
99
  function addBinding() {
100
+ // InspectorSection expands its group when a header action is clicked, so the new row is visible.
99
101
  save({ backendBindings: [...bindings.value, { envVar: '', source: { kind: 'mock' } }] })
100
102
  }
101
103
 
@@ -209,14 +211,10 @@ onUnmounted(() => preview.stopPolling(props.block.id))
209
211
  </script>
210
212
 
211
213
  <template>
212
- <div class="space-y-3">
213
- <div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
214
- {{ t('inspector.frontendConfig.title') }}
215
- </div>
216
- <p class="text-[11px] leading-snug text-slate-500">
217
- {{ t('inspector.frontendConfig.hint') }}
218
- </p>
219
-
214
+ <InspectorSection
215
+ :title="t('inspector.frontendConfig.title')"
216
+ :hint="t('inspector.frontendConfig.hint')"
217
+ >
220
218
  <!-- Detect from repo: propose a config the user reviews + applies. Non-binding — nothing is
221
219
  persisted until Apply. Only shown when the frame is linked to a repo. -->
222
220
  <div
@@ -289,218 +287,179 @@ onUnmounted(() => preview.stopPolling(props.block.id))
289
287
  </div>
290
288
 
291
289
  <!-- Build: package manager + frontend directory + install command + build script + output dir -->
292
- <div class="border-t border-slate-800 pt-2">
293
- <button
294
- type="button"
295
- class="flex w-full items-center gap-1.5 text-start text-[11px] font-semibold uppercase tracking-wide text-slate-500 hover:text-slate-300"
296
- @click="showBuild = !showBuild"
297
- >
298
- <UIcon
299
- :name="showBuild ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
300
- class="h-3.5 w-3.5"
290
+ <InspectorSection v-model:open="showBuild" :title="t('inspector.frontendConfig.groups.build')">
291
+ <div class="space-y-1">
292
+ <span class="text-[11px] text-slate-400">{{
293
+ t('inspector.frontendConfig.packageManager')
294
+ }}</span>
295
+ <div class="flex flex-wrap gap-1">
296
+ <UButton
297
+ v-for="pm in PACKAGE_MANAGERS"
298
+ :key="pm"
299
+ :color="packageManager === pm ? 'primary' : 'neutral'"
300
+ :variant="packageManager === pm ? 'soft' : 'ghost'"
301
+ size="xs"
302
+ @click="save({ packageManager: pm })"
303
+ >
304
+ {{ pm }}
305
+ </UButton>
306
+ </div>
307
+ </div>
308
+
309
+ <div class="space-y-1">
310
+ <label class="text-[11px] text-slate-400">{{
311
+ t('inspector.frontendConfig.directory')
312
+ }}</label>
313
+ <UInput
314
+ :model-value="config.directory ?? ''"
315
+ size="xs"
316
+ class="font-mono"
317
+ maxlength="400"
318
+ placeholder="frontend/"
319
+ @blur="(e: FocusEvent) => saveText('directory', (e.target as HTMLInputElement).value)"
320
+ @keydown.enter="
321
+ (e: KeyboardEvent) => saveText('directory', (e.target as HTMLInputElement).value)
322
+ "
301
323
  />
302
- {{ t('inspector.frontendConfig.groups.build') }}
303
- </button>
324
+ <p class="text-[11px] leading-snug text-slate-500">
325
+ {{ t('inspector.frontendConfig.directoryHint') }}
326
+ </p>
327
+ </div>
304
328
 
305
- <div v-if="showBuild" class="mt-2 space-y-3">
306
- <div class="space-y-1">
307
- <span class="text-[11px] text-slate-400">{{
308
- t('inspector.frontendConfig.packageManager')
309
- }}</span>
310
- <div class="flex flex-wrap gap-1">
311
- <UButton
312
- v-for="pm in PACKAGE_MANAGERS"
313
- :key="pm"
314
- :color="packageManager === pm ? 'primary' : 'neutral'"
315
- :variant="packageManager === pm ? 'soft' : 'ghost'"
316
- size="xs"
317
- @click="save({ packageManager: pm })"
318
- >
319
- {{ pm }}
320
- </UButton>
321
- </div>
322
- </div>
329
+ <div class="space-y-1">
330
+ <label class="text-[11px] text-slate-400">{{
331
+ t('inspector.frontendConfig.installCommand')
332
+ }}</label>
333
+ <UInput
334
+ :model-value="config.installCommand ?? ''"
335
+ size="xs"
336
+ class="font-mono"
337
+ maxlength="400"
338
+ placeholder="pnpm install --frozen-lockfile"
339
+ @blur="
340
+ (e: FocusEvent) => saveText('installCommand', (e.target as HTMLInputElement).value)
341
+ "
342
+ @keydown.enter="
343
+ (e: KeyboardEvent) => saveText('installCommand', (e.target as HTMLInputElement).value)
344
+ "
345
+ />
346
+ </div>
323
347
 
348
+ <div class="grid grid-cols-2 gap-2">
324
349
  <div class="space-y-1">
325
350
  <label class="text-[11px] text-slate-400">{{
326
- t('inspector.frontendConfig.directory')
351
+ t('inspector.frontendConfig.buildScript')
327
352
  }}</label>
328
353
  <UInput
329
- :model-value="config.directory ?? ''"
354
+ :model-value="config.buildScript ?? ''"
330
355
  size="xs"
331
356
  class="font-mono"
332
- maxlength="400"
333
- placeholder="frontend/"
334
- @blur="(e: FocusEvent) => saveText('directory', (e.target as HTMLInputElement).value)"
357
+ maxlength="200"
358
+ placeholder="build"
359
+ @blur="(e: FocusEvent) => saveText('buildScript', (e.target as HTMLInputElement).value)"
335
360
  @keydown.enter="
336
- (e: KeyboardEvent) => saveText('directory', (e.target as HTMLInputElement).value)
361
+ (e: KeyboardEvent) => saveText('buildScript', (e.target as HTMLInputElement).value)
337
362
  "
338
363
  />
339
- <p class="text-[11px] leading-snug text-slate-500">
340
- {{ t('inspector.frontendConfig.directoryHint') }}
341
- </p>
342
364
  </div>
343
-
344
365
  <div class="space-y-1">
345
366
  <label class="text-[11px] text-slate-400">{{
346
- t('inspector.frontendConfig.installCommand')
367
+ t('inspector.frontendConfig.outputDir')
347
368
  }}</label>
348
369
  <UInput
349
- :model-value="config.installCommand ?? ''"
370
+ :model-value="config.outputDir ?? ''"
350
371
  size="xs"
351
372
  class="font-mono"
352
373
  maxlength="400"
353
- placeholder="pnpm install --frozen-lockfile"
354
- @blur="
355
- (e: FocusEvent) => saveText('installCommand', (e.target as HTMLInputElement).value)
356
- "
374
+ placeholder="dist"
375
+ @blur="(e: FocusEvent) => saveText('outputDir', (e.target as HTMLInputElement).value)"
357
376
  @keydown.enter="
358
- (e: KeyboardEvent) => saveText('installCommand', (e.target as HTMLInputElement).value)
377
+ (e: KeyboardEvent) => saveText('outputDir', (e.target as HTMLInputElement).value)
359
378
  "
360
379
  />
361
380
  </div>
362
-
363
- <div class="grid grid-cols-2 gap-2">
364
- <div class="space-y-1">
365
- <label class="text-[11px] text-slate-400">{{
366
- t('inspector.frontendConfig.buildScript')
367
- }}</label>
368
- <UInput
369
- :model-value="config.buildScript ?? ''"
370
- size="xs"
371
- class="font-mono"
372
- maxlength="200"
373
- placeholder="build"
374
- @blur="
375
- (e: FocusEvent) => saveText('buildScript', (e.target as HTMLInputElement).value)
376
- "
377
- @keydown.enter="
378
- (e: KeyboardEvent) => saveText('buildScript', (e.target as HTMLInputElement).value)
379
- "
380
- />
381
- </div>
382
- <div class="space-y-1">
383
- <label class="text-[11px] text-slate-400">{{
384
- t('inspector.frontendConfig.outputDir')
385
- }}</label>
386
- <UInput
387
- :model-value="config.outputDir ?? ''"
388
- size="xs"
389
- class="font-mono"
390
- maxlength="400"
391
- placeholder="dist"
392
- @blur="(e: FocusEvent) => saveText('outputDir', (e.target as HTMLInputElement).value)"
393
- @keydown.enter="
394
- (e: KeyboardEvent) => saveText('outputDir', (e.target as HTMLInputElement).value)
395
- "
396
- />
397
- </div>
398
- </div>
399
381
  </div>
400
- </div>
382
+ </InspectorSection>
401
383
 
402
384
  <!-- Serve: mode (static vs command) + serve script (command mode) + port -->
403
- <div class="border-t border-slate-800 pt-2">
404
- <button
405
- type="button"
406
- class="flex w-full items-center gap-1.5 text-start text-[11px] font-semibold uppercase tracking-wide text-slate-500 hover:text-slate-300"
407
- @click="showServe = !showServe"
408
- >
409
- <UIcon
410
- :name="showServe ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
411
- class="h-3.5 w-3.5"
412
- />
413
- {{ t('inspector.frontendConfig.groups.serve') }}
414
- </button>
415
-
416
- <div v-if="showServe" class="mt-2 space-y-3">
417
- <div class="space-y-1">
418
- <span class="text-[11px] text-slate-400">{{
419
- t('inspector.frontendConfig.serveMode')
420
- }}</span>
421
- <div class="flex flex-wrap gap-1">
422
- <UButton
423
- :color="serveMode === 'static' ? 'primary' : 'neutral'"
424
- :variant="serveMode === 'static' ? 'soft' : 'ghost'"
425
- size="xs"
426
- @click="save({ serveMode: 'static' })"
427
- >
428
- {{ t('inspector.frontendConfig.serveStatic') }}
429
- </UButton>
430
- <UButton
431
- :color="serveMode === 'command' ? 'primary' : 'neutral'"
432
- :variant="serveMode === 'command' ? 'soft' : 'ghost'"
433
- size="xs"
434
- @click="save({ serveMode: 'command' })"
435
- >
436
- {{ t('inspector.frontendConfig.serveCommand') }}
437
- </UButton>
438
- </div>
439
- <!-- Explain each mode, and disambiguate from the separate envInjection axis. -->
440
- <p class="text-[11px] leading-snug text-slate-500">
441
- <span class="text-slate-400">{{ t('inspector.frontendConfig.serveStatic') }}:</span>
442
- {{ t('inspector.frontendConfig.serveStaticDesc') }}
443
- </p>
444
- <p class="text-[11px] leading-snug text-slate-500">
445
- <span class="text-slate-400">{{ t('inspector.frontendConfig.serveCommand') }}:</span>
446
- {{ t('inspector.frontendConfig.serveCommandDesc') }}
447
- </p>
448
- <p class="text-[11px] leading-snug text-slate-500/80">
449
- {{ t('inspector.frontendConfig.serveEnvAxisNote') }}
450
- </p>
451
- </div>
452
-
453
- <div v-if="serveMode === 'command'" class="space-y-1">
454
- <label class="text-[11px] text-slate-400">{{
455
- t('inspector.frontendConfig.serveScript')
456
- }}</label>
457
- <UInput
458
- :model-value="config.serveScript ?? ''"
385
+ <InspectorSection v-model:open="showServe" :title="t('inspector.frontendConfig.groups.serve')">
386
+ <div class="space-y-1">
387
+ <span class="text-[11px] text-slate-400">{{
388
+ t('inspector.frontendConfig.serveMode')
389
+ }}</span>
390
+ <div class="flex flex-wrap gap-1">
391
+ <UButton
392
+ :color="serveMode === 'static' ? 'primary' : 'neutral'"
393
+ :variant="serveMode === 'static' ? 'soft' : 'ghost'"
459
394
  size="xs"
460
- class="font-mono"
461
- maxlength="200"
462
- placeholder="preview"
463
- @blur="(e: FocusEvent) => saveText('serveScript', (e.target as HTMLInputElement).value)"
464
- @keydown.enter="
465
- (e: KeyboardEvent) => saveText('serveScript', (e.target as HTMLInputElement).value)
466
- "
467
- />
468
- </div>
469
-
470
- <div class="space-y-1">
471
- <label class="text-[11px] text-slate-400">{{
472
- t('inspector.frontendConfig.servePort')
473
- }}</label>
474
- <UInput
475
- :model-value="config.servePort != null ? String(config.servePort) : ''"
476
- type="number"
477
- min="1"
478
- max="65535"
479
- step="1"
395
+ @click="save({ serveMode: 'static' })"
396
+ >
397
+ {{ t('inspector.frontendConfig.serveStatic') }}
398
+ </UButton>
399
+ <UButton
400
+ :color="serveMode === 'command' ? 'primary' : 'neutral'"
401
+ :variant="serveMode === 'command' ? 'soft' : 'ghost'"
480
402
  size="xs"
481
- class="font-mono"
482
- placeholder="4173"
483
- @blur="(e: FocusEvent) => saveServePort((e.target as HTMLInputElement).value)"
484
- />
403
+ @click="save({ serveMode: 'command' })"
404
+ >
405
+ {{ t('inspector.frontendConfig.serveCommand') }}
406
+ </UButton>
485
407
  </div>
408
+ <!-- Explain each mode, and disambiguate from the separate envInjection axis. -->
409
+ <p class="text-[11px] leading-snug text-slate-500">
410
+ <span class="text-slate-400">{{ t('inspector.frontendConfig.serveStatic') }}:</span>
411
+ {{ t('inspector.frontendConfig.serveStaticDesc') }}
412
+ </p>
413
+ <p class="text-[11px] leading-snug text-slate-500">
414
+ <span class="text-slate-400">{{ t('inspector.frontendConfig.serveCommand') }}:</span>
415
+ {{ t('inspector.frontendConfig.serveCommandDesc') }}
416
+ </p>
417
+ <p class="text-[11px] leading-snug text-slate-500/80">
418
+ {{ t('inspector.frontendConfig.serveEnvAxisNote') }}
419
+ </p>
486
420
  </div>
487
- </div>
488
421
 
489
- <!-- Mocking: WireMock mappings dir -->
490
- <div class="border-t border-slate-800 pt-2">
491
- <button
492
- type="button"
493
- class="flex w-full items-center gap-1.5 text-start text-[11px] font-semibold uppercase tracking-wide text-slate-500 hover:text-slate-300"
494
- @click="showMocking = !showMocking"
495
- >
496
- <UIcon
497
- :name="showMocking ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
498
- class="h-3.5 w-3.5"
422
+ <div v-if="serveMode === 'command'" class="space-y-1">
423
+ <label class="text-[11px] text-slate-400">{{
424
+ t('inspector.frontendConfig.serveScript')
425
+ }}</label>
426
+ <UInput
427
+ :model-value="config.serveScript ?? ''"
428
+ size="xs"
429
+ class="font-mono"
430
+ maxlength="200"
431
+ placeholder="preview"
432
+ @blur="(e: FocusEvent) => saveText('serveScript', (e.target as HTMLInputElement).value)"
433
+ @keydown.enter="
434
+ (e: KeyboardEvent) => saveText('serveScript', (e.target as HTMLInputElement).value)
435
+ "
499
436
  />
500
- {{ t('inspector.frontendConfig.groups.mocking') }}
501
- </button>
437
+ </div>
502
438
 
503
- <div v-if="showMocking" class="mt-2 space-y-1">
439
+ <div class="space-y-1">
440
+ <label class="text-[11px] text-slate-400">{{
441
+ t('inspector.frontendConfig.servePort')
442
+ }}</label>
443
+ <UInput
444
+ :model-value="config.servePort != null ? String(config.servePort) : ''"
445
+ type="number"
446
+ min="1"
447
+ max="65535"
448
+ step="1"
449
+ size="xs"
450
+ class="font-mono"
451
+ placeholder="4173"
452
+ @blur="(e: FocusEvent) => saveServePort((e.target as HTMLInputElement).value)"
453
+ />
454
+ </div>
455
+ </InspectorSection>
456
+
457
+ <!-- Mocking: WireMock mappings dir -->
458
+ <InspectorSection
459
+ v-model:open="showMocking"
460
+ :title="t('inspector.frontendConfig.groups.mocking')"
461
+ >
462
+ <div class="space-y-1">
504
463
  <label class="text-[11px] text-slate-400">{{
505
464
  t('inspector.frontendConfig.mockMappingsPath')
506
465
  }}</label>
@@ -521,23 +480,14 @@ onUnmounted(() => preview.stopPolling(props.block.id))
521
480
  {{ t('inspector.frontendConfig.mockMappingsHint') }}
522
481
  </p>
523
482
  </div>
524
- </div>
483
+ </InspectorSection>
525
484
 
526
485
  <!-- Env injection: build-time env vars vs a runtime window.env shim -->
527
- <div class="border-t border-slate-800 pt-2">
528
- <button
529
- type="button"
530
- class="flex w-full items-center gap-1.5 text-start text-[11px] font-semibold uppercase tracking-wide text-slate-500 hover:text-slate-300"
531
- @click="showEnv = !showEnv"
532
- >
533
- <UIcon
534
- :name="showEnv ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
535
- class="h-3.5 w-3.5"
536
- />
537
- {{ t('inspector.frontendConfig.groups.envInjection') }}
538
- </button>
539
-
540
- <div v-if="showEnv" class="mt-2 space-y-1">
486
+ <InspectorSection
487
+ v-model:open="showEnv"
488
+ :title="t('inspector.frontendConfig.groups.envInjection')"
489
+ >
490
+ <div class="space-y-1">
541
491
  <div class="flex flex-wrap gap-1">
542
492
  <UButton
543
493
  :color="envInjection === 'build' ? 'primary' : 'neutral'"
@@ -560,39 +510,25 @@ onUnmounted(() => preview.stopPolling(props.block.id))
560
510
  {{ t('inspector.frontendConfig.envInjectionHint') }}
561
511
  </p>
562
512
  </div>
563
- </div>
513
+ </InspectorSection>
564
514
 
565
515
  <!-- Backend bindings: env var → upstream. These double as the board's frontend→service links. -->
566
- <div class="border-t border-slate-800 pt-2">
567
- <button
568
- type="button"
569
- class="flex w-full items-center gap-1.5 text-start text-[11px] font-semibold uppercase tracking-wide text-slate-500 hover:text-slate-300"
570
- @click="showBindings = !showBindings"
571
- >
572
- <UIcon
573
- :name="showBindings ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
574
- class="h-3.5 w-3.5"
516
+ <InspectorSection
517
+ v-model:open="showBindings"
518
+ :title="t('inspector.frontendConfig.groups.bindings')"
519
+ :hint="t('inspector.frontendConfig.bindings.hint')"
520
+ :count="bindings.length"
521
+ >
522
+ <template #actions>
523
+ <UButton
524
+ size="xs"
525
+ variant="ghost"
526
+ color="neutral"
527
+ icon="i-lucide-plus"
528
+ @click="addBinding"
575
529
  />
576
- {{ t('inspector.frontendConfig.groups.bindings') }}
577
- <span v-if="bindings.length" class="font-normal normal-case text-slate-600"
578
- >({{ bindings.length }})</span
579
- >
580
- </button>
581
-
582
- <div v-if="showBindings" class="mt-2 space-y-2">
583
- <div class="flex items-center justify-between">
584
- <p class="text-[11px] leading-snug text-slate-500">
585
- {{ t('inspector.frontendConfig.bindings.hint') }}
586
- </p>
587
- <UButton
588
- size="xs"
589
- variant="ghost"
590
- color="neutral"
591
- icon="i-lucide-plus"
592
- @click="addBinding"
593
- />
594
- </div>
595
-
530
+ </template>
531
+ <div class="space-y-2">
596
532
  <div v-if="bindings.length" class="space-y-1.5">
597
533
  <div v-for="(b, i) in bindings" :key="i" class="flex items-center gap-1">
598
534
  <UInput
@@ -634,23 +570,14 @@ onUnmounted(() => preview.stopPolling(props.block.id))
634
570
  <FrontendBindingsResolved :config="config" />
635
571
  </div>
636
572
  </div>
637
- </div>
573
+ </InspectorSection>
638
574
 
639
575
  <!-- Browsable preview (local/node only; the Worker reports it unsupported). -->
640
- <div class="border-t border-slate-800 pt-2">
641
- <button
642
- type="button"
643
- class="flex w-full items-center gap-1.5 text-start text-[11px] font-semibold uppercase tracking-wide text-slate-500 hover:text-slate-300"
644
- @click="showPreview = !showPreview"
645
- >
646
- <UIcon
647
- :name="showPreview ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
648
- class="h-3.5 w-3.5"
649
- />
650
- {{ t('inspector.frontendConfig.groups.preview') }}
651
- </button>
652
-
653
- <div v-if="showPreview" class="mt-2">
576
+ <InspectorSection
577
+ v-model:open="showPreview"
578
+ :title="t('inspector.frontendConfig.groups.preview')"
579
+ >
580
+ <div>
654
581
  <UCheckbox
655
582
  :model-value="previewSupported && config.previewEnabled === true"
656
583
  :label="t('inspector.frontendConfig.previewEnabled')"
@@ -744,6 +671,6 @@ onUnmounted(() => preview.stopPolling(props.block.id))
744
671
  </p>
745
672
  </div>
746
673
  </div>
747
- </div>
748
- </div>
674
+ </InspectorSection>
675
+ </InspectorSection>
749
676
  </template>
@@ -0,0 +1,60 @@
1
+ <script setup lang="ts">
2
+ // Shared collapsible section shell for the inspector panel: a chevron header with an
3
+ // optional count, warning flag, and header-actions slot (kept OUTSIDE the toggle button
4
+ // so clicking a "+" / menu never collapses the section — it only ever expands it, so a
5
+ // row added via the header control is never dropped into a hidden body), plus an optional
6
+ // explanatory hint rendered at the top of the body — what the section means and what it
7
+ // is used for.
8
+ //
9
+ // Open state is a `defineModel` so a parent that needs programmatic control (e.g.
10
+ // FrontendConfig opening its Build group after a detect run) can bind `v-model:open`;
11
+ // without a binding the model acts as local state and `defaultOpen` picks the initial
12
+ // value (controlled callers should drive their own ref instead of passing it).
13
+ const props = defineProps<{
14
+ title: string
15
+ icon?: string
16
+ hint?: string
17
+ count?: number
18
+ warning?: boolean
19
+ defaultOpen?: boolean
20
+ }>()
21
+
22
+ const open = defineModel<boolean>('open', { default: false })
23
+ if (props.defaultOpen) open.value = true
24
+ </script>
25
+
26
+ <template>
27
+ <section class="border-t border-slate-800 pt-2" data-testid="inspector-section">
28
+ <div class="flex items-center gap-1.5">
29
+ <button
30
+ type="button"
31
+ class="flex min-w-0 flex-1 items-center gap-1.5 text-start text-[11px] font-semibold uppercase tracking-wide text-slate-400 hover:text-slate-200"
32
+ :aria-expanded="open"
33
+ data-testid="inspector-section-toggle"
34
+ @click="open = !open"
35
+ >
36
+ <UIcon
37
+ :name="open ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
38
+ class="h-3.5 w-3.5 shrink-0 text-slate-500"
39
+ />
40
+ <UIcon v-if="icon" :name="icon" class="h-3.5 w-3.5 shrink-0" />
41
+ <span class="truncate">{{ title }}</span>
42
+ <span v-if="count" class="font-normal normal-case text-slate-500">({{ count }})</span>
43
+ <UIcon
44
+ v-if="warning"
45
+ name="i-lucide-triangle-alert"
46
+ class="h-3.5 w-3.5 shrink-0 text-amber-400"
47
+ />
48
+ </button>
49
+ <!-- An action ("+", attach, menu) mutates the body list, so expand (never collapse)
50
+ on interaction — otherwise the new row lands inside the collapsed body. -->
51
+ <div v-if="$slots.actions" class="contents" @click="open = true">
52
+ <slot name="actions" />
53
+ </div>
54
+ </div>
55
+ <div v-if="open" class="mt-2 space-y-3">
56
+ <p v-if="hint" class="text-[11px] leading-snug text-slate-500">{{ hint }}</p>
57
+ <slot />
58
+ </div>
59
+ </section>
60
+ </template>
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
3
  import type { Block, ServiceConnection } from '~/types/domain'
4
+ import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
4
5
 
5
6
  // Service-frame (`type: 'service'`) connections: the other services this one USES
6
7
  // (consumer→provider edges, stored on this frame — the consumer end). Each row picks a
@@ -78,11 +79,13 @@ const usedBy = computed(() =>
78
79
  </script>
79
80
 
80
81
  <template>
81
- <div class="space-y-2 border-t border-slate-800 pt-2" data-testid="service-connections">
82
- <div class="flex items-center justify-between">
83
- <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
84
- {{ t('inspector.serviceConnections.title') }}
85
- </span>
82
+ <InspectorSection
83
+ :title="t('inspector.serviceConnections.title')"
84
+ :hint="t('inspector.serviceConnections.hint')"
85
+ :count="connections.length"
86
+ data-testid="service-connections"
87
+ >
88
+ <template #actions>
86
89
  <UButton
87
90
  size="xs"
88
91
  variant="ghost"
@@ -92,10 +95,7 @@ const usedBy = computed(() =>
92
95
  data-testid="service-connection-add"
93
96
  @click="addConnection"
94
97
  />
95
- </div>
96
- <p class="text-[11px] leading-snug text-slate-500">
97
- {{ t('inspector.serviceConnections.hint') }}
98
- </p>
98
+ </template>
99
99
 
100
100
  <div v-if="connections.length" class="space-y-1.5">
101
101
  <div
@@ -147,5 +147,5 @@ const usedBy = computed(() =>
147
147
  </UBadge>
148
148
  </div>
149
149
  </div>
150
- </div>
150
+ </InspectorSection>
151
151
  </template>