@skyhook-io/k8s-ui 1.14.6 → 1.14.7

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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/components/applications/ApplicationDetail.tsx +36 -4
  3. package/src/components/applications/ApplicationsView.tsx +2 -1
  4. package/src/components/audit/AuditAlerts.tsx +35 -37
  5. package/src/components/audit/AuditFindingsTable.tsx +23 -27
  6. package/src/components/charts/PrometheusChartsView.tsx +29 -10
  7. package/src/components/checks/ChecksView.tsx +56 -26
  8. package/src/components/gitops/GitOpsDetailLayout.tsx +12 -9
  9. package/src/components/issues/IssuesView.tsx +35 -54
  10. package/src/components/logs/LogCore.tsx +40 -9
  11. package/src/components/resources/ResourcesSidebar.tsx +66 -78
  12. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +21 -15
  13. package/src/components/resources/renderers/CompositionRenderer.tsx +30 -10
  14. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +16 -12
  15. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +10 -4
  16. package/src/components/resources/renderers/GenericRenderer.tsx +13 -7
  17. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +6 -4
  18. package/src/components/resources/renderers/PolicyCoverageSection.tsx +7 -4
  19. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +13 -15
  20. package/src/components/resources/renderers/SbomReportRenderer.tsx +10 -4
  21. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +10 -4
  22. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +13 -6
  23. package/src/components/shared/CreateResourceDialog.tsx +7 -4
  24. package/src/components/timeline/TimelineList.tsx +14 -11
  25. package/src/components/timeline/TimelineStrip.tsx +26 -4
  26. package/src/components/timeline/TimelineSwimlanes.tsx +3 -5
  27. package/src/components/timeline/TimelineToolbar.tsx +43 -10
  28. package/src/components/topology/TopologyControls.tsx +27 -8
  29. package/src/components/topology/TopologyGraph.tsx +14 -5
  30. package/src/components/trace/ReachabilityView.tsx +17 -16
  31. package/src/components/ui/CardSection.tsx +79 -35
  32. package/src/components/ui/Collapse.test.tsx +74 -0
  33. package/src/components/ui/Collapse.tsx +115 -29
  34. package/src/components/ui/DialogPortal.tsx +7 -2
  35. package/src/components/ui/ForceDeleteConfirmDialog.tsx +7 -4
  36. package/src/components/ui/RestrictedState.tsx +7 -4
  37. package/src/components/ui/RowActionMenu.tsx +20 -8
  38. package/src/components/ui/SelectMenu.tsx +18 -6
  39. package/src/components/ui/YamlEditor.tsx +7 -9
  40. package/src/components/ui/drawer-components.tsx +14 -21
  41. package/src/components/ui/index.ts +30 -22
  42. package/src/components/workload/WorkloadView.tsx +6 -10
  43. package/src/hooks/useAnimatedUnmount.ts +21 -3
  44. package/src/theme/components.css +4 -1
  45. package/src/utils/animation.ts +93 -13
@@ -26,6 +26,8 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react
26
26
  import { clsx } from 'clsx'
27
27
  import { Calendar, ChevronDown, ChevronLeft, ChevronRight, Minus, Plus } from 'lucide-react'
28
28
  import { Tooltip } from '../ui/Tooltip'
