@skyhook-io/k8s-ui 1.18.1 → 1.19.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 (101) hide show
  1. package/README.md +8 -0
  2. package/package.json +1 -1
  3. package/src/components/applications/ApplicationsView.tsx +8 -7
  4. package/src/components/charts/AreaChart.test.tsx +39 -1
  5. package/src/components/charts/AreaChart.tsx +32 -13
  6. package/src/components/charts/axis.test.ts +17 -1
  7. package/src/components/charts/axis.ts +13 -0
  8. package/src/components/charts/format.test.ts +159 -2
  9. package/src/components/charts/format.ts +118 -0
  10. package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
  11. package/src/components/compare/ResourceCompareView.tsx +6 -2
  12. package/src/components/gitops/GitOpsTableView.tsx +13 -8
  13. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
  14. package/src/components/gitops/insights/GitOpsInsightViews.tsx +66 -36
  15. package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
  16. package/src/components/gitops/insights/insights-helpers.test.ts +28 -0
  17. package/src/components/gitops/insights/insights-helpers.ts +5 -3
  18. package/src/components/issues/IssueGuidance.test.tsx +190 -0
  19. package/src/components/issues/IssueGuidance.tsx +48 -0
  20. package/src/components/issues/IssuesView.tsx +17 -5
  21. package/src/components/issues/diagnostic.ts +2 -0
  22. package/src/components/issues/index.ts +1 -0
  23. package/src/components/issues/severity.ts +2 -0
  24. package/src/components/issues/types.ts +10 -0
  25. package/src/components/logs/LogCore.tsx +13 -11
  26. package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
  27. package/src/components/namespace-switcher/NamespacePicker.tsx +2 -0
  28. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
  29. package/src/components/resources/ResourcesSidebar.tsx +26 -5
  30. package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
  31. package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
  32. package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
  33. package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
  34. package/src/components/resources/ResourcesView.tsx +238 -66
  35. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  36. package/src/components/resources/generic-status.ts +15 -0
  37. package/src/components/resources/printer-columns.test.ts +36 -0
  38. package/src/components/resources/printer-columns.ts +12 -1
  39. package/src/components/resources/readiness-gates.test.ts +95 -0
  40. package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
  41. package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
  42. package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
  43. package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
  44. package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
  45. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
  46. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
  47. package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
  48. package/src/components/resources/renderers/index.ts +3 -0
  49. package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
  50. package/src/components/resources/resource-utils-crossplane.ts +44 -8
  51. package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
  52. package/src/components/resources/resource-utils-flagger.ts +76 -0
  53. package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
  54. package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
  55. package/src/components/resources/resource-utils.ts +80 -2
  56. package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
  57. package/src/components/shared/CreateResourceDialog.tsx +5 -0
  58. package/src/components/shared/DetailShell.phone.test.tsx +54 -0
  59. package/src/components/shared/DetailShell.tsx +14 -3
  60. package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
  61. package/src/components/shared/EditableYamlView.test.tsx +58 -0
  62. package/src/components/shared/EditableYamlView.tsx +18 -3
  63. package/src/components/shared/FilterRailPanel.test.tsx +39 -0
  64. package/src/components/shared/FilterRailPanel.tsx +62 -0
  65. package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
  66. package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
  67. package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
  68. package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
  69. package/src/components/shared/ManagedByChip.tsx +135 -2
  70. package/src/components/shared/ResourceActionsBar.tsx +22 -6
  71. package/src/components/shared/ResourceRendererDispatch.tsx +23 -0
  72. package/src/components/shared/SetImageDialog.tsx +3 -0
  73. package/src/components/topology/TopologyControls.tsx +1 -1
  74. package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
  75. package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
  76. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
  77. package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
  78. package/src/components/ui/PageHeader.tsx +3 -2
  79. package/src/components/ui/SummaryTile.tsx +13 -3
  80. package/src/components/ui/Tooltip.touch.test.tsx +55 -0
  81. package/src/components/ui/Tooltip.tsx +11 -2
  82. package/src/components/ui/YamlReview.test.tsx +52 -0
  83. package/src/components/ui/YamlReview.tsx +7 -0
  84. package/src/components/ui/drawer-components.test.tsx +27 -0
  85. package/src/components/ui/drawer-components.tsx +22 -42
  86. package/src/components/workload/WorkloadView.relationships.test.tsx +40 -0
  87. package/src/components/workload/WorkloadView.tsx +57 -34
  88. package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
  89. package/src/hooks/useKeepInViewport.test.ts +27 -0
  90. package/src/hooks/useKeepInViewport.ts +87 -0
  91. package/src/types/core.ts +54 -1
  92. package/src/types/gitops-insights.ts +8 -0
  93. package/src/utils/api-resources.ts +1 -0
  94. package/src/utils/gitops-owner.ts +136 -8
  95. package/src/utils/gitops-write-guard.test.ts +332 -0
  96. package/src/utils/gitops-write-guard.ts +175 -11
  97. package/src/utils/index.ts +1 -0
  98. package/src/utils/read-actions.ts +34 -0
  99. package/src/utils/touch-intent.test.ts +39 -0
  100. package/src/utils/touch-intent.ts +35 -0
  101. package/vitest.config.ts +26 -0
@@ -38,3 +38,55 @@ describe('YamlReview after a failed apply', () => {
38
38
  expect(review()).not.toContain('changed after your review')
39
39
  })
40
40
  })
