@skyhook-io/k8s-ui 1.8.7 → 1.8.9

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 (42) hide show
  1. package/package.json +3 -3
  2. package/src/components/applications/ApplicationsList.tsx +5 -2
  3. package/src/components/applications/ApplicationsView.tsx +37 -30
  4. package/src/components/checks/ChecksView.tsx +25 -14
  5. package/src/components/cluster-switcher/ClusterSwitcher.tsx +27 -9
  6. package/src/components/gitops/GitOpsTableView.tsx +163 -88
  7. package/src/components/gitops/insights/GitOpsInsightViews.tsx +12 -5
  8. package/src/components/issues/IssuesView.tsx +41 -5
  9. package/src/components/issues/ResourceIssuesSection.tsx +3 -0
  10. package/src/components/issues/diagnostic.ts +22 -0
  11. package/src/components/issues/index.ts +1 -1
  12. package/src/components/issues/issues.test.ts +21 -0
  13. package/src/components/issues/types.ts +18 -0
  14. package/src/components/namespace-switcher/NamespacePicker.tsx +381 -0
  15. package/src/components/namespace-switcher/index.ts +6 -0
  16. package/src/components/resources/ResourcesView.tsx +20 -81
  17. package/src/components/scope-pill/ScopePill.tsx +35 -0
  18. package/src/components/scope-pill/index.ts +2 -0
  19. package/src/components/timeline/TimelineList.tsx +27 -1
  20. package/src/components/topology/TopologyControls.tsx +90 -14
  21. package/src/components/ui/FreshnessControl.tsx +153 -0
  22. package/src/components/ui/SortableTh.tsx +16 -10
  23. package/src/components/ui/SummaryTile.tsx +9 -1
  24. package/src/components/ui/Toast.tsx +1 -1
  25. package/src/components/ui/index.ts +2 -0
  26. package/src/components/workload/ResourceDetailDrawer.tsx +215 -32
  27. package/src/components/workload/WorkloadView.tsx +26 -8
  28. package/src/filter-state/filter-state-core.test.ts +98 -0
  29. package/src/filter-state/filter-state-core.ts +138 -0
  30. package/src/filter-state/filter-state.tsx +127 -0
  31. package/src/filter-state/index.ts +16 -0
  32. package/src/hooks/index.ts +1 -0
  33. package/src/hooks/useKeyboardShortcuts.tsx +23 -2
  34. package/src/hooks/useRefreshAnimation.ts +15 -2
  35. package/src/index.ts +12 -0
  36. package/src/types/core.ts +42 -0
  37. package/src/types/gitops-insights.ts +4 -0
  38. package/src/utils/animation.ts +10 -0
  39. package/src/utils/format-freshness.test.ts +34 -0
  40. package/src/utils/format.ts +32 -0
  41. package/src/utils/resource-hierarchy.test.ts +51 -0
  42. package/src/utils/resource-hierarchy.ts +7 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.8.7",
3
+ "version": "1.8.9",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -85,7 +85,7 @@
85
85
  "yaml": ">=2.0.0"
86
86
  },