29
+ import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
30
+ import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
29
31
  import {
30
32
  barHeight,
31
33
  clampLensToSelection,
@@ -100,6 +102,7 @@ function DateTimeField({ label, valueMs, onChange, nowMs }: {
100
102
  nowMs: number
101
103
  }) {
102
104
  const [open, setOpen] = useState(false)
105
+ const { shouldRender, isOpen } = useAnimatedUnmount(open, overlayExitMs('menu'))
103
106
  const rootRef = useRef<HTMLDivElement>(null)
104
107
  const value = new Date(valueMs)
105
108
  const [viewY, setViewY] = useState(() => value.getFullYear())
@@ -145,9 +148,16 @@ function DateTimeField({ label, valueMs, onChange, nowMs }: {
145
148
  {fieldStamp(valueMs)}
146
149
  <Calendar className="h-3 w-3 shrink-0 text-theme-text-tertiary" />
147
150
  </button>
148
- {open && (
151
+ {shouldRender && (
149
152
  <div
150
- className="absolute left-0 top-full z-[60] mt-1 w-[232px] rounded-lg border border-theme-border bg-theme-surface p-2.5 shadow-theme-lg"
153
+ inert={!open}
154
+ className={clsx(
155
+ 'absolute left-0 top-full z-[60] mt-1 w-[232px] origin-top-left rounded-lg border border-theme-border bg-theme-surface p-2.5 shadow-theme-lg',
156
+ TRANSITION_MENU,
157
+ isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]',
158
+ !open && 'pointer-events-none',
159
+ )}
160
+ style={overlayTransitionStyle(isOpen, 'menu')}
151
161
  role="dialog"
152
162
  aria-label={`Pick ${label.toLowerCase()} date and time`}
153
163
  >
@@ -294,6 +304,7 @@ export function TimelineStrip({
294
304
  const trackRef = useRef<HTMLDivElement>(null)
295
305
  const [width, setWidth] = useState(800)
296
306
  const [pickerOpen, setPickerOpen] = useState(false)
307
+ const { shouldRender: pickerRender, isOpen: pickerShown } = useAnimatedUnmount(pickerOpen, overlayExitMs('menu'))
297
308
  const [customFromMs, setCustomFromMs] = useState(0)
298
309
  const [customToMs, setCustomToMs] = useState(0)
299
310
 
@@ -528,11 +539,22 @@ export function TimelineStrip({
528
539
  <ChevronDown className="h-3 w-3 text-theme-text-tertiary" />
529
540
  </button>
530
541
  </Tooltip>
531
- {pickerOpen && (
542
+ {pickerRender && (
532
543
  // NO overflow-hidden: the DateTimeField calendars pop past the
533
544
  // dialog's bottom edge and were getting clipped to a header sliver.
534
545
  // The footer rounds its own bottom corners instead.
535
- <div className="absolute left-0 top-full z-50 mt-2 w-[460px] max-w-[92vw] rounded-xl border border-theme-border bg-theme-surface shadow-theme-lg" role="dialog" aria-label="Time range picker">
546
+ <div
547
+ inert={!pickerOpen}
548
+ className={clsx(
549
+ 'absolute left-0 top-full z-50 mt-2 w-[460px] max-w-[92vw] origin-top-left rounded-xl border border-theme-border bg-theme-surface shadow-theme-lg',
550
+ TRANSITION_MENU,
551
+ pickerShown ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]',
552
+ !pickerOpen && 'pointer-events-none',
553
+ )}
554
+ style={overlayTransitionStyle(pickerShown, 'menu')}
555
+ role="dialog"
556
+ aria-label="Time range picker"
557
+ >
536
558
  <p className="border-b border-theme-border/60 px-4 py-2.5 text-[11px] leading-relaxed text-theme-text-secondary">
537
559
  How much history to load. The blue band zooms into a slice of it.
538
560
  </p>
@@ -43,6 +43,7 @@ import { isChangeEvent, isHistoricalEvent, isOperation, displayKind } from '../.
43
43
  import { DiffViewer } from './DiffViewer'
44
44
  import { getHealthBadgeColor, getEventTypeColor } from '../../utils/badge-colors'
45
45
  import { MiddleEllipsis } from '../ui/MiddleEllipsis'
46
+ import { CollapseChevron } from '../ui/Collapse'
46
47
  import { Tooltip } from '../ui/Tooltip'
47
48
  import { ResourceRefBadge } from '../ui/drawer-components'
48
49
  import { buildResourceHierarchy, extractPinnedLanes, removePinnedLanes, isProblematicEvent, laneTrackEvents, isChildVisibleInWindow, collidingLaneKeys, laneCollisionKey, type ResourceLane as BaseResourceLane, type TimelineGrouping, type PinnedLaneRef } from '../../utils/resource-hierarchy'
@@ -1644,10 +1645,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
1644
1645
  aria-label={isExpanded ? 'Collapse' : 'Expand'}
1645
1646
  className="p-1 -m-0.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
1646
1647
  >
1647
- <ChevronRight className={clsx(
1648
- 'w-4 h-4 transition-transform',
1649
- isExpanded && 'rotate-90'
1650
- )} />
1648
+ <CollapseChevron open={isExpanded} inheritColor className="w-4 h-4" />
1651
1649
  </button>
1652
1650
  ) : compact ? null : (
1653
1651
  <div className="w-4" />
@@ -2285,7 +2283,7 @@ function ChildLaneLabel({ kind, group, showGroupChip, kindTitle, name, labelWidt
2285
2283
  style={{ left: selfRailPx }}
2286
2284
  aria-label={expanded ? 'Collapse' : 'Expand'}
2287
2285
  >
2288
- <ChevronRight className={clsx('w-4 h-4 transition-transform', expanded && 'rotate-90')} />
2286
+ <CollapseChevron open={!!expanded} inheritColor className="w-4 h-4" />
2289
2287
  </button>
2290
2288
  )}
2291
2289
  {/* Single-line child row: kind chip · name (middle-ellipsis). Colored chips
@@ -18,6 +18,8 @@ import {
18
18
  import { SearchBox } from '../ui/SearchBox'
19
19
  import { type ShortcutScope } from '../../hooks/useKeyboardShortcuts'
20
20
  import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
21
+ import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
22
+ import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
21
23
  import { pluralize } from '../../utils/pluralize'
22
24
  import type { TimelineEvent, TimeRange } from '../../types'
23
25
  import type { TimelineGrouping } from '../../utils/resource-hierarchy'
@@ -475,6 +477,7 @@ function SegmentedRadioGroup<T extends string>({
475
477
  */
476
478
  export function ViewMenu({ viewOptions }: { viewOptions: TimelineViewOptions }) {
477
479
  const [open, setOpen] = useState(false)
480
+ const { shouldRender, isOpen } = useAnimatedUnmount(open, overlayExitMs('menu'))
478
481
  const rootRef = useRef<HTMLDivElement | null>(null)
479
482
  usePopoverDismiss(open, setOpen, rootRef)
480
483
 
@@ -497,11 +500,18 @@ export function ViewMenu({ viewOptions }: { viewOptions: TimelineViewOptions })
497
500
  <ChevronDown className="w-3.5 h-3.5 text-theme-text-tertiary" />
498
501
  </button>
499
502
 
500
- {open && (
503
+ {shouldRender && (
501
504
  <div
502
505
  role="menu"
503
506
  aria-label="View options"
504
- className="absolute right-0 top-full z-50 mt-1 min-w-[14rem] rounded-lg border border-theme-border bg-theme-elevated p-2 shadow-theme-lg"
507
+ inert={!open}
508
+ className={clsx(
509
+ 'absolute right-0 top-full z-50 mt-1 min-w-[14rem] origin-top-right rounded-lg border border-theme-border bg-theme-elevated p-2 shadow-theme-lg',
510
+ TRANSITION_MENU,
511
+ isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]',
512
+ !open && 'pointer-events-none',
513
+ )}
514
+ style={overlayTransitionStyle(isOpen, 'menu')}
505
515
  >
506
516
  <span className="block px-2 pb-1 text-[10px] font-bold uppercase tracking-[0.08em] text-theme-text-tertiary">Sort</span>
507
517
  <SegmentedRadioGroup
@@ -634,10 +644,11 @@ function usePopoverDismiss(
634
644
  */
635
645
  function KindsMenu({ kindFilter, onKindFilterChange, kindOptions }: KindsMenuProps) {
636
646
  const [open, setOpen] = useState(false)
647
+ const { shouldRender, isOpen } = useAnimatedUnmount(open, overlayExitMs('menu'))
637
648
  const [search, setSearch] = useState('')
638
649
  const rootRef = useRef<HTMLDivElement | null>(null)
639
650
  const panelRef = useRef<HTMLDivElement | null>(null)
640
- const [anchor, setAnchor] = useState<{ left: number; top: number; maxHeight: number } | null>(null)
651
+ const [anchor, setAnchor] = useState<{ left: number; top: number; maxHeight: number; flipUp: boolean } | null>(null)
641
652
  const activeCount = kindFilter.length
642
653
  const selected = useMemo(() => new Set(kindFilter), [kindFilter])
643
654
 
@@ -645,9 +656,11 @@ function KindsMenu({ kindFilter, onKindFilterChange, kindOptions }: KindsMenuPro
645
656
  // clips absolutely-positioned children. Portal the popover to the body and pin
646
657
  // it to the button with fixed coords so it escapes the clip. Dismiss must treat
647
658
  // BOTH the chip and the portaled panel as "inside".
659
+ // The anchor is kept through the exit transition (clearing it at logical
660
+ // close would unmount the panel mid-fade) and re-placed on the next open.
648
661
  const PANEL_WIDTH = 256
649
662
  useLayoutEffect(() => {
650
- if (!open) { setAnchor(null); return }
663
+ if (!open) return
651
664
  const place = () => {
652
665
  const r = rootRef.current?.getBoundingClientRect()
653
666
  if (!r) return
@@ -666,11 +679,13 @@ function KindsMenu({ kindFilter, onKindFilterChange, kindOptions }: KindsMenuPro
666
679
  const top = flipUp
667
680
  ? Math.max(4, r.top - 4 - Math.min(panelH, maxHeight))
668
681
  : r.bottom + 4
669
- setAnchor({ left, top, maxHeight })
682
+ setAnchor({ left, top, maxHeight, flipUp })
670
683
  }
671
684
  place()
672
685
  // Re-place after the panel mounts so the first open can measure its height and
673
- // flip; the initial pass runs with panelH=0 (panel not yet in the DOM).
686
+ // flip; the initial pass runs with panelH=0 (panel not yet in the DOM). The
687
+ // presence hook mounts the panel a tick after `open`, hence `shouldRender` in
688
+ // the deps: the pass that runs once it is in the DOM does the real measurement.
674
689
  const raf = requestAnimationFrame(place)
675
690
  window.addEventListener('scroll', place, true)
676
691
  window.addEventListener('resize', place)
@@ -679,7 +694,7 @@ function KindsMenu({ kindFilter, onKindFilterChange, kindOptions }: KindsMenuPro
679
694
  window.removeEventListener('scroll', place, true)
680
695
  window.removeEventListener('resize', place)
681
696
  }
682
- }, [open])
697
+ }, [open, shouldRender])
683
698
 
684
699
  useEffect(() => {
685
700
  if (!open) return
@@ -731,11 +746,29 @@ function KindsMenu({ kindFilter, onKindFilterChange, kindOptions }: KindsMenuPro
731
746
  <ChevronDown className="w-3.5 h-3.5 text-theme-text-tertiary" />
732
747
  </button>
733
748
 
734
- {open && anchor && createPortal(
749
+ {shouldRender && anchor && createPortal(
735
750
  <div
736
751
  ref={panelRef}
737
- style={{ position: 'fixed', left: anchor.left, top: anchor.top, width: PANEL_WIDTH, maxHeight: anchor.maxHeight }}
738
- className="z-50 overflow-y-auto rounded-md border border-theme-border bg-theme-surface shadow-theme-lg"
752
+ inert={!open}
753
+ style={{
754
+ position: 'fixed',
755
+ left: anchor.left,
756
+ top: anchor.top,
757
+ width: PANEL_WIDTH,
758
+ maxHeight: anchor.maxHeight,
759
+ ...overlayTransitionStyle(isOpen, 'menu'),
760
+ }}
761
+ className={clsx(
762
+ 'z-50 overflow-y-auto rounded-md border border-theme-border bg-theme-surface shadow-theme-lg',
763
+ anchor.flipUp ? 'origin-bottom-left' : 'origin-top-left',
764
+ TRANSITION_MENU,
765
+ isOpen
766
+ ? 'opacity-100 translate-y-0 scale-100'
767
+ : anchor.flipUp
768
+ ? 'opacity-0 translate-y-1 scale-[0.97]'
769
+ : 'opacity-0 -translate-y-1 scale-[0.97]',
770
+ !open && 'pointer-events-none',
771
+ )}
739
772
  >
740
773
  <MultiSelectPicker
741
774
  items={kindOptions}
@@ -3,6 +3,8 @@ import { FolderTree, ShieldCheck, ChevronDown, Check } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import type { TopologyMode, GroupingMode } from '../../types/core'
5
5
  import { Tooltip } from '../ui/Tooltip'
6
+ import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
7
+ import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
6
8
 
7
9
  interface TopologyControlsProps {
8
10
  viewMode: TopologyMode
@@ -37,6 +39,7 @@ export function TopologyControls({
37
39
  leadingSlot,
38
40
  }: TopologyControlsProps) {
39
41
  const [groupOpen, setGroupOpen] = useState(false)
42
+ const { shouldRender: groupRender, isOpen: groupShown } = useAnimatedUnmount(groupOpen, overlayExitMs('menu'))
40
43
  const groupRef = useRef<HTMLDivElement>(null)
41
44
  const groupTriggerRef = useRef<HTMLButtonElement>(null)
42
45
  const groupItemRefs = useRef<(HTMLButtonElement | null)[]>([])
@@ -53,20 +56,25 @@ export function TopologyControls({
53
56
  if (restoreFocus) groupTriggerRef.current?.focus()
54
57
  }, [])
55
58
 
56
- // On open, move focus onto the active option so the menu is keyboard-navigable
57
- // (parity with the native <select> this replaced). Click-outside closes it.
59
+ // On open, move focus onto the active option so the menu is keyboard-navigable.
60
+ // The items exist only once the presence hook has mounted the menu, so this
61
+ // waits for `groupRender`.
58
62
  useEffect(() => {
59
- if (!groupOpen) return
63
+ if (!groupOpen || !groupRender) return
60
64
  const active = Math.max(0, groupOptions.findIndex((o) => o.value === groupingMode))
61
65
  groupItemRefs.current[active]?.focus()
66
+ // groupOptions/groupingMode are read once at open; re-running on their
67
+ // identity change would steal focus mid-interaction.
68
+ // eslint-disable-next-line react-hooks/exhaustive-deps
69
+ }, [groupOpen, groupRender])
70
+
71
+ useEffect(() => {
72
+ if (!groupOpen) return
62
73
  const onDown = (e: MouseEvent) => {
63
74
  if (groupRef.current && !groupRef.current.contains(e.target as Node)) setGroupOpen(false)
64
75
  }
65
76
  document.addEventListener('mousedown', onDown)
66
77
  return () => document.removeEventListener('mousedown', onDown)
67
- // groupOptions/groupingMode are read once at open; re-running on their
68
- // identity change would steal focus mid-interaction.
69
- // eslint-disable-next-line react-hooks/exhaustive-deps
70
78
  }, [groupOpen])
71
79
 
72
80
  const onGroupMenuKey = (e: KeyboardEvent<HTMLDivElement>) => {
@@ -123,8 +131,19 @@ export function TopologyControls({
123
131
  {currentGroupLabel}
124
132
  <ChevronDown className="w-3 h-3 text-theme-text-tertiary" />
125
133
  </button>
126
- {groupOpen && (
127
- <div role="menu" onKeyDown={onGroupMenuKey} className="absolute right-0 top-full mt-1 z-50 min-w-[160px] rounded-lg border border-theme-border bg-theme-surface py-1 shadow-xl">
134
+ {groupRender && (
135
+ <div
136
+ role="menu"
137
+ onKeyDown={onGroupMenuKey}
138
+ inert={!groupOpen}
139
+ className={clsx(
140
+ 'absolute right-0 top-full mt-1 z-50 min-w-[160px] origin-top-right rounded-lg border border-theme-border bg-theme-surface py-1 shadow-xl',
141
+ TRANSITION_MENU,
142
+ groupShown ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]',
143
+ !groupOpen && 'pointer-events-none',
144
+ )}
145
+ style={overlayTransitionStyle(groupShown, 'menu')}
146
+ >
128
147
  {groupOptions.map((o, idx) => (
129
148
  <button
130
149
  key={o.value}
@@ -23,6 +23,7 @@ import { toCanvas } from 'html-to-image'
23
23
 
24
24
  import { AlertTriangle, ChevronsDownUp, ChevronsUpDown, Download, Info, Layers, LayoutGrid, Loader2, Maximize, Minus, Pause, Play, Plus, RotateCw, Shield } from 'lucide-react'
25
25
  import { PaneLoader } from '../ui/PaneLoader'
26
+ import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse'
26
27
  import { TopologyOverlayBar } from './TopologyOverlayBar'
27
28
  import { Input } from '../ui/Input'
28
29
  import { Tooltip } from '../ui/Tooltip'
@@ -315,6 +316,8 @@ export function TopologyGraph({
315
316
  const [fitViewCounter, setFitViewCounter] = useState(0)
316
317
  const [isExporting, setIsExporting] = useState(false)
317
318
  const [showLegend, setShowLegend] = useState(false)
319
+ const [warningsOpen, setWarningsOpen] = useState(false)
320
+ const warningsDisclosure = useDisclosure(warningsOpen)
318
321
  const prevStructureRef = useRef<string>('')
319
322
  const layoutVersionRef = useRef(0) // Used to invalidate stale layout results
320
323
  // Saved node positions for preservation across topology updates.
@@ -1064,10 +1067,16 @@ export function TopologyGraph({
1064
1067
  ? 'Large retained run history is summarized to keep the graph readable.'
1065
1068
  : 'Some resources failed to load. Data may be incomplete.'}
1066
1069
  </span>
1067
- <details className="mt-1">
1068
- <summary className="text-xs text-amber-400/80 hover:text-amber-400">
1069
- Show details ({topology.warnings.length})
1070
- </summary>
1070
+ <button
1071
+ {...warningsDisclosure.buttonProps}
1072
+ type="button"
1073
+ onClick={() => setWarningsOpen((v) => !v)}
1074
+ className="mt-1 flex items-center gap-1 text-xs text-amber-400/80 hover:text-amber-400"
1075
+ >
1076
+ <CollapseChevron open={warningsOpen} inheritColor className="h-3 w-3" />
1077
+ Show details ({topology.warnings.length})
1078
+ </button>
1079
+ <Collapse open={warningsOpen} id={warningsDisclosure.panelId}>
1071
1080
  <ul className="mt-1 text-xs text-theme-text-tertiary space-y-0.5">
1072
1081
  {rbacWarnings.length > 0 && otherWarnings.length > 0 && (
1073
1082
  <li className="text-amber-400/60 font-medium mt-1">RBAC restrictions:</li>
@@ -1088,7 +1097,7 @@ export function TopologyGraph({
1088
1097
  <li key={`other-${i}`} className="font-mono">{w}</li>
1089
1098
  ))}
1090
1099
  </ul>
1091
- </details>
1100
+ </Collapse>
1092
1101
  </div>
1093
1102
  </div>
1094
1103
  </div>
@@ -4,7 +4,8 @@ import { ReachActions, JustTestedNote, CopyableCommand, completedRequestMode, ty
4
4
  import { AlertBanner } from '../ui/drawer-components'
5
5
  import { PaneLoader } from '../ui/PaneLoader'
6
6
  import { ReachabilityGraph, MarkGlyph } from './ReachabilityGraph'
7
- import { Activity, ChevronRight } from 'lucide-react'
7
+ import { Activity } from 'lucide-react'
8
+ import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse'
8
9
  import { Tooltip } from '../ui/Tooltip'
9
10
  import { buildGraph, noteHeadline } from './reachGraphModel'
10
11
  import { buildOrigins, defaultOrigin, probeCheckStats, type Origin, type OriginId } from './reachOrigins'
@@ -778,10 +779,10 @@ function untestableSummary(out: Origin[]): string {
778
779
  // ---------------------------------------------------------------- inspector
779
780
 
780
781
  /**
781
- * The panel's one disclosure, matching drawer-components' Section: a rotating
782
- * ChevronRight and a grid-template-rows transition. Hand-rolled "›"/"⌄" toggles
783
- * neither looked like the rest of Radar nor animated, so a section appearing was
784
- * an abrupt jump with no affordance saying it could be opened at all.
782
+ * The panel's one disclosure, on the shared Collapse primitive like
783
+ * drawer-components' Section. Hand-rolled "›"/"⌄" toggles neither looked like
784
+ * the rest of Radar nor animated, so a section appearing was an abrupt jump
785
+ * with no affordance saying it could be opened at all.
785
786
  */
786
787
  function Disclosure({
787
788
  open,
@@ -796,21 +797,22 @@ function Disclosure({
796
797
  count?: number
797
798
  children: React.ReactNode
798
799
  }) {
800
+ const { panelId, buttonProps } = useDisclosure(open)
799
801
  return (
800
802
  <div>
801
803
  <button
804
+ {...buttonProps}
802
805
  type="button"
803
806
  onClick={onToggle}
804
- aria-expanded={open}
805
807
  className="flex w-full items-center gap-1.5 text-[9.5px] font-bold tracking-[0.07em] text-theme-text-tertiary transition-colors hover:text-theme-text-secondary"
806
808
  >
807
- <ChevronRight className={`h-3 w-3 flex-none transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
809
+ <CollapseChevron open={open} className="h-3 w-3 flex-none" />
808
810
  <span>{label}</span>
809
811
  {count !== undefined && <span className="flex-1 text-right font-normal tracking-normal">{count}</span>}
810
812
  </button>
811
- <div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: open ? '1fr' : '0fr' }}>
812
- <div className="overflow-hidden">{children}</div>
813
- </div>
813
+ <Collapse open={open} id={panelId}>
814
+ {children}
815
+ </Collapse>
814
816
  </div>
815
817
  )
816
818
  }
@@ -981,6 +983,7 @@ function HopSection({
981
983
  }) {
982
984
  const [openOverride, setOpenOverride] = useState<boolean | null>(null)
983
985
  const open = openOverride ?? hop.expanded
986
+ const { panelId, buttonProps } = useDisclosure(open)
984
987
  const ref = useRef<HTMLDivElement>(null)
985
988
  // A click on the graph scrolls its hop into view instead of replacing the
986
989
  // panel - the gesture still means "I care about this one", it just no longer
@@ -1012,15 +1015,15 @@ function HopSection({
1012
1015
  className={`mt-2 rounded-md px-2 py-1 transition-colors duration-200 ${selected ? 'selection selection-ring' : ''}`}
1013
1016
  >
1014
1017
  <button
1018
+ {...buttonProps}
1015
1019
  type="button"
1016
1020
  onClick={() => {
1017
1021
  setOpenOverride(!open)
1018
1022
  onSelect(hop.id)
1019
1023
  }}
1020
- aria-expanded={open}
1021
1024
  className="flex w-full items-center gap-1.5 text-left"
1022
1025
  >
1023
- <ChevronRight className={`h-3 w-3 flex-none text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
1026
+ <CollapseChevron open={open} className="h-3 w-3 flex-none" />
1024
1027
  <span className="text-[9px] font-bold tracking-[0.04em] text-theme-text-tertiary">{hop.kind}</span>
1025
1028
  <span className="min-w-0 flex-1 truncate font-mono text-[11.5px] font-semibold text-theme-text-primary">{hop.name}</span>
1026
1029
  <span className={`badge-sm whitespace-nowrap ${SEV_BADGE[hop.chipTone]}`}>{hop.chipText || HOP_STATE_NOTE[hop.state]}</span>
@@ -1032,8 +1035,7 @@ function HopSection({
1032
1035
  .join(' — ')}
1033
1036
  </div>
1034
1037
  )}
1035
- <div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: open ? '1fr' : '0fr' }}>
1036
- <div className="overflow-hidden">
1038
+ <Collapse open={open} id={panelId}>
1037
1039
  <div className="pb-1 pl-[18px]">
1038
1040
  {hop.openRef?.name && (
1039
1041
  <button type="button" onClick={() => onOpen(hop.openRef!)} className="text-[11px] text-accent-text hover:underline">
@@ -1084,8 +1086,7 @@ function HopSection({
1084
1086
  <Caveats items={hop.notProve} />
1085
1087
  </div>
1086
1088
  </div>
1087
- </div>
1088
- </div>
1089
+ </Collapse>
1089
1090
  </div>
1090
1091
  )
1091
1092
  }
@@ -1,4 +1,4 @@
1
- import { Fragment, type ComponentType, type ReactNode } from 'react'
1
+ import { Fragment, type ComponentType, type ReactNode } from "react";
2
2
 
3
3
  // =============================================================================
4
4
  // CARD SECTION PRIMITIVES — the shared "Sectioned + icons" vocabulary used by
@@ -12,106 +12,150 @@ import { Fragment, type ComponentType, type ReactNode } from 'react'
12
12
 
13
13
  // Section role → eyebrow + icon hue. Body text always uses the secondary token
14
14
  // (the contrast floor), regardless of tone.
15
- export type CardSectionTone = 'neutral' | 'warn' | 'fix'
15
+ export type CardSectionTone = "neutral" | "warn" | "fix";
16
16
 
17
17
  const SECTION_TONE_CLASS: Record<CardSectionTone, string> = {
18
18
  // Eyebrow grey — the default, for neutral sections (Why it matters, Raw error,
19
19
  // Affected resources, Context, Subject).
20
- neutral: 'text-theme-text-tertiary',
20
+ neutral: "text-theme-text-tertiary",
21
21
  // What's wrong — amber, the diagnosis beat.
22
- warn: 'text-amber-700 dark:text-amber-300',
22
+ warn: "text-amber-700 dark:text-amber-300",
23
23
  // Next step / How to fix — emerald, the action beat.
24
- fix: 'text-emerald-700 dark:text-emerald-400',
25
- }
24
+ fix: "text-emerald-700 dark:text-emerald-400",
25
+ };
26
26
 
27
27
  // The single eyebrow style: 11px / 600 / uppercase, with an icon in a fixed
28
28
  // gutter so every section's body aligns under the same left edge.
29
29
  export function CardSection({
30
30
  icon: Icon,
31
31
  label,
32
- tone = 'neutral',
32
+ tone = "neutral",
33
33
  labelExtra,
34
34
  children,
35
35
  }: {
36
- icon: ComponentType<{ className?: string }>
37
- label: string
38
- tone?: CardSectionTone
36
+ icon: ComponentType<{ className?: string }>;
37
+ label: string;
38
+ tone?: CardSectionTone;
39
39
  /** Optional trailing eyebrow text (e.g. "· retried 5×"), rendered muted. */
40
- labelExtra?: ReactNode
41
- children: ReactNode
40
+ labelExtra?: ReactNode;
41
+ children: ReactNode;
42
42
  }) {
43
- const toneClass = SECTION_TONE_CLASS[tone]
43
+ const toneClass = SECTION_TONE_CLASS[tone];
44
44
  return (
45
45
  <section className="flex items-start gap-2.5">
46
46
  {/* Icon sits in a fixed gutter, anchored to the eyebrow line (top-aligned
47
47
  with a nudge to its optical center) — sections like Affected resources
48
48
  can be dozens of rows tall, and a section-centered glyph would float
49
49
  detached mid-list. */}
50
- <Icon className={`mt-[-1px] h-[18px] w-[18px] shrink-0 ${toneClass}`} aria-hidden />
50
+ <Icon
51
+ className={`mt-[-1px] h-[18px] w-[18px] shrink-0 ${toneClass}`}
52
+ aria-hidden
53
+ />
51
54
  <div className="flex min-w-0 flex-1 flex-col gap-1">
52
- <h4 className={`text-[11px] font-semibold uppercase tracking-[0.06em] ${toneClass}`}>
55
+ <h4
56
+ className={`text-[11px] font-semibold uppercase tracking-[0.06em] ${toneClass}`}
57
+ >
53
58
  {label}
54
- {labelExtra ? <span className="font-medium normal-case tracking-normal text-theme-text-tertiary"> {labelExtra}</span> : null}
59
+ {labelExtra ? (
60
+ <span className="font-medium normal-case tracking-normal text-theme-text-tertiary">
61
+ {" "}
62
+ {labelExtra}
63
+ </span>
64
+ ) : null}
55
65
  </h4>
56
66
  {children}
57
67
  </div>
58
68
  </section>
59
- )
69
+ );
60
70
  }
61
71
 
62
72
  // Body paragraph at the contrast floor — 14px, secondary token, never lighter.
63
- export function CardBody({ children, className }: { children: ReactNode; className?: string }) {
64
- return <p className={`text-sm leading-relaxed text-theme-text-secondary ${className ?? ''}`}>{children}</p>
73
+ export function CardBody({
74
+ children,
75
+ className,
76
+ }: {
77
+ children: ReactNode;
78
+ className?: string;
79
+ }) {
80
+ return (
81
+ <p
82
+ className={`text-sm leading-relaxed text-theme-text-secondary ${className ?? ""}`}
83
+ >
84
+ {children}
85
+ </p>
86
+ );
65
87
  }
66
88
 
67
89
  // The dark terminal block for raw error output. Intentionally dark in BOTH
68
90
  // themes (it's a console surface, not chrome), so it draws from the dedicated
69
91
  // --terminal-* tokens rather than the theme surface tokens.
70
- export function TerminalBlock({ label, children }: { label?: ReactNode; children: ReactNode }) {
92
+ export function TerminalBlockLabel({
93
+ children,
94
+ divider,
95
+ }: {
96
+ children: ReactNode;
97
+ divider?: boolean;
98
+ }) {
99
+ return (
100
+ <div
101
+ className={`${divider ? "border-t" : "border-b"} border-[var(--terminal-divider)] px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.06em] text-[var(--terminal-label)]`}
102
+ >
103
+ {children}
104
+ </div>
105
+ );
106
+ }
107
+
108
+ export function TerminalBlock({
109
+ label,
110
+ children,
111
+ footer,
112
+ }: {
113
+ label?: ReactNode;
114
+ children: ReactNode;
115
+ /** Rendered under the output, inside the same block: a folded tail, a divider. */
116
+ footer?: ReactNode;
117
+ }) {
71
118
  return (
72
119
  <div className="overflow-hidden rounded-lg border border-[var(--terminal-border)] bg-[var(--terminal-bg)]">
73
- {label ? (
74
- <div className="border-b border-[var(--terminal-divider)] px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.06em] text-[var(--terminal-label)]">
75
- {label}
76
- </div>
77
- ) : null}
120
+ {label ? <TerminalBlockLabel>{label}</TerminalBlockLabel> : null}
78
121
  <pre className="overflow-x-auto whitespace-pre-wrap break-words px-3 py-2.5 font-mono text-xs leading-relaxed text-[var(--terminal-text)]">
79
122
  {children}
80
123
  </pre>
124
+ {footer}
81
125
  </div>
82
- )
126
+ );
83
127
  }
84
128
 
85
129
  // The neutral category chip — drops the per-category hue so severity is the only
86
130
  // status color. Self-contained (don't pair with badge-sm): 11px / 600, 2px×8px
87
131
  // padding, no border, neutral grey.
88
132
  export const NEUTRAL_CHIP_CLASS =
89
- 'inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-semibold leading-tight bg-theme-elevated text-theme-text-secondary'
133
+ "inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-semibold leading-tight bg-theme-elevated text-theme-text-secondary";
90
134
 
91
135
  // Mono kind chip — the grey tag for an all-caps resource Kind (the Issue row
92
136
  // header + the Subject footer row). Mono and tighter than NEUTRAL_CHIP_CLASS,
93
137
  // which carries the label/category chips.
94
138
  export const KIND_CHIP_CLASS =
95
- 'shrink-0 rounded bg-theme-elevated px-1.5 py-px font-mono text-[11px] uppercase text-theme-text-secondary'
139
+ "shrink-0 rounded bg-theme-elevated px-1.5 py-px font-mono text-[11px] uppercase text-theme-text-secondary";
96
140
 
97
141
  // Render a prose string, turning markdown `inline-code` spans into mono chips
98
142
  // via the shared .inline-code class. Plain-text passthrough when the string
99
143
  // carries no backticks, so it's a no-op for today's plain catalog copy and
100
144
  // lights up automatically once backtick markup is added.
101
145
  export function renderProse(text: string | undefined | null): ReactNode {
102
- if (!text) return null
103
- if (!text.includes('`')) return text
104
- const parts = text.split(/(`[^`]+`)/g)
146
+ if (!text) return null;
147
+ if (!text.includes("`")) return text;
148
+ const parts = text.split(/(`[^`]+`)/g);
105
149
  return parts.map((part, i) => {
106
150
  // length > 2 excludes a bare "``" (which the split leaves in a plain part)
107
151
  // so it renders as literal text, not an empty chip.
108
- if (part.length > 2 && part.startsWith('`') && part.endsWith('`')) {
152
+ if (part.length > 2 && part.startsWith("`") && part.endsWith("`")) {
109
153
  return (
110
154
  <code key={i} className="inline-code">
111
155
  {part.slice(1, -1)}
112
156
  </code>
113
- )
157
+ );
114
158
  }
115
- return <Fragment key={i}>{part}</Fragment>
116
- })
159
+ return <Fragment key={i}>{part}</Fragment>;
160
+ });
117
161
  }