41
+
42
+
43
+ it('retains pause context when the prediction removes the annotation without claiming confirmed resumption', () => {
44
+ const baselineYaml = 'apiVersion: example.test/v1\nkind: Bucket\nmetadata:\n name: archive\n annotations:\n crossplane.io/paused: "true"\nspec:\n providerConfigRef:\n name: default\n'
45
+ const predictedYaml = baselineYaml.replace(' crossplane.io/paused: "true"', ' crossplane.io/paused: "false"')
46
+ const result = renderToString(<YamlReview submittedYaml={predictedYaml} documents={[{ index: 0, status: 'accepted', action: 'update', baselineYaml, predictedYaml }]} onBack={() => {}} onApply={() => {}} />)
47
+ expect(result).toContain('Pause affects reconciliation')
48
+ expect(result).toContain('requests resumption')
49
+ expect(result).not.toContain('will resume')
50
+ expect(result.replaceAll('<!-- -->', '')).toContain('Server preview clears the pause request on Bucket archive')
51
+ })
52
+
53
+
54
+ it('shows pause context for an unselected later document because Apply submits the whole batch', () => {
55
+ const first = 'apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: ordinary\n'
56
+ const second = 'apiVersion: example.test/v1\nkind: Bucket\nmetadata:\n name: later-paused\n annotations:\n crossplane.io/paused: "true"\nspec:\n providerConfigRef:\n name: default\n'
57
+ const result = renderToString(<YamlReview submittedYaml={first + '\n---\n' + second} documents={[{ index: 0, status: 'accepted', predictedYaml: first }, { index: 1, status: 'accepted', predictedYaml: second }]} onBack={() => {}} onApply={() => {}} />)
58
+ expect(result).toContain('Pause affects reconciliation')
59
+ expect(result).toContain('Bucket later-paused')
60
+ })
61
+
62
+
63
+ it('states the actual retained pause in the prediction without inferring who preserved it', () => {
64
+ const baselineYaml = 'apiVersion: example.test/v1\nkind: Bucket\nmetadata:\n name: archive\n annotations:\n crossplane.io/paused: "true"\nspec:\n providerConfigRef:\n name: default\n'
65
+ const submittedYaml = baselineYaml.replace(' crossplane.io/paused: "true"', '')
66
+ const result = renderToString(<YamlReview submittedYaml={submittedYaml} documents={[{ index: 0, status: 'accepted', action: 'update', baselineYaml, predictedYaml: baselineYaml }]} onBack={() => {}} onApply={() => {}} />)
67
+ expect(result.replaceAll('<!-- -->', '')).toContain('Server preview keeps the pause request on Bucket archive')
68
+ expect(result).not.toContain('another field manager owns')
69
+ expect(result).not.toContain('Server preview clears')
70
+ })
71
+
72
+ it.each(['apply', 'create'] as const)('handles a not-previewable Crossplane document in %s mode without pretending submitted YAML is live', mode => {
73
+ const submittedYaml = 'apiVersion: example.test/v1\nkind: Bucket\nmetadata:\n name: archive\nspec:\n providerConfigRef:\n name: default\n'
74
+ const result = renderToString(<YamlReview submittedYaml={submittedYaml} mode={mode} documents={[{ index: 0, status: 'unavailable', submittedYaml }]} onBack={() => {}} onApply={() => {}} />)
75
+ expect(result.includes('Existing pause state is unknown')).toBe(mode === 'apply')
76
+ expect(result.includes('Omitting the annotation from server-side apply YAML does not guarantee its removal')).toBe(mode === 'apply')
77
+ expect(result).not.toContain('Server preview clears')
78
+ })
79
+
80
+
81
+ it.each(['create', 'unavailable'] as const)('keeps per-document pause evidence paired when the first document has no baseline (%s)', firstState => {
82
+ const firstYaml = 'apiVersion: example.test/v1\nkind: Bucket\nmetadata:\n name: fresh\nspec:\n providerConfigRef:\n name: default\n'
83
+ const pausedYaml = firstYaml.replace('name: fresh', 'name: archive\n annotations:\n crossplane.io/paused: "true"')
84
+ const first = firstState === 'create'
85
+ ? { index: 0, status: 'accepted' as const, action: 'create' as const, name: 'fresh', predictedYaml: firstYaml }
86
+ : { index: 0, status: 'unavailable' as const, action: 'unknown' as const, name: 'fresh', submittedYaml: firstYaml }
87
+ const result = renderToString(<YamlReview submittedYaml={firstYaml + '\n---\n' + pausedYaml} documents={[first, { index: 1, status: 'accepted', action: 'update', name: 'archive', baselineYaml: pausedYaml, predictedYaml: pausedYaml }]} onBack={() => {}} onApply={() => {}} />).replaceAll('<!-- -->', '')
88
+ expect(result).toContain('Server preview keeps the pause request on Bucket archive')
89
+ expect(result).not.toContain('Server preview clears the pause request')
90
+ expect(result).not.toContain('Server preview requests a pause on Bucket archive')
91
+ expect(result.includes('Live pause state is unverified for Bucket fresh')).toBe(firstState === 'unavailable')
92
+ })
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { AlertTriangle, ArrowLeft, Check, Loader2, ShieldAlert, ShieldCheck, X } from 'lucide-react'
3
3
  import { normalizeYamlForReview, splitYamlDocuments } from '../../utils/yaml'
4
+ import { CrossplaneYamlApplyNotice } from './CrossplaneOperationNotice'
4
5
  import { Badge } from './Badge'
5
6
  import { StatusDot } from './status-tone'
6
7
  import { YamlDiffEditor } from './YamlEditor'