87
87
  "devDependencies": {
88
- "@types/node": "^26.0.0",
88
+ "@types/node": "^26.0.1",
89
89
  "@types/react": "^19.2.17",
90
90
  "@types/react-dom": "^19.2.3",
91
91
  "@xterm/addon-fit": "^0.11.0",
@@ -95,7 +95,7 @@
95
95
  "clsx": "^2.1.1",
96
96
  "diff": "^9.0.0",
97
97
  "elkjs": "^0.11.1",
98
- "lucide-react": "^1.16.0",
98
+ "lucide-react": "^1.22.0",
99
99
  "react": "^19.2.7",
100
100
  "react-dom": "^19.2.7",
101
101
  "typescript": "^6.0.2",
@@ -1,4 +1,4 @@
1
- import { useMemo } from 'react'
1
+ import { useMemo, type ReactNode } from 'react'
2
2
  import { type AppRow, buildSingleAppEntry } from '../../utils/applications'
3
3
  import { ApplicationsView } from './ApplicationsView'
4
4
 
@@ -11,9 +11,11 @@ import { ApplicationsView } from './ApplicationsView'
11
11
  export interface ApplicationsListProps {
12
12
  apps: AppRow[]
13
13
  onSelect: (key: string) => void
14
+ /** Leading element in the header actions (e.g. a freshness control). */
15
+ headerActions?: ReactNode
14
16
  }
15
17
 
16
- export function ApplicationsList({ apps, onSelect }: ApplicationsListProps) {
18
+ export function ApplicationsList({ apps, onSelect, headerActions }: ApplicationsListProps) {
17
19
  // Env tokens this CLUSTER proved (identity classifications on the wire) feed
18
20
  // the namespace heuristic, so sibling-less rows in discovered env namespaces
19
21
  // still label without any hardcoded vocabulary.
@@ -25,6 +27,7 @@ export function ApplicationsList({ apps, onSelect }: ApplicationsListProps) {
25
27
  variant="single"
26
28
  entries={entries}
27
29
  onSelect={onSelect}
30
+ headerActions={headerActions}
28
31
  title="Applications"
29
32
  description="Deployable software in this cluster — your services, workers, and jobs, grouped by app/release evidence."
30
33
  />
@@ -6,6 +6,7 @@ import { StatusDot, mapHealthToTone } from '../ui/status-tone'
6
6
  import { Tooltip } from '../ui/Tooltip'
7
7
  import { EmptyState } from '../ui/EmptyState'
8
8
  import { SearchBox } from '../ui/SearchBox'
9
+ import { useFilterState, defineFilterSchema } from '../../filter-state'
9
10
  import { PageHeader } from '../ui/PageHeader'
10
11
  import { SummaryTile, type SummaryTone } from '../ui/SummaryTile'
11
12
  import { Facet, type FacetTone } from '../ui/Facet'
@@ -97,16 +98,33 @@ export interface ApplicationsViewProps {
97
98
  /** Rendered instead of the built-in EmptyState when there are zero entries
98
99
  * pre-filter (the fleet host injects a coverage/offline-aware empty). */
99
100
  emptySlot?: ReactNode
101
+ /** Leading element in the header actions cluster (e.g. a freshness control). */
102
+ headerActions?: ReactNode
100
103
  }
101
104
 
102
- export function ApplicationsView({ entries: allEntries, variant, onSelect, title = 'Applications', description, emptySlot }: ApplicationsViewProps) {
103
- const [textFilter, setTextFilter] = useState('')
104
- const [fHealth, setFHealth] = useState<Set<AppHealth>>(new Set())
105
- const [fEnv, setFEnv] = useState<Set<string>>(new Set())
106
- const [fSource, setFSource] = useState<Set<AppSource>>(new Set())
107
- const [fClass, setFClass] = useState<Set<AppWorkloadClass>>(new Set())
108
- const [fType, setFType] = useState<Set<AppCategory>>(new Set())
109
- const [showSystem, setShowSystem] = useState(false)
105
+ const APPS_FILTER_SCHEMA = defineFilterSchema({
106
+ health: { param: 'health', type: 'set' },
107
+ class: { param: 'class', type: 'set' },
108
+ type: { param: 'type', type: 'set' },
109
+ env: { param: 'env', type: 'set' },
110
+ source: { param: 'source', type: 'set' },
111
+ q: { param: 'q', type: 'text' },
112
+ system: { param: 'system', type: 'boolean' },
113
+ })
114
+
115
+ export function ApplicationsView({ entries: allEntries, variant, onSelect, title = 'Applications', description, emptySlot, headerActions }: ApplicationsViewProps) {
116
+ // Facets + search + show-system live in the URL (shareable, bookmarkable) via
117
+ // the shared filter-state contract. Sort stays local: it's a compound
118
+ // {key, dir} view-preference, not a result-narrowing filter, so it isn't
119
+ // forced into the filter vocabulary.
120
+ const filters = useFilterState(APPS_FILTER_SCHEMA)
121
+ const textFilter = filters.values.q
122
+ const fHealth = filters.values.health as Set<AppHealth>
123
+ const fEnv = filters.values.env
124
+ const fSource = filters.values.source as Set<AppSource>
125
+ const fClass = filters.values.class as Set<AppWorkloadClass>
126
+ const fType = filters.values.type as Set<AppCategory>
127
+ const showSystem = filters.values.system
110
128
  const [sort, setSort] = useState<{ key: SortKey; dir: SortDir } | null>(null)
111
129
 
112
130
  // The Show-system toggle keys off per-entry workload namespaces, which only
@@ -242,12 +260,6 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
242
260
  return { health, env, source, workloadClass, category }
243
261
  }, [all])
244
262
 
245
- const toggle = <T,>(set: Set<T>, setter: (s: Set<T>) => void, v: T) => {
246
- const next = new Set(set)
247
- next.has(v) ? next.delete(v) : next.add(v)
248
- setter(next)
249
- }
250
-
251
263
  // asc → desc → off (null = default health-worst-first sort).
252
264
  const onSort = (key: SortKey) => {
253
265
  setSort((prev) => {
@@ -265,20 +277,14 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
265
277
  // Clickable status tile wired to the health facet — tap to filter to that tier.
266
278
  const healthTile = (h: AppHealth, tone: SummaryTone) =>
267
279
  counts.health[h] ? (
268
- <SummaryTile key={h} label={HEALTH_META[h].label} value={counts.health[h]} tone={tone} active={fHealth.has(h)} onClick={() => toggle(fHealth, setFHealth, h)} />
280
+ <SummaryTile key={h} label={HEALTH_META[h].label} value={counts.health[h]} tone={tone} active={fHealth.has(h)} onClick={() => filters.toggle('health', h)} />
269
281
  ) : null
270
282
 
271
283
  // showSystem lives in the Filters rail, so Clear resets it too (and its
272
284
  // non-default ON state counts as an active filter that surfaces the button).
273
- const anyFilterActive = !!(textFilter || fHealth.size || fClass.size || fType.size || fSource.size || fEnv.size || showSystem)
285
+ const anyFilterActive = filters.isActive
274
286
  const clearAllFilters = () => {
275
- setTextFilter('')
276
- setFHealth(new Set())
277
- setFClass(new Set())
278
- setFType(new Set())
279
- setFSource(new Set())
280
- setFEnv(new Set())
281
- setShowSystem(false)
287
+ filters.clearAll()
282
288
  }
283
289
 
284
290
  return (
@@ -294,6 +300,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
294
300
  description={description}
295
301
  actions={
296
302
  <>
303
+ {headerActions}
297
304
  <SummaryTile label={total === 1 ? 'application' : 'applications'} value={total} />
298
305
  {healthTile('unhealthy', 'error')}
299
306
  {healthTile('degraded', 'warning')}
@@ -323,14 +330,14 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
323
330
  )}
324
331
  </div>
325
332
  <div className="flex-1 overflow-y-auto">
326
- <Facet icon={HeartPulse} title="Availability" options={HEALTH_ORDER.map((h) => ({ value: h, label: HEALTH_META[h].label, count: counts.health[h] ?? 0, tone: HEALTH_TONE[h] }))} selected={fHealth} onToggle={(v) => toggle(fHealth, setFHealth, v)} />
327
- <Facet icon={Layers} title="Class" options={CLASS_ORDER.map((c) => ({ value: c, label: CLASS_META[c].label, count: counts.workloadClass[c] ?? 0 }))} selected={fClass} onToggle={(v) => toggle(fClass, setFClass, v)} />
328
- <Facet icon={Shapes} title="Type" options={CATEGORY_ORDER.map((c) => ({ value: c, label: CATEGORY_META[c].label, count: counts.category[c] ?? 0, tooltip: CATEGORY_META[c].tooltip }))} selected={fType} onToggle={(v) => toggle(fType, setFType, v)} />
329
- <Facet icon={Globe} title="Environment" info={<EnvHint />} options={envOptions} selected={fEnv} onToggle={(v) => toggle(fEnv, setFEnv, v)} />
330
- <Facet icon={Tag} title="Source" options={SOURCE_ORDER.map((s) => ({ value: s, label: SOURCE_META[s].label, count: counts.source[s] ?? 0 }))} selected={fSource} onToggle={(v) => toggle(fSource, setFSource, v)} />
333
+ <Facet icon={HeartPulse} title="Availability" options={HEALTH_ORDER.map((h) => ({ value: h, label: HEALTH_META[h].label, count: counts.health[h] ?? 0, tone: HEALTH_TONE[h] }))} selected={fHealth} onToggle={(v) => filters.toggle('health', v)} />
334
+ <Facet icon={Layers} title="Class" options={CLASS_ORDER.map((c) => ({ value: c, label: CLASS_META[c].label, count: counts.workloadClass[c] ?? 0 }))} selected={fClass} onToggle={(v) => filters.toggle('class', v)} />
335
+ <Facet icon={Shapes} title="Type" options={CATEGORY_ORDER.map((c) => ({ value: c, label: CATEGORY_META[c].label, count: counts.category[c] ?? 0, tooltip: CATEGORY_META[c].tooltip }))} selected={fType} onToggle={(v) => filters.toggle('type', v)} />
336
+ <Facet icon={Globe} title="Environment" info={<EnvHint />} options={envOptions} selected={fEnv} onToggle={(v) => filters.toggle('env', v)} />
337
+ <Facet icon={Tag} title="Source" options={SOURCE_ORDER.map((s) => ({ value: s, label: SOURCE_META[s].label, count: counts.source[s] ?? 0 }))} selected={fSource} onToggle={(v) => filters.toggle('source', v)} />
331
338
  {systemCount > 0 && (
332
339
  <label className="flex cursor-pointer items-center gap-2 border-b border-theme-border px-3 py-2 text-[11px] text-theme-text-secondary hover:bg-theme-hover">
333
- <input type="checkbox" checked={showSystem} onChange={(e) => setShowSystem(e.target.checked)} className="accent-skyhook-500" />
340
+ <input type="checkbox" checked={showSystem} onChange={(e) => filters.setBoolean('system', e.target.checked)} className="accent-skyhook-500" />
334
341
  <span>Show system namespaces</span>
335
342
  <span className="ml-auto tabular-nums text-theme-text-tertiary">{systemCount}</span>
336
343
  </label>
@@ -343,7 +350,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
343
350
  <div className="flex shrink-0 items-center gap-3 border-b border-theme-border px-4 py-3">
344
351
  <SearchBox
345
352
  value={textFilter}
346
- onChange={setTextFilter}
353
+ onChange={(v) => filters.setString('q', v)}
347
354
  scope="applications"
348
355
  shortcutId="applications-search"
349
356
  className="max-w-md flex-1"
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { ChevronDown, ChevronRight, ExternalLink, EyeOff, MoreHorizontal, Search, ShieldCheck, Wrench, X } from 'lucide-react'
4
4
  import { ClusterName, EmptyState, FilterPill, DistributionBar, DistributionLegendChip } from '../ui'
5
+ import { useFilterState, defineFilterSchema } from '../../filter-state'
5
6
  import type { CheckMeta, CheckReference } from '../audit'
6
7
  import { CHECK_SEVERITIES, CHECK_SEVERITY_RANK, type Check, type CheckSeverity, type EffectiveCheckFinding, type CheckResourceRef } from './types'
7
8
  import {
@@ -79,11 +80,24 @@ interface FleetCheck {
79
80
  clusters: Check[]
80
81
  }
81
82
 
83
+ const CHECKS_FILTER_SCHEMA = defineFilterSchema({
84
+ severity: { param: 'severity', type: 'set' },
85
+ category: { param: 'category', type: 'set' },
86
+ framework: { param: 'framework', type: 'set' },
87
+ q: { param: 'q', type: 'text' },
88
+ })
89
+
82
90
  export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceClick, clusterLabel, clusterLabelById, clusterFilter: clusterFilterProp, onClusterFilterChange, emptyAction, onHideCheck, onHideCategory }: ChecksViewProps) {
83
- const [severityFilter, setSeverityFilter] = useState<Set<CheckSeverity>>(new Set())
84
- const [categoryFilter, setCategoryFilter] = useState<Set<string>>(new Set())
85
- const [frameworkFilter, setFrameworkFilter] = useState<Set<string>>(new Set())
86
- const [search, setSearch] = useState('')
91
+ // Severity / category / framework / search live in the URL (shareable,
92
+ // bookmarkable audit links) via the shared filter-state contract. The cluster
93
+ // facet is deliberately NOT here — it's a host-controlled seam (see
94
+ // onClusterFilterChange) for the multi-cluster/fleet case and isn't shown in
95
+ // single-cluster OSS, so it stays on its own controlled/internal path.
96
+ const filters = useFilterState(CHECKS_FILTER_SCHEMA)
97
+ const severityFilter = filters.values.severity as Set<CheckSeverity>
98
+ const categoryFilter = filters.values.category
99
+ const frameworkFilter = filters.values.framework
100
+ const search = filters.values.q
87
101
  const [openId, setOpenId] = useState<string | null>(null)
88
102
 
89
103
  // Cluster facet is controlled when the host opts in (onClusterFilterChange);
@@ -209,13 +223,10 @@ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceC
209
223
  return next
210
224
  })
211
225
 
212
- const hasFilters = severityFilter.size > 0 || categoryFilter.size > 0 || frameworkFilter.size > 0 || clusterFilter.size > 0 || search !== ''
226
+ const hasFilters = filters.isActive || clusterFilter.size > 0
213
227
  const clearAll = () => {
214
- setSeverityFilter(new Set())
215
- setCategoryFilter(new Set())
216
- setFrameworkFilter(new Set())
228
+ filters.clearAll()
217
229
  setClusterFilter(new Set())
218
- setSearch('')
219
230
  }
220
231
 
221
232
  return (
@@ -239,13 +250,13 @@ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceC
239
250
  type="text"
240
251
  placeholder="Search checks…"
241
252
  value={search}
242
- onChange={(e) => setSearch(e.target.value)}
253
+ onChange={(e) => filters.setString('q', e.target.value)}
243
254
  className="w-64 rounded-lg border border-theme-border-light bg-theme-base py-1.5 pl-9 pr-8 text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-[var(--color-radar-accent)]"
244
255
  />
245
256
  {search && (
246
257
  <button
247
258
  type="button"
248
- onClick={() => setSearch('')}
259
+ onClick={() => filters.setString('q', '')}
249
260
  aria-label="Clear search"
250
261
  className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-theme-text-tertiary hover:text-theme-text-primary"
251
262
  >
@@ -259,17 +270,17 @@ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceC
259
270
 
260
271
  <div className="flex flex-wrap items-center gap-1.5">
261
272
  {CHECK_SEVERITIES.map((s) => (
262
- <CheckSeverityChip key={s} severity={s} count={totals[s]} active={severityFilter.has(s)} onClick={() => toggle(setSeverityFilter, s)} />
273
+ <CheckSeverityChip key={s} severity={s} count={totals[s]} active={severityFilter.has(s)} onClick={() => filters.toggle('severity', s)} />
263
274
  ))}
264
275
  <span className="mx-1.5 h-5 w-px bg-theme-border" />
265
276
  {CATEGORIES.map((c) => (
266
- <FilterPill key={c} label={c} active={categoryFilter.has(c)} onClick={() => toggle(setCategoryFilter, c)} />
277
+ <FilterPill key={c} label={c} active={categoryFilter.has(c)} onClick={() => filters.toggle('category', c)} />
267
278
  ))}
268
279
  {frameworks.length > 0 && (
269
280
  <>
270
281
  <span className="mx-1.5 h-5 w-px bg-theme-border" />
271
282
  {frameworks.map((fw) => (
272
- <FilterPill key={fw} label={fw} active={frameworkFilter.has(fw)} onClick={() => toggle(setFrameworkFilter, fw)} />
283
+ <FilterPill key={fw} label={fw} active={frameworkFilter.has(fw)} onClick={() => filters.toggle('framework', fw)} />
273
284
  ))}
274
285
  </>
275
286
  )}
@@ -58,6 +58,15 @@ export interface ClusterSwitcherProps {
58
58
  errorSlot?: ReactNode
59
59
  className?: string
60
60
  align?: 'left' | 'right'
61
+ /**
62
+ * 'chip' (default) renders a self-contained bordered pill. 'segment' renders
63
+ * a borderless label+value cell for embedding in a shared bordered container
64
+ * (the unified cluster+namespace scope control) — no border/background/min-w
65
+ * of its own, with an optional muted {@link label} before the value.
66
+ */
67
+ variant?: 'chip' | 'segment'
68
+ /** Muted label shown before the value in the 'segment' variant (e.g. "Cluster"). */
69
+ label?: string
61
70
  }
62
71
 
63
72
  // Trigger width cap. With middle-truncation kicking in, this is a
@@ -83,6 +92,8 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
83
92
  errorSlot,
84
93
  className = '',
85
94
  align = 'left',
95
+ variant = 'chip',
96
+ label,
86
97
  }, ref) => {
87
98
  const [isOpen, setIsOpen] = useState(false)
88
99
  const [search, setSearch] = useState('')
@@ -195,14 +206,21 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
195
206
  type="button"
196
207
  onClick={() => setIsOpen(v => !v)}
197
208
  disabled={disabled || loading}
198
- className={`
199
- flex items-center gap-1.5 px-2.5 py-1.5 min-w-[140px]
200
- bg-theme-elevated border border-theme-border rounded text-sm font-medium
201
- text-theme-text-primary hover:bg-theme-hover hover:border-theme-border-light
202
- transition-colors cursor-pointer
203
- disabled:opacity-50 disabled:cursor-not-allowed
204
- `}
209
+ className={
210
+ variant === 'segment'
211
+ ? `flex items-center gap-1.5 px-3 py-1.5 h-full min-w-[150px] max-w-[264px] text-[13px] font-medium
212
+ text-theme-text-primary hover:bg-theme-hover transition-colors cursor-pointer
213
+ disabled:opacity-50 disabled:cursor-not-allowed`
214
+ : `flex items-center gap-1.5 px-2.5 py-1.5 min-w-[140px]
215
+ bg-theme-elevated border border-theme-border rounded text-sm font-medium
216
+ text-theme-text-primary hover:bg-theme-hover hover:border-theme-border-light
217
+ transition-colors cursor-pointer
218
+ disabled:opacity-50 disabled:cursor-not-allowed`
219
+ }
205
220
  >
221
+ {label && (
222
+ <span className="shrink-0 font-normal text-theme-text-tertiary">{label}</span>
223
+ )}
206
224
  {loading ? (
207
225
  <>
208
226
  <Loader2 className="w-3.5 h-3.5 animate-spin" />
@@ -220,7 +238,7 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
220
238
  <ClusterName
221
239
  name={currentName}
222
240
  fallbackBadge={<Server className="w-3.5 h-3.5 text-theme-text-secondary" />}
223
- className={TRIGGER_NAME_MAX_WIDTH}
241
+ className={variant === 'segment' ? 'min-w-0 max-w-[214px]' : TRIGGER_NAME_MAX_WIDTH}
224
242
  noTooltip={isOpen}
225
243
  />
226
244
  {currentSourceLabel && (
@@ -240,7 +258,7 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
240
258
  )}
241
259
  </>
242
260
  )}
243
- <ChevronDown className={`w-3 h-3 ml-auto transition-transform ${isOpen ? 'rotate-180' : ''}`} />
261
+ <ChevronDown className={`w-3 h-3 shrink-0 transition-transform ${variant === 'segment' ? '' : 'ml-auto'} ${isOpen ? 'rotate-180' : ''}`} />
244
262
  </button>
245
263
 
246
264
  {isOpen && (