@@ -25,6 +26,9 @@ export interface YamlPreviewResult {
25
26
 
26
27
  export interface YamlReviewProps {
27
28
  submittedYaml: string
29
+ mode?: 'apply' | 'create' | 'update'
30
+ /** Review YAML omits status; raw evidence is bound to one editor target. */
31
+ resourceData?: unknown
28
32
  originalYaml?: string
29
33
  documents: YamlPreviewResult[]
30
34
  nonAtomic?: boolean
@@ -85,6 +89,8 @@ export function reviewedResourceVersionsForPreview(documents: YamlPreviewResult[
85
89
 
86
90
  export function YamlReview({
87
91
  submittedYaml,
92
+ mode = 'apply',
93
+ resourceData,
88
94
  originalYaml,
89
95
  documents,
90
96
  nonAtomic = false,
@@ -363,6 +369,7 @@ export function YamlReview({
363
369
  </div>
364
370
 
365
371
  <div className="shrink-0 border-t border-theme-border bg-theme-elevated/80 px-4 py-3">
372
+ <CrossplaneYamlApplyNotice yaml={submittedYaml} baselineYaml={originalYaml} previewDocuments={documents} mode={mode} resourceData={resourceData} />
366
373
  {changedSinceReview && (
367
374
  <AlertBanner
368
375
  variant="warning"
@@ -218,6 +218,33 @@ describe('RelatedResourcesSection ownership', () => {
218
218
  expect(html).toContain("Its destination isn&#x27;t this cluster as far as Radar can tell, so its members aren&#x27;t listed.")
219
219
  })
220
220
 
221
+ describe('titles managers the way the chip does', () => {
222
+ const other = { ...app, name: 'other' }
223
+ const title = (relationships: Relationships) => {
224
+ const html = renderToString(<RelatedResourcesSection relationships={relationships} onNavigate={() => {}} />)
225
+ return html.includes('Listed By') ? 'Listed By' : html.includes('Managed By') ? 'Managed By' : 'none'
226
+ }
227
+ const answer = (state: 'listed' | 'shared' | 'disagree' | 'agree', recorders = [app]) => ({ state, direct: { object: rs, recorders } })
228
+ it.each(['listed', 'shared', 'disagree'] as const)('a %s answer lists its recorders', state => {
229
+ expect(title({ managers: [app], ownership: { children: { complete: true }, manager: answer(state) } })).toBe('Listed By')
230
+ })
231
+ it('an agree answer manages', () => {
232
+ expect(title({ managers: [app], ownership: { children: { complete: true }, manager: answer('agree') } })).toBe('Managed By')
233
+ })
234
+ it('a manager that is not a direct recorder keeps Managed By', () => {
235
+ expect(title({ managers: [app, other], ownership: { children: { complete: true }, manager: answer('listed') } })).toBe('Managed By')
236
+ })
237
+ it('an inherited answer keeps Managed By', () => {
238
+ expect(title({ managers: [app], ownership: { children: { complete: true }, manager: { ...answer('listed'), via: { object: cm, recorders: [app] } } } })).toBe('Managed By')
239
+ })
240
+ it('a note without managers keeps Managed By', () => {
241
+ expect(title({ ownership: { children: { complete: true }, manager: answer('listed', []), managers: { remote: 1 } } })).toBe('Managed By')
242
+ })
243
+ it('an older Radar without an answer keeps Managed By', () => {
244
+ expect(title({ managers: [app] })).toBe('Managed By')
245
+ })
246
+ })
247
+
221
248
  it('says which direction Managed By comes from', () => {
222
249
  const html = renderToString(<RelatedResourcesSection relationships={{ managers: [app] }} onNavigate={() => {}} />)
223
250
  expect(html).toContain('Argo CD Applications or Flux objects whose status lists this resource, and other controllers that manage it without an owner reference.')
@@ -1,4 +1,5 @@
1
- import { dedupeResourceRefs, omitResourceRefs } from '../../utils/resource-refs'
1
+ import { dedupeResourceRefs, omitResourceRefs, resourceRefKey } from '../../utils/resource-refs'
2
+ import { gapReasonText } from '../../utils/read-actions'
2
3
  import { customResourceDefinitionRef, getCRDDefinitionsSnapshot, subscribeCRDDefinitions } from '../../utils/navigation'
3
4
  import { Disclosure } from './Disclosure'
4
5
  import { createContext, useContext, useState, useSyncExternalStore } from 'react'
@@ -295,9 +296,9 @@ function isConditionHealthy(cond: { type?: string; status?: string }): boolean {
295
296
  return inverted ? cond.status === 'False' : cond.status === 'True'
296
297
  }
297
298
 
298
- export type ConditionTone = 'ok' | 'warning' | 'fail' | 'unknown'
299
+ export type ConditionTone = 'ok' | 'warning' | 'fail' | 'unknown' | 'neutral'
299
300
 
300
- export function defaultConditionTone(cond: { type?: string; status?: string }): ConditionTone {
301
+ export function defaultConditionTone(cond: { type?: string; status?: string }): Exclude<ConditionTone, 'neutral'> {
301
302
  if (cond.status !== 'True' && cond.status !== 'False') return 'unknown'
302
303
  return isConditionHealthy(cond) ? 'ok' : 'fail'
303
304
  }
@@ -342,6 +343,7 @@ export function ConditionsSection({
342
343
  const isOk = tone === 'ok'
343
344
  const isWarning = tone === 'warning'
344
345
  const isFail = tone === 'fail'
346
+ const isNeutral = tone === 'neutral'
345
347
  return (
346
348
  <div key={cond.type} className={clsx(
347
349
  'flex items-start py-1.5 pr-1 text-sm relative',
@@ -355,12 +357,14 @@ export function ConditionsSection({
355
357
  {/* Timeline dot */}
356
358
  <span className={clsx(
357
359
  'w-3 h-3 rounded-full flex items-center justify-center shrink-0 mt-1 z-10 ring-2 ring-theme-surface',
358
- isOk ? 'bg-emerald-500/20 text-emerald-500 dark:bg-emerald-500/30'
360
+ isNeutral ? 'bg-theme-elevated text-theme-text-tertiary'
361
+ : isOk ? 'bg-emerald-500/20 text-emerald-500 dark:bg-emerald-500/30'
359
362
  : tone === 'unknown' ? 'bg-gray-400/20 text-gray-400 dark:bg-gray-400/30'
360
363
  : isWarning ? 'bg-amber-500/25 text-amber-600 dark:text-amber-400 dark:bg-amber-500/35'
361
364
  : 'bg-red-500/25 text-red-500 dark:bg-red-500/35'
362
365
  )}>
363
- {isOk ? <Check className="w-2 h-2" strokeWidth={4} />
366
+ {isNeutral ? <Minus className="w-2 h-2" strokeWidth={4} />
367
+ : isOk ? <Check className="w-2 h-2" strokeWidth={4} />
364
368
  : tone === 'unknown' ? <Minus className="w-2 h-2" strokeWidth={4} />
365
369
  : isWarning ? <AlertTriangle className="w-2 h-2" strokeWidth={4} />
366
370
  : <X className="w-2 h-2" strokeWidth={4} />}
@@ -369,7 +373,8 @@ export function ConditionsSection({
369
373
  <div className="min-w-0 flex-1 pl-2">
370
374
  <span className={clsx(
371
375
  'font-medium text-[13px]',
372
- isOk ? 'text-theme-text-primary'
376
+ isNeutral ? 'text-theme-text-secondary'
377
+ : isOk ? 'text-theme-text-primary'
373
378
  : tone === 'unknown' ? 'text-theme-text-secondary'
374
379
  : isWarning ? 'text-amber-600 dark:text-amber-400'
375
380
  : 'text-red-600 dark:text-red-400'
@@ -415,7 +420,7 @@ interface AlertBannerProps {
415
420
  icon?: React.ComponentType<{ className?: string }>
416
421
  title: string
417
422
  message?: React.ReactNode
418
- items?: string[]
423
+ items?: React.ReactNode[]
419
424
  children?: React.ReactNode
420
425
  /** A control at the end of the title line, e.g. a link to where the problem is handled. */
421
426
  action?: React.ReactNode
@@ -815,7 +820,7 @@ export type CopyHandler = (text: string, key: string) => void
815
820
  // RELATED RESOURCES SECTION
816
821
  // ============================================================================
817
822
 
818
- import type { TimelineEvent, Relationships, ResourceRef, OwnershipCoverage } from '../../types'
823
+ import type { TimelineEvent, Relationships, ResourceRef, OwnershipCoverage, ManagerAnswer } from '../../types'
819
824
  import { isChangeEvent, isK8sEvent } from '../../types'
820
825
  import { Info, Link } from 'lucide-react'
821
826
  import { formatRelativeAgeTime } from '../../utils/format'
@@ -910,7 +915,7 @@ export function RelatedResourcesSection({ relationships, onNavigate, kind, track
910
915
  />
911
916
  )}
912
917
  {(managers.length > 0 || managedByNote.text) && (
913
- <RelationshipGroup label="Managed By" refs={managers} onNavigate={onNavigate} note={managedByNote} />
918
+ <RelationshipGroup label={managersListOnly(relationships.ownership?.manager, managers) ? 'Listed By' : 'Managed By'} refs={managers} onNavigate={onNavigate} note={managedByNote} />
914
919
  )}
915
920
  {relationships.deployment && (
916
921
  <RelationshipGroup label="Deployment" refs={[relationships.deployment]} onNavigate={onNavigate} />
@@ -1073,39 +1078,6 @@ function joinOr(items: string[]): string {
1073
1078
  return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} or ${items[items.length - 1]}`
1074
1079
  }
1075
1080
 
1076
- function gapReasonText(reason: string): string {
1077
- switch (reason) {
1078
- case 'radar_kind_not_collected':
1079
- case 'radar_namespace_not_collected':
1080
- return "Radar doesn't collect it"
1081
- case 'kind_sync_pending':
1082
- return 'still loading'
1083
- case 'kind_sync_timed_out':
1084
- return 'loading is overdue'
1085
- case 'radar_forbidden':
1086
- case 'radar_credentials_rejected':
1087
- return 'Radar may not list it'
1088
- case 'kind_not_watchable':
1089
- return "its API can't be watched"
1090
- case 'user_permission_check_failed':
1091
- return "your access couldn't be checked"
1092
- case 'user_forbidden':
1093
- return 'you may not list it'
1094
- case 'kind_unknown':
1095
- return "Radar's API discovery doesn't list its kind or version"
1096
- case 'object_not_found':
1097
- return "Radar doesn't have it yet"
1098
- case 'cluster_connecting':
1099
- return 'Radar is still connecting'
1100
- case 'cluster_disconnected':
1101
- return 'Radar lost its connection'
1102
- case 'connection_changed':
1103
- return "Radar's connection changed meanwhile"
1104
- default:
1105
- return 'the read failed'
1106
- }
1107
- }
1108
-
1109
1081
  // membersNote says what a GitOps manager's status records beyond the members
1110
1082
  // Manages lists; listed says whether it lists any.
1111
1083
  function membersNote(manages: OwnershipCoverage['manages'] | undefined, listed: boolean): RelationshipNote | undefined {
@@ -1180,6 +1152,14 @@ const MANAGED_BY_HINT = [
1180
1152
  "Kubernetes' garbage collector follows owner references, not these links.",
1181
1153
  ]
1182
1154
 
1155
+ // managersListOnly: every manager shown is one whose list names the resource
1156
+ // while its metadata doesn't confirm it, which the chip calls Listed by.
1157
+ function managersListOnly(answer: ManagerAnswer | undefined, managers: ResourceRef[]): boolean {
1158
+ if (!answer || answer.via || !['listed', 'shared', 'disagree'].includes(answer.state) || !managers.length) return false
1159
+ const recorders = new Set((answer.direct.recorders ?? []).map(resourceRefKey))
1160
+ return managers.every(m => recorders.has(resourceRefKey(m)))
1161
+ }
1162
+
1183
1163
  // managersNote explains Managed By: where it comes from, more than one
1184
1164
  // manager, and the managers the search couldn't settle. With none listed, a
1185
1165
  // remote list naming the resource or a manager kind Radar couldn't search is
@@ -68,3 +68,43 @@ describe('workload GitOps manager', () => {
68
68
  expect(html).toContain("Its tracking label names Application shop, whose status doesn&#x27;t list it.")
69
69
  })
70
70
  })
71
+
72
+ describe('manager answer group projection', () => {
73
+ const app = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'shop' }
74
+ const object = { kind: 'Deployment', group: 'apps', namespace: 'demo', name: 'web' }
75
+ const answer: import('../../types/core').ManagerAnswer = { state: 'listed', lead: app, direct: { object, recorders: [app] } }
76
+ const render = (manager: typeof answer, extra: Partial<import('../../types/core').Relationships> = {}) =>
77
+ renderToStaticMarkup(<WorkloadView kind="deployments" namespace="demo" name="web" onBack={() => {}} resource={workload} expanded={false}
78
+ relationships={{ managers: [app], ownership: { children: { complete: true }, manager }, ...extra }} />)
79
+ // The group is titled the way the chip words the answer.
80
+ const group = (html: string) => html.includes('Listed By') ? 'Listed By' : html.includes('Managed By') ? 'Managed By' : null
81
+
82
+ it.each(['agree', 'listed'] as const)('hides only the exact single %s recorder', state => {
83
+ const html = render({ ...answer, state })
84
+ expect(html).toContain(state === 'agree' ? 'Managed by' : 'Listed by')
85
+ expect(group(html)).toBeNull()
86
+ })
87
+ it.each(['claimed', 'unknown', 'none'] as const)('keeps Managed By for %s', state => {
88
+ expect(group(render({ ...answer, state }))).toBe('Managed By')
89
+ })
90
+ it.each(['disagree', 'shared'] as const)('keeps the group for %s, titled Listed By', state => {
91
+ expect(group(render({ ...answer, state }))).toBe('Listed By')
92
+ })
93
+ it('keeps Managed By with via', () => {
94
+ expect(render({ ...answer, via: { object, recorders: [app] } })).toContain('Managed By')
95
+ })
96
+ it('keeps extra non-recorded managers', () => {
97
+ expect(render(answer, { managers: [app, { kind: 'Certificate', group: 'cert-manager.io', namespace: 'demo', name: 'tls' }] })).toContain('Managed By')
98
+ })
99
+ it('keeps a different single manager', () => {
100
+ expect(render(answer, { managers: [{ ...app, name: 'other' }] })).toContain('Managed By')
101
+ })
102
+ it('keeps the coverage note', () => {
103
+ expect(group(render(answer, { ownership: { children: { complete: true }, manager: answer, managers: { unreadable: 1 } } }))).toBe('Listed By')
104
+ })
105
+ it('uses native Helm’s existing chip', () => {
106
+ const helm = { kind: 'HelmRelease', group: '', namespace: 'demo', name: 'chart' }
107
+ const html = render({ state: 'claimed', lead: helm, direct: { object, claims: [{ manager: helm, source: 'helm-release', check: 'not-recording' }] } })
108
+ expect(html).toContain('Managed by Helm')
109
+ })
110
+ })
@@ -43,7 +43,7 @@ import type { NavigateToResource } from '../../utils/navigation'
43
43
  import { refToSelectedResource, pluralToKind, knownKindForPluralWithGroup, kindToPlural, kindToPluralWithGroup, apiVersionToGroup } from '../../utils/navigation'
44
44
  import { neighborhoodFor, seedNodeIds, topologyNodeResourceKind } from '../../utils/topology-neighborhood'
45
45
  import { TopologyGraph } from '../topology/TopologyGraph'
46
- import { gitOpsOwnerFromRelationships, managerListing, type GitOpsOwnerRef } from '../../utils/gitops-owner'
46
+ import { gitOpsOwnerFromRelationships, managerChipCoversGroup, managerChipPresentation, managerListing, type GitOpsOwnerRef } from '../../utils/gitops-owner'
47
47
  import { gitOpsRouteForResource } from '../../utils/gitops-route'
48
48
  import { buildResourceHierarchy, getAllEventsFromHierarchy, isProblematicEvent, type ResourceLane } from '../../utils/resource-hierarchy'
49
49
  import { TimelineSwimlanes, type TimeWindow } from '../timeline/TimelineSwimlanes'
@@ -62,6 +62,8 @@ import { AlertBanner, getKindColorOutline, displayKindName, OperationalIssuesSho
62
62
  import { Badge, type BadgeSeverity } from '../ui/Badge'
63
63
  import { Tooltip } from '../ui/Tooltip'
64
64
  import { midTruncate } from '../../utils/format'
65
+ import { isManagedResource, isComposite, getCrossplanePauseState } from '../resources/resource-utils-crossplane'
66
+ import { CrossplanePauseChip } from '../resources/renderers/CrossplanePauseContext'
65
67
  import { hpaStateLabel } from '../resources/resource-utils-hpa'
66
68
  import {
67
69
  cronToHuman,
@@ -540,7 +542,7 @@ export function WorkloadView({
540
542
  gitOpsOwnerPending = false,
541
543
  gitOpsOwnerSource,
542
544
  gitOpsOwnerStatus,
543
- helmOwner,
545
+ helmOwner: resolvedHelmOwner,
544
546
  helmOwnerSource,
545
547
  onOpenHelmRelease,
546
548
  onNavigateGitOpsPath,
@@ -725,7 +727,12 @@ export function WorkloadView({
725
727
  // Metadata
726
728
  const metadata = useMemo(() => extractMetadata(kind, resource), [kind, resource])
727
729
  const relationshipGitOpsOwner = useMemo(() => gitOpsOwnerFromRelationships(relationships), [relationships])
728
- const gitopsOwner = resolvedGitOpsOwner ?? relationshipGitOpsOwner
730
+ const manager = relationships?.ownership?.manager
731
+ const gitopsOwner = manager ? relationshipGitOpsOwner : resolvedGitOpsOwner ?? relationshipGitOpsOwner
732
+ const helmOwner = manager
733
+ ? manager.lead?.kind === 'HelmRelease' && !manager.lead.group ? { namespace: manager.lead.namespace, name: manager.lead.name } : null
734
+ : resolvedHelmOwner
735
+ const showGitOpsChip = manager ? !helmOwner && Boolean(managerChipPresentation(manager)) : Boolean(gitopsOwner)
729
736
  const listing = useMemo(
730
737
  () => managerListing(gitopsOwner, relationships?.managers, gitOpsOwnerVerified, !relationships?.ownership?.managers),
731
738
  [gitopsOwner, relationships?.managers, relationships?.ownership?.managers, gitOpsOwnerVerified],
@@ -733,11 +740,11 @@ export function WorkloadView({
733
740
  // When the chip's manager is the only one whose status lists the resource,
734
741
  // the chip says it all: Related Resources would repeat it.
735
742
  const rendererRelationships = useMemo(
736
- () => (relationships && listing?.state === 'confirmed' ? { ...relationships, managers: undefined } : relationships),
737
- [relationships, listing],
743
+ () => (relationships && (manager ? managerChipCoversGroup(relationships) : listing?.state === 'confirmed') ? { ...relationships, managers: undefined } : relationships),
744
+ [relationships, manager, listing],
738
745
  )
739
746
  // When they disagree, Related Resources says which manager the label names.
740
- const trackedBy = listing?.state === 'conflict' && gitopsOwner ? `${gitOpsOwnerKindLabel(gitopsOwner)} ${gitopsOwner.namespace ? `${gitopsOwner.namespace}/` : ''}${gitopsOwner.name}` : undefined
747
+ const trackedBy = !manager && listing?.state === 'conflict' && gitopsOwner ? `${gitOpsOwnerKindLabel(gitopsOwner)} ${gitopsOwner.namespace ? `${gitopsOwner.namespace}/` : ''}${gitopsOwner.name}` : undefined
741
748
  // When the resource itself is a portal GitOps CR (Application, Kustomization,
742
749
  // HelmRelease, etc.), surface a link to its dedicated GitOps detail page —
743
750
  // the drawer's renderer is thorough but the tab has the tree + insights +
@@ -835,6 +842,7 @@ export function WorkloadView({
835
842
  ? getWorkloadDisplayStatus(resource, apiKind, workloadPods)
836
843
  : null
837
844
  const rolloutActivity = rolloutDisplay?.activity ?? null
845
+ const crossplaneResource = isManagedResource(resource) || isComposite(resource) || getCrossplanePauseState(resource) === 'acknowledged'
838
846
  // A 404 with a cached copy: the object was deleted while this view was
839
847
  // open. Everything below that copy describes the past.
840
848
  const resourceGone = Boolean(resource) && isFetchError(resourceError) && resourceError.status === 404
@@ -922,7 +930,7 @@ export function WorkloadView({
922
930
  icon: <Activity className="w-4 h-4" />,
923
931
  badge: resourceEvents.length > 0 ? <span className="ml-1 badge-sm bg-theme-elevated">{resourceEvents.length}</span> : undefined,
924
932
  },
925
- { id: 'logs', label: 'Logs', icon: <Terminal className="w-4 h-4" />, hidden: !logsTabVisible },
933
+ { id: 'logs', label: 'Logs', icon: <Terminal className="w-4 h-4" />, hidden: !logsTabVisible, phoneCompactHeader: true },
926
934
  { id: 'metrics', label: 'Metrics', icon: <BarChart3 className="w-4 h-4" />, hidden: !metricsTabVisible },
927
935
  {
928
936
  id: 'reachability',
@@ -975,6 +983,7 @@ export function WorkloadView({
975
983
  {status.text}
976
984
  </span>
977
985
  )}
986
+ {!resourceGone && crossplaneResource && <CrossplanePauseChip state={getCrossplanePauseState(resource)} />}
978
987
  </div>
979
988
  <div className="flex shrink-0 flex-nowrap items-center justify-end gap-1.5">
980
989
  {diagnoseAction}
@@ -1035,10 +1044,10 @@ export function WorkloadView({
1035
1044
  </Tooltip>
1036
1045
  </div>
1037
1046
  <p className="text-sm text-theme-text-tertiary">{namespace}</p>
1038
- {(gitopsOwner || helmOwner || (gitOpsResourcePath && onNavigateGitOpsPath) || onOpenLiveTraffic) && (
1047
+ {(showGitOpsChip || helmOwner || (gitOpsResourcePath && onNavigateGitOpsPath) || onOpenLiveTraffic) && (
1039
1048
  <div className="mt-1 flex flex-wrap items-center gap-1.5">
1040
- {gitopsOwner && <ManagedByChip owner={gitopsOwner} status={gitOpsOwnerStatus} verified={gitOpsOwnerVerified} pending={gitOpsOwnerPending} source={gitOpsOwnerSource} listing={listing} onOpen={onOpenGitOpsResource} />}
1041
- {helmOwner && <HelmManagedByChip owner={helmOwner} source={helmOwnerSource} onOpen={onOpenHelmRelease} />}
1049
+ {showGitOpsChip && <ManagedByChip manager={manager} owner={gitopsOwner} status={gitOpsOwnerStatus} verified={gitOpsOwnerVerified} pending={gitOpsOwnerPending} source={gitOpsOwnerSource} listing={listing} onOpen={onOpenGitOpsResource} />}
1050
+ {helmOwner && <HelmManagedByChip manager={manager} owner={helmOwner} source={helmOwnerSource} onOpen={onOpenHelmRelease} />}
1042
1051
  {gitOpsResourcePath && onNavigateGitOpsPath && (
1043
1052
  <OpenInGitOpsChip onClick={() => onNavigateGitOpsPath(gitOpsResourcePath)} />
1044
1053
  )}
@@ -1155,10 +1164,35 @@ export function WorkloadView({
1155
1164
  }
1156
1165
 
1157
1166
  // ── Expanded (full) mode ─────────────────────────────────────────────────
1167
+ const nameAndCopy = (
1168
+ <>
1169
+ <h1 className="text-lg font-semibold text-theme-text-primary truncate">{name}</h1>
1170
+ <Tooltip content="Copy name" delay={150}>
1171
+ <button
1172
+ onClick={() => copyToClipboard(name, 'name')}
1173
+ className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0"
1174
+ aria-label="Copy name"
1175
+ >
1176
+ {copied === 'name' ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
1177
+ </button>
1178
+ </Tooltip>
1179
+ </>
1180
+ )
1158
1181
  return (
1159
1182
  <OperationalIssuesShownContext.Provider value={!!hasOperationalIssues || !!operationalIssuesPending}>
1160
1183
  <DetailShell
1161
1184
  breadcrumb={breadcrumb}
1185
+ compactIdentity={
1186
+ // The namespace stays: Restart and the other actions sit right below.
1187
+ <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
1188
+ {titlePrefix}
1189
+ <div className="flex min-w-0 max-w-full items-center gap-2">{nameAndCopy}</div>
1190
+ {namespace && namespace !== '_' && (
1191
+ <span className="whitespace-nowrap text-sm text-theme-text-secondary">Namespace: <span className="text-theme-text-primary">{namespace}</span></span>
1192
+ )}
1193
+ {namespace && namespace !== '_' && namespaceNote}
1194
+ </div>
1195
+ }
1162
1196
  wrapHeader={inlineBadges}
1163
1197
  subheader={resourceGone ? undefined : subheader}
1164
1198
  nav={
@@ -1175,20 +1209,6 @@ export function WorkloadView({
1175
1209
  )
1176
1210
  }
1177
1211
  identity={(() => {
1178
- const nameAndCopy = (
1179
- <>
1180
- <h1 className="text-lg font-semibold text-theme-text-primary truncate">{name}</h1>
1181
- <Tooltip content="Copy name" delay={150}>
1182
- <button
1183
- onClick={() => copyToClipboard(name, 'name')}
1184
- className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0"
1185
- aria-label="Copy name"
1186
- >
1187
- {copied === 'name' ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
1188
- </button>
1189
- </Tooltip>
1190
- </>
1191
- )
1192
1212
  const kindAndStatus = (
1193
1213
  <>
1194
1214
  {!hideKindBadge && (
@@ -1201,6 +1221,7 @@ export function WorkloadView({
1201
1221
  {status.text}
1202
1222
  </span>
1203
1223
  )}
1224
+ {!resourceGone && crossplaneResource && <CrossplanePauseChip state={getCrossplanePauseState(resource)} />}
1204
1225
  {!resourceGone && statusNote}
1205
1226
  </>
1206
1227
  )
@@ -1222,11 +1243,11 @@ export function WorkloadView({
1222
1243
  </span>
1223
1244
  </Tooltip>
1224
1245
  ),
1225
- gitopsOwner && (
1226
- <ManagedByChip key="gitops" owner={gitopsOwner} status={gitOpsOwnerStatus} verified={gitOpsOwnerVerified} pending={gitOpsOwnerPending} source={gitOpsOwnerSource} listing={listing} onOpen={onOpenGitOpsResource} variant="block" />
1246
+ showGitOpsChip && (
1247
+ <ManagedByChip key="gitops" manager={manager} owner={gitopsOwner} status={gitOpsOwnerStatus} verified={gitOpsOwnerVerified} pending={gitOpsOwnerPending} source={gitOpsOwnerSource} listing={listing} onOpen={onOpenGitOpsResource} variant="block" />
1227
1248
  ),
1228
1249
  helmOwner && (
1229
- <HelmManagedByChip key="helm" owner={helmOwner} source={helmOwnerSource} onOpen={onOpenHelmRelease} variant="block" />
1250
+ <HelmManagedByChip key="helm" manager={manager} owner={helmOwner} source={helmOwnerSource} onOpen={onOpenHelmRelease} variant="block" />
1230
1251
  ),
1231
1252
  gitOpsResourcePath && onNavigateGitOpsPath && (
1232
1253
  <OpenInGitOpsChip key="open-gitops" onClick={() => onNavigateGitOpsPath(gitOpsResourcePath)} />
@@ -1447,10 +1468,12 @@ export function WorkloadView({
1447
1468
  </div>
1448
1469
  )}
1449
1470
  {effectiveTab === 'yaml' && (
1450
- <div className="flex h-full min-h-0">
1471
+ // Phones put the objects in a scrolling row above the YAML, which
1472
+ // gets the full width.
1473
+ <div className="flex h-full min-h-0 phone:flex-col">
1451
1474
  {renderRelatedYaml && yamlObjects.length > 1 && (
1452
- <div className="flex w-56 shrink-0 flex-col gap-0.5 overflow-y-auto border-r border-theme-border bg-theme-base px-2 py-2">
1453
- <div className="px-1.5 pb-1 pt-0.5 text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">Objects</div>
1475
+ <div className="flex w-56 shrink-0 flex-col gap-0.5 overflow-y-auto border-r border-theme-border bg-theme-base px-2 py-2 phone:w-full phone:flex-row phone:gap-1 phone:overflow-x-auto phone:overflow-y-hidden phone:border-b phone:border-r-0">
1476
+ <div className="px-1.5 pb-1 pt-0.5 text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary phone:hidden">Objects</div>
1454
1477
  {yamlObjects.map((o) => {
1455
1478
  const active = o.primary ? yamlObjectId === null : yamlObjectId === o.id
1456
1479
  return (
@@ -1459,7 +1482,7 @@ export function WorkloadView({
1459
1482
  type="button"
1460
1483
  onClick={() => setYamlObjectId(o.primary ? null : o.id)}
1461
1484
  className={clsx(
1462
- 'flex w-full flex-col rounded-md px-1.5 py-1.5 text-left transition-colors',
1485
+ 'flex w-full flex-col rounded-md px-1.5 py-1.5 text-left transition-colors phone:w-auto phone:max-w-[11rem] phone:shrink-0',
1463
1486
  active ? 'selection selection-ring' : 'hover:bg-theme-hover',
1464
1487
  )}
1465
1488
  >
@@ -2300,7 +2323,7 @@ function WorkloadOverviewTab({
2300
2323
 
2301
2324
  <div className="grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_360px] 2xl:grid-cols-[minmax(0,1fr)_400px]">
2302
2325
  <div className="space-y-4">
2303
- <div className="grid items-start gap-4 2xl:grid-cols-[minmax(0,1fr)_minmax(320px,420px)]">
2326
+ <div className="grid items-start gap-4 phone:grid-cols-[minmax(0,1fr)] 2xl:grid-cols-[minmax(0,1fr)_minmax(320px,420px)]">
2304
2327
  <div className="space-y-4">
2305
2328
  {showServingPath && (
2306
2329
  <ServingConfigurationCard
@@ -2393,7 +2416,7 @@ function WorkloadOverviewTab({
2393
2416
  <div className="grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_360px] 2xl:grid-cols-[minmax(0,1fr)_400px]">
2394
2417
  <div className="space-y-4">
2395
2418
  <CurrentStateCard resource={resource} apiKind={apiKind} readiness={readiness} />
2396
- <div className="grid items-start gap-4 2xl:grid-cols-2">
2419
+ <div className="grid items-start gap-4 phone:grid-cols-[minmax(0,1fr)] 2xl:grid-cols-2">
2397
2420
  {overviewShape === 'cronjob' ? (
2398
2421
  <CronJobRunsCard
2399
2422
  resource={resource}
@@ -2433,7 +2456,7 @@ function WorkloadOverviewTab({
2433
2456
  />
2434
2457
  ) : null}
2435
2458
  {extraContent && (
2436
- <div className="grid items-start gap-4 2xl:grid-cols-2">
2459
+ <div className="grid items-start gap-4 phone:grid-cols-[minmax(0,1fr)] 2xl:grid-cols-2">
2437
2460
  {extraContent}
2438
2461
  </div>
2439
2462
  )}
@@ -0,0 +1,72 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // The hook must leave a caller's own width constraints alone unless the menu
4
+ // can't fit, and put them back exactly once it fits again.
5
+ import { act, useRef } from 'react'
6
+ import { createRoot, type Root } from 'react-dom/client'
7
+ import { afterEach, expect, it } from 'vitest'
8
+ import { useKeepInViewport } from './useKeepInViewport'
9
+
10
+ let root: Root | undefined
11
+ let host: HTMLDivElement | undefined
12
+ afterEach(() => {
13
+ act(() => root?.unmount())
14
+ host?.remove()
15
+ })
16
+
17
+ function Menu({ tick }: { tick: number }) {
18
+ const ref = useRef<HTMLDivElement>(null)
19
+ useKeepInViewport(ref, true)
20
+ return <div ref={ref} data-tick={tick} style={{ minWidth: 240 }} />
21
+ }
22
+
23
+ function setMenuWidth(el: HTMLElement, width: number) {
24
+ Object.defineProperty(el, 'offsetWidth', { configurable: true, value: width })
25
+ el.getBoundingClientRect = () => ({ left: 0, right: width, top: 0, bottom: 0, width, height: 0, x: 0, y: 0, toJSON: () => ({}) })
26
+ }
27
+
28
+ function render(tick: number) {
29
+ act(() => root!.render(<Menu tick={tick} />))
30
+ return host!.querySelector('div')!
31
+ }
32
+
33
+ it('keeps the caller min-width when the menu fits, and restores it after a squeeze', () => {
34
+ Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 1024 })
35
+ host = document.createElement('div')
36
+ document.body.appendChild(host)
37
+ root = createRoot(host)
38
+ let el = render(1)
39
+ setMenuWidth(el, 260)
40
+ el = render(2)
41
+ expect(el.style.minWidth).toBe('240px')
42
+ expect(el.style.maxWidth).toBe('')
43
+
44
+ Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 200 })
45
+ el = render(3)
46
+ expect(el.style.minWidth).toBe('0px')
47
+ expect(el.style.maxWidth).toBe('184px')
48
+
49
+ Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 1024 })
50
+ el = render(4)
51
+ expect(el.style.minWidth).toBe('240px')
52
+ expect(el.style.maxWidth).toBe('')
53
+ })
54
+
55
+ it('measures with transitions off and restores the caller transition', () => {
56
+ Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 1024 })
57
+ host = document.createElement('div')
58
+ document.body.appendChild(host)
59
+ root = createRoot(host)
60
+ let el = render(1)
61
+ el.style.transition = 'opacity 100ms'
62
+ const seen: string[] = []
63
+ el.getBoundingClientRect = () => {
64
+ seen.push(el.style.transition)
65
+ return { left: 410, right: 650, top: 0, bottom: 0, width: 240, height: 0, x: 410, y: 0, toJSON: () => ({}) }
66
+ }
67
+ el = render(2)
68
+ expect(seen.length).toBeGreaterThan(0)
69
+ expect(seen.every((t) => t === 'none')).toBe(true)
70
+ expect(el.style.transition).toBe('opacity 100ms')
71
+ expect(el.style.translate).toBe('')
72
+ })
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { keepInViewportShift } from './useKeepInViewport'
3
+
4
+ describe('keepInViewportShift', () => {
5
+ it('leaves a menu that fits alone', () => {
6
+ expect(keepInViewportShift(56, 336, 8, 1272)).toBe(0)
7
+ })
8
+
9
+ it('pulls a menu past the right edge back inside', () => {
10
+ // A 280px menu opened at x=56 on a 320px phone.
11
+ expect(keepInViewportShift(56, 336, 8, 312)).toBe(-24)
12
+ })
13
+
14
+ it('stops at a clipping ancestor, not the viewport edge', () => {
15
+ // Content column starts after a 56px rail: the left bound is 64, not 8,
16
+ // so a menu sitting under the rail moves out from behind it.
17
+ expect(keepInViewportShift(40, 240, 64, 312)).toBe(24)
18
+ })
19
+
20
+ it('keeps the left edge when the menu is wider than the bounds', () => {
21
+ expect(keepInViewportShift(20, 420, 8, 312)).toBe(-12)
22
+ })
23
+
24
+ it('pushes a menu past the left edge back in', () => {
25
+ expect(keepInViewportShift(-30, 200, 8, 382)).toBe(38)
26
+ })
27
+ })