@skyhook-io/radar-app 0.2.2 → 1.0.1

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.
@@ -1,4 +1,4 @@
1
- import { useMemo, useState } from 'react'
1
+ import { useMemo, useState, forwardRef } from 'react'
2
2
  import { AlertTriangle } from 'lucide-react'
3
3
  import {
4
4
  ClusterSwitcher,
@@ -16,11 +16,15 @@ interface ContextSwitcherProps {
16
16
  className?: string
17
17
  }
18
18
 
19
+ export interface ContextSwitcherHandle {
20
+ open: () => void
21
+ }
22
+
19
23
  interface ParsedContext extends ParsedContextName {
20
24
  context: ContextInfo
21
25
  }
22
26
 
23
- export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
27
+ export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcherProps>(({ className = '' }, ref) => {
24
28
  const [showConfirm, setShowConfirm] = useState(false)
25
29
  const [pendingSwitch, setPendingSwitch] = useState<ParsedContext | null>(null)
26
30
  const [sessionCounts, setSessionCounts] = useState<SessionCounts | null>(null)
@@ -33,13 +37,37 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
33
37
  const { tabs } = useDock()
34
38
 
35
39
  // Parse contexts and decide whether to render group headers (multi-account only).
36
- const { parsedById, hasMultipleAccounts } = useMemo(() => {
37
- if (!contexts) return { parsedById: new Map<string, ParsedContext>(), hasMultipleAccounts: false }
38
- const parsed: ParsedContext[] = contexts.map(ctx => ({ context: ctx, ...parseContextName(ctx.name) }))
40
+ // hasMultipleSources gates the kubeconfig-source chip only useful when 2+
41
+ // distinct kubeconfig files are in play. Single-source setups (the common
42
+ // case) skip the chip entirely so the dropdown stays clean.
43
+ const { parsedById, hasMultipleAccounts, hasMultipleSources } = useMemo(() => {
44
+ if (!contexts) return {
45
+ parsedById: new Map<string, ParsedContext>(),
46
+ hasMultipleAccounts: false,
47
+ hasMultipleSources: false,
48
+ }
49
+ // Strip the disambiguation suffix (" (<source>)" or " (<source> #N)")
50
+ // before parsing — qualified names won't match the GKE/EKS/AKS regexes
51
+ // otherwise, and the suffix is redundant with the source chip we
52
+ // render separately.
53
+ const stripSourceSuffix = (name: string, source?: string): string => {
54
+ if (!source) return name
55
+ const escaped = source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
56
+ return name.replace(new RegExp(`\\s+\\(${escaped}(?:\\s+#\\d+)?\\)$`), '')
57
+ }
58
+ const parsed: ParsedContext[] = contexts.map(ctx => ({
59
+ context: ctx,
60
+ ...parseContextName(stripSourceSuffix(ctx.name, ctx.source)),
61
+ }))
39
62
  const accounts = new Set(parsed.map(p => `${p.provider}:${p.account}`))
63
+ const sources = new Set(contexts.map(c => c.source).filter(Boolean))
40
64
  const byId = new Map<string, ParsedContext>()
41
65
  for (const p of parsed) byId.set(p.context.name, p)
42
- return { parsedById: byId, hasMultipleAccounts: accounts.size > 1 }
66
+ return {
67
+ parsedById: byId,
68
+ hasMultipleAccounts: accounts.size > 1,
69
+ hasMultipleSources: sources.size > 1,
70
+ }
43
71
  }, [contexts])
44
72
 
45
73
  // Map parsed contexts → generic ClusterSwitcher items, sorted GKE/EKS/AKS/Other → account → name.
@@ -61,20 +89,16 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
61
89
  : hasMultipleAccounts
62
90
  ? 'Other'
63
91
  : undefined
64
- // `name` is the raw context — ClusterSwitcher renders it through
65
- // ClusterName, which collapses GKE/EKS/AKS shapes to the meaningful
66
- // tail. `secondary` shows the original raw when we collapsed it,
67
- // so users always see the full context at a glance (rather than
68
- // having to hover to reveal it).
69
92
  return {
70
93
  id: p.context.name,
71
- name: p.context.name,
94
+ name: p.raw,
72
95
  secondary: p.provider ? p.raw : undefined,
73
96
  badge: p.region || undefined,
97
+ sourceLabel: hasMultipleSources ? p.context.source : undefined,
74
98
  group: { key: groupKey, label: groupLabel },
75
99
  }
76
100
  })
77
- }, [parsedById, hasMultipleAccounts])
101
+ }, [parsedById, hasMultipleAccounts, hasMultipleSources])
78
102
 
79
103
  const performSwitch = async (parsed: ParsedContext) => {
80
104
  startSwitch({
@@ -152,15 +176,24 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
152
176
  )
153
177
  }
154
178
 
155
- const currentRaw = clusterInfo?.context || contexts?.find(c => c.isCurrent)?.name || 'Unknown'
156
- const currentId = contexts?.find(c => c.isCurrent)?.name
179
+ const currentCtx = contexts?.find(c => c.isCurrent)
180
+ const currentId = currentCtx?.name
181
+ // Use parsed.raw (the source-stripped form) for the trigger so the
182
+ // disambiguation suffix doesn't double up with the source chip.
183
+ // Fall back to clusterInfo.context for the very-early window before
184
+ // /api/contexts has resolved.
185
+ const currentParsed = currentId ? parsedById.get(currentId) : undefined
186
+ const currentRaw = currentParsed?.raw || clusterInfo?.context || currentCtx?.name || 'Unknown'
187
+ const currentSourceLabel = hasMultipleSources ? currentCtx?.source || undefined : undefined
157
188
 
158
189
  return (
159
190
  <>
160
191
  <ClusterSwitcher
192
+ ref={ref}
161
193
  className={className}
162
194
  currentId={currentId}
163
195
  currentName={currentRaw}
196
+ currentSourceLabel={currentSourceLabel}
164
197
  items={items}
165
198
  onSelect={handleSelect}
166
199
  loading={switchContext.isPending}
@@ -222,4 +255,4 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
222
255
  )}
223
256
  </>
224
257
  )
225
- }
258
+ })
@@ -0,0 +1,298 @@
1
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { ChevronDown, Globe, Search, AlertTriangle, X } from 'lucide-react'
4
+ import { useNamespaceScope, useSetActiveNamespace } from '../api/client'
5
+ import { Tooltip } from './ui/Tooltip'
6
+
7
+ export interface NamespaceSwitcherHandle {
8
+ open: () => void
9
+ }
10
+
11
+ interface NamespaceSwitcherProps {
12
+ className?: string
13
+ disabled?: boolean
14
+ disabledTooltip?: string
15
+ }
16
+
17
+ /**
18
+ * NamespaceSwitcher is a per-user multi-select view filter for the cluster
19
+ * view. It does NOT reshape the shared informer cache — picks are saved
20
+ * server-side per user and intersected with the user's RBAC-allowed
21
+ * namespaces on each read.
22
+ *
23
+ * Three states reflect what the backend reports:
24
+ * - cluster-wide: empty trigger label "All namespaces", picker lets the
25
+ * user narrow the view; otherwise informational.
26
+ * - namespace: label shows the namespace count (or single name); picker
27
+ * offers other accessible namespaces and a clear-all reset.
28
+ * - restricted: user can't list namespaces and isn't pinned; picker
29
+ * surfaces only the kubeconfig context's namespace + any saved picks.
30
+ *
31
+ * Selection model: the dropdown keeps a draft Set<string>; toggling rows
32
+ * mutates the draft locally; closing the dropdown applies the draft in a
33
+ * single mutation. "Clear all" applies immediately and closes; "Select all
34
+ * visible" / "Clear visible" mutate the draft only and wait for close.
35
+ */
36
+ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSwitcherProps>(function NamespaceSwitcher(
37
+ { className = '', disabled = false, disabledTooltip },
38
+ ref,
39
+ ) {
40
+ const { data: scope, isLoading } = useNamespaceScope()
41
+ const setActive = useSetActiveNamespace()
42
+
43
+ const [isOpen, setIsOpen] = useState(false)
44
+ const [search, setSearch] = useState('')
45
+ const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
46
+ const [draft, setDraft] = useState<Set<string>>(() => new Set())
47
+
48
+ const triggerRef = useRef<HTMLButtonElement>(null)
49
+ const dropdownRef = useRef<HTMLDivElement>(null)
50
+
51
+ const scopeActives = useMemo(() => scope?.actives ?? [], [scope?.actives])
52
+ const activesKey = useMemo(() => [...scopeActives].sort().join(','), [scopeActives])
53
+
54
+ // Sync the draft with the server's view whenever it changes (initial load,
55
+ // post-mutation refetch, eviction after RBAC drift).
56
+ useEffect(() => {
57
+ setDraft(new Set(scopeActives))
58
+ }, [activesKey, scopeActives])
59
+
60
+ const items = useMemo(() => {
61
+ if (!scope) return [] as string[]
62
+ return [...(scope.accessibleNamespaces ?? [])].sort((a, b) => a.localeCompare(b))
63
+ }, [scope])
64
+
65
+ const filteredItems = useMemo(() => {
66
+ const q = search.trim().toLowerCase()
67
+ if (!q) return items
68
+ return items.filter(n => n.toLowerCase().includes(q))
69
+ }, [items, search])
70
+
71
+ const applySelection = useCallback((next: Set<string>) => {
72
+ if (!scope) return
73
+ const nextArr = Array.from(next).sort()
74
+ if (nextArr.join(',') === activesKey) return
75
+ setActive.mutate({ namespaces: nextArr })
76
+ }, [activesKey, scope, setActive])
77
+
78
+ const closeAndApply = useCallback(() => {
79
+ setIsOpen(false)
80
+ setSearch('')
81
+ applySelection(draft)
82
+ }, [applySelection, draft])
83
+
84
+ useImperativeHandle(ref, () => ({
85
+ open: () => {
86
+ if (disabled || isLoading || setActive.isPending) return
87
+ setIsOpen(true)
88
+ },
89
+ }), [disabled, isLoading, setActive.isPending])
90
+
91
+ useEffect(() => {
92
+ if (!isOpen) return
93
+ const trigger = triggerRef.current
94
+ if (!trigger) return
95
+ const r = trigger.getBoundingClientRect()
96
+ setPos({ top: r.bottom + 4, left: r.left, width: Math.max(r.width, 240) })
97
+ }, [isOpen])
98
+
99
+ useEffect(() => {
100
+ if (!isOpen) return
101
+ function onClick(e: MouseEvent) {
102
+ if (
103
+ !dropdownRef.current?.contains(e.target as Node) &&
104
+ !triggerRef.current?.contains(e.target as Node)
105
+ ) {
106
+ closeAndApply()
107
+ }
108
+ }
109
+ function onKey(e: KeyboardEvent) {
110
+ if (e.key === 'Escape') closeAndApply()
111
+ }
112
+ document.addEventListener('mousedown', onClick)
113
+ document.addEventListener('keydown', onKey)
114
+ return () => {
115
+ document.removeEventListener('mousedown', onClick)
116
+ document.removeEventListener('keydown', onKey)
117
+ }
118
+ }, [isOpen, closeAndApply])
119
+
120
+ if (!scope) return null
121
+
122
+ const toggle = (ns: string) => {
123
+ const next = new Set(draft)
124
+ if (next.has(ns)) next.delete(ns)
125
+ else next.add(ns)
126
+ setDraft(next)
127
+ }
128
+
129
+ const clearAll = () => {
130
+ setDraft(new Set())
131
+ setIsOpen(false)
132
+ setSearch('')
133
+ applySelection(new Set())
134
+ }
135
+
136
+ const selectAllVisible = () => {
137
+ const next = new Set(draft)
138
+ for (const ns of filteredItems) next.add(ns)
139
+ setDraft(next)
140
+ }
141
+
142
+ const clearVisible = () => {
143
+ const next = new Set(draft)
144
+ for (const ns of filteredItems) next.delete(ns)
145
+ setDraft(next)
146
+ }
147
+
148
+ const activeCount = scopeActives.length
149
+ const triggerLabel =
150
+ activeCount === 0 ? 'All namespaces' : activeCount === 1 ? scopeActives[0] : `${activeCount} namespaces`
151
+ const isClusterWide = activeCount === 0
152
+ const restrictedHint = scope.mode === 'restricted'
153
+ const isDisabled = disabled || isLoading || setActive.isPending
154
+ const canClearAll = scope.canClearNamespace || activeCount === 0
155
+ const tooltipContent = disabled && disabledTooltip
156
+ ? disabledTooltip
157
+ : restrictedHint
158
+ ? 'Limited namespace visibility — only namespaces granted by your RBAC are shown.'
159
+ : isClusterWide
160
+ ? 'Currently viewing all namespaces. Click to narrow the view.'
161
+ : activeCount === 1
162
+ ? `View is filtered to namespace ${scopeActives[0]}. Click to switch or reset.`
163
+ : `View is filtered to ${activeCount} namespaces. Click to adjust or reset.`
164
+
165
+ // Counts used to label the bulk-action buttons; computed against the visible
166
+ // (filtered) set so the labels match what the action will affect.
167
+ const visibleSelectedCount = filteredItems.reduce((n, ns) => n + (draft.has(ns) ? 1 : 0), 0)
168
+ const allVisibleSelected = filteredItems.length > 0 && visibleSelectedCount === filteredItems.length
169
+
170
+ return (
171
+ <>
172
+ <Tooltip
173
+ content={tooltipContent}
174
+ delay={300}
175
+ position="bottom"
176
+ >
177
+ <button
178
+ ref={triggerRef}
179
+ onClick={() => !isDisabled && (isOpen ? closeAndApply() : setIsOpen(true))}
180
+ disabled={isDisabled}
181
+ className={`flex items-center gap-1.5 px-2 py-1 rounded text-sm bg-theme-elevated hover:bg-theme-hover text-theme-text-primary disabled:opacity-60 transition-colors ${className}`}
182
+ aria-label="Switch active namespaces"
183
+ >
184
+ {isClusterWide ? (
185
+ <Globe className="w-3.5 h-3.5 text-theme-text-tertiary" />
186
+ ) : restrictedHint ? (
187
+ <AlertTriangle className="w-3.5 h-3.5 text-theme-text-tertiary" />
188
+ ) : null}
189
+ <span className="font-medium max-w-[180px] truncate">
190
+ {setActive.isPending ? 'Switching…' : triggerLabel}
191
+ </span>
192
+ <ChevronDown className="w-3 h-3 opacity-60" />
193
+ </button>
194
+ </Tooltip>
195
+
196
+ {isOpen &&
197
+ createPortal(
198
+ <div
199
+ ref={dropdownRef}
200
+ style={{ position: 'fixed', top: pos.top, left: pos.left, minWidth: pos.width, zIndex: 100 }}
201
+ className="bg-theme-surface border border-theme-border rounded-md shadow-theme-lg overflow-hidden"
202
+ >
203
+ {items.length > 6 && (
204
+ <div className="flex items-center gap-2 px-2 py-1.5 border-b border-theme-border">
205
+ <Search className="w-3.5 h-3.5 text-theme-text-tertiary" />
206
+ <input
207
+ autoFocus
208
+ value={search}
209
+ onChange={e => setSearch(e.target.value)}
210
+ placeholder="Filter namespaces"
211
+ className="flex-1 bg-transparent text-sm outline-none text-theme-text-primary placeholder:text-theme-text-tertiary"
212
+ />
213
+ </div>
214
+ )}
215
+
216
+ <div className="flex items-center justify-between px-2 py-1.5 border-b border-theme-border text-xs text-theme-text-secondary">
217
+ <button
218
+ onClick={canClearAll ? clearAll : undefined}
219
+ disabled={!canClearAll || activeCount === 0}
220
+ className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
221
+ aria-label="Clear namespace selection"
222
+ >
223
+ <X className="w-3 h-3" />
224
+ Clear all
225
+ </button>
226
+ <button
227
+ onClick={allVisibleSelected ? clearVisible : selectAllVisible}
228
+ disabled={filteredItems.length === 0}
229
+ className="px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
230
+ >
231
+ {allVisibleSelected
232
+ ? `Clear ${filteredItems.length} visible`
233
+ : search.trim()
234
+ ? `Select ${filteredItems.length} visible`
235
+ : 'Select all'}
236
+ </button>
237
+ </div>
238
+
239
+ <ul className="max-h-80 overflow-y-auto py-1">
240
+ {filteredItems.length === 0 && (
241
+ <li className="px-3 py-2 text-xs text-theme-text-tertiary">
242
+ {search ? 'No matches.' : 'No namespaces available.'}
243
+ </li>
244
+ )}
245
+
246
+ {filteredItems.map(ns => {
247
+ const isChecked = draft.has(ns)
248
+ const isContextDefault = ns === scope.kubeconfigNamespace && ns !== ''
249
+ return (
250
+ <li key={ns}>
251
+ <label
252
+ className="w-full flex items-center justify-between px-3 py-1.5 text-sm hover:bg-theme-hover text-left text-theme-text-primary cursor-pointer"
253
+ >
254
+ <span className="flex items-center gap-2 min-w-0">
255
+ <input
256
+ type="checkbox"
257
+ checked={isChecked}
258
+ onChange={() => toggle(ns)}
259
+ className="shrink-0 accent-current"
260
+ />
261
+ <span className="truncate">{ns}</span>
262
+ {isContextDefault && (
263
+ <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary shrink-0">
264
+ kubeconfig
265
+ </span>
266
+ )}
267
+ </span>
268
+ </label>
269
+ </li>
270
+ )
271
+ })}
272
+ </ul>
273
+
274
+ <div className="flex items-center justify-between px-3 py-1.5 border-t border-theme-border text-[11px] text-theme-text-tertiary">
275
+ <span>
276
+ {draft.size === 0 ? 'All namespaces' : `${draft.size} selected`}
277
+ </span>
278
+ <button
279
+ onClick={closeAndApply}
280
+ className="px-2 py-0.5 rounded bg-theme-elevated hover:bg-theme-hover text-theme-text-primary"
281
+ >
282
+ Done
283
+ </button>
284
+ </div>
285
+
286
+ {!scope.authoritative && (
287
+ <div className="px-3 py-2 border-t border-theme-border text-[11px] status-degraded">
288
+ Limited list — your RBAC doesn&rsquo;t allow listing all
289
+ namespaces. Other namespaces may be accessible but won&rsquo;t
290
+ appear here until you switch context.
291
+ </div>
292
+ )}
293
+ </div>,
294
+ document.body,
295
+ )}
296
+ </>
297
+ )
298
+ })
@@ -1,7 +1,9 @@
1
- import { useState, useEffect } from 'react'
1
+ import { useState, useEffect, useMemo } from 'react'
2
2
  import { X, Plus, Trash2 } from 'lucide-react'
3
+ import { clsx } from 'clsx'
3
4
  import { useAuditSettings, useUpdateAuditSettings, useAudit } from '../../api/client'
4
5
  import type { CheckMeta } from '@skyhook-io/k8s-ui'
6
+ import { validateRFC1123Label, type ValidationResult } from '@skyhook-io/k8s-ui/utils/validators'
5
7
 
6
8
  interface AuditSettingsDialogProps {
7
9
  namespaces: string[]
@@ -28,12 +30,23 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
28
30
  ? Object.values(auditData.checks).sort((a, b) => a.title.localeCompare(b.title))
29
31
  : []
30
32
 
33
+ // Validate the staged namespace input against RFC 1123. Saving a bogus
34
+ // entry would silently never match anything in the audit pipeline,
35
+ // leaving the user thinking the ignore filter doesn't work.
36
+ const newNsTrimmed = newNs.trim()
37
+ const newNsValidation = useMemo<ValidationResult>(
38
+ () => (newNsTrimmed === '' ? { valid: true } : validateRFC1123Label(newNsTrimmed)),
39
+ [newNsTrimmed],
40
+ )
41
+ const newNsError = newNsValidation.valid ? null : newNsValidation.error
42
+ const newNsDuplicate = newNsTrimmed !== '' && ignoredNs.includes(newNsTrimmed)
43
+ const canAddNamespace =
44
+ newNsTrimmed !== '' && newNsValidation.valid && !newNsDuplicate
45
+
31
46
  const addNamespace = () => {
32
- const ns = newNs.trim()
33
- if (ns && !ignoredNs.includes(ns)) {
34
- setIgnoredNs([...ignoredNs, ns])
35
- setNewNs('')
36
- }
47
+ if (!canAddNamespace) return
48
+ setIgnoredNs([...ignoredNs, newNsTrimmed])
49
+ setNewNs('')
37
50
  }
38
51
 
39
52
  const toggleCheck = (checkID: string) => {
@@ -95,16 +108,30 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
95
108
  onChange={e => setNewNs(e.target.value)}
96
109
  onKeyDown={e => { if (e.key === 'Enter') addNamespace() }}
97
110
  placeholder="Add namespace..."
98
- className="flex-1 px-3 py-1.5 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-skyhook-500"
111
+ aria-invalid={newNsError ? true : undefined}
112
+ aria-describedby="new-ns-help"
113
+ className={clsx(
114
+ 'flex-1 px-3 py-1.5 bg-theme-elevated border rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2',
115
+ newNsError || newNsDuplicate
116
+ ? 'border-red-500/60 focus:ring-red-500'
117
+ : 'border-theme-border-light focus:ring-skyhook-500',
118
+ )}
99
119
  />
100
120
  <button
101
121
  onClick={addNamespace}
102
- disabled={!newNs.trim()}
122
+ disabled={!canAddNamespace}
103
123
  className="px-3 py-1.5 text-sm btn-brand rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
104
124
  >
105
125
  <Plus className="w-4 h-4" />
106
126
  </button>
107
127
  </div>
128
+ {(newNsError || newNsDuplicate) && (
129
+ <p id="new-ns-help" className="mt-1.5 text-xs text-red-400">
130
+ {newNsDuplicate
131
+ ? `"${newNsTrimmed}" is already in the list.`
132
+ : `Namespace ${newNsError}.`}
133
+ </p>
134
+ )}
108
135
  </div>
109
136
 
110
137
  {/* Disabled Checks */}
@@ -150,8 +177,20 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
150
177
  </button>
151
178
  <button
152
179
  onClick={handleSave}
153
- disabled={updateSettings.isPending}
154
- className="px-4 py-1.5 text-sm btn-brand rounded-lg disabled:opacity-50"
180
+ // Block save while the namespace input has unfixed pending
181
+ // text otherwise the user clicks Save expecting their
182
+ // entry to be included and it's silently dropped.
183
+ disabled={
184
+ updateSettings.isPending || newNsError !== null || newNsDuplicate
185
+ }
186
+ title={
187
+ newNsError
188
+ ? 'Fix or clear the pending namespace input before saving'
189
+ : newNsDuplicate
190
+ ? 'Clear the duplicate pending input before saving'
191
+ : undefined
192
+ }
193
+ className="px-4 py-1.5 text-sm btn-brand rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
155
194
  >
156
195
  {updateSettings.isPending ? 'Saving...' : 'Save'}
157
196
  </button>
@@ -148,7 +148,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
148
148
  onClick={() => { setSelectedRepo('all'); setRepoDropdownOpen(false) }}
149
149
  className={clsx(
150
150
  'w-full px-3 py-2 text-left text-sm hover:bg-theme-hover flex items-center justify-between',
151
- selectedRepo === 'all' ? 'text-blue-400' : 'text-theme-text-primary'
151
+ selectedRepo === 'all' ? 'text-accent' : 'text-theme-text-primary'
152
152
  )}
153
153
  >
154
154
  <span>All Repositories</span>
@@ -186,9 +186,9 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
186
186
  type="checkbox"
187
187
  checked={showOfficialOnly}
188
188
  onChange={(e) => setShowOfficialOnly(e.target.checked)}
189
- className="rounded border-theme-border text-blue-500 focus:ring-blue-500"
189
+ className="rounded border-theme-border text-accent focus:ring-accent"
190
190
  />
191
- <BadgeCheck className="w-3.5 h-3.5 text-blue-400" />
191
+ <BadgeCheck className="w-3.5 h-3.5 text-accent" />
192
192
  Official
193
193
  </label>
194
194
  <label className="flex items-center gap-1.5 text-sm text-theme-text-secondary">
@@ -196,7 +196,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
196
196
  type="checkbox"
197
197
  checked={showVerifiedOnly}
198
198
  onChange={(e) => setShowVerifiedOnly(e.target.checked)}
199
- className="rounded border-theme-border text-blue-500 focus:ring-blue-500"
199
+ className="rounded border-theme-border text-accent focus:ring-accent"
200
200
  />
201
201
  <Shield className="w-3.5 h-3.5 text-green-400" />
202
202
  Verified
@@ -207,7 +207,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
207
207
  <select
208
208
  value={artifactHubSort}
209
209
  onChange={(e) => setArtifactHubSort(e.target.value as ArtifactHubSortOption)}
210
- className="bg-theme-elevated border border-theme-border-light rounded px-2 py-1 text-sm text-theme-text-primary focus:outline-none focus:ring-2 focus:ring-blue-500"
210
+ className="bg-theme-elevated border border-theme-border-light rounded px-2 py-1 text-sm text-theme-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
211
211
  >
212
212
  <option value="relevance">Relevance</option>
213
213
  <option value="stars">Stars</option>
@@ -225,7 +225,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
225
225
  placeholder={chartSource === 'local' ? "Search charts..." : "Search ArtifactHub..."}
226
226
  value={searchTerm}
227
227
  onChange={(e) => setSearchTerm(e.target.value)}
228
- className="w-full max-w-md pl-10 pr-4 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-blue-500"
228
+ className="w-full max-w-md pl-10 pr-4 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-accent"
229
229
  />
230
230
  </div>
231
231
 
@@ -237,7 +237,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
237
237
  type="checkbox"
238
238
  checked={showAllVersions}
239
239
  onChange={(e) => setShowAllVersions(e.target.checked)}
240
- className="rounded border-theme-border text-blue-500 focus:ring-blue-500"
240
+ className="rounded border-theme-border text-accent focus:ring-accent"
241
241
  />
242
242
  All versions
243
243
  </label>
@@ -276,7 +276,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
276
276
  Add repositories using <code className="bg-theme-elevated px-1 rounded">helm repo add</code>
277
277
  </p>
278
278
  <p className="mt-2">
279
- Or try searching on <button onClick={() => setChartSource('artifacthub')} className="text-blue-400 hover:underline">ArtifactHub</button>
279
+ Or try searching on <button onClick={() => setChartSource('artifacthub')} className="text-accent-text hover:underline">ArtifactHub</button>
280
280
  </p>
281
281
  </div>
282
282
  ) : (
@@ -375,7 +375,7 @@ function RepoDropdownItem({ repo, isSelected, onSelect, onUpdate, isUpdating, ca
375
375
  onClick={onSelect}
376
376
  className={clsx(
377
377
  'flex-1 text-left text-sm truncate',
378
- isSelected ? 'text-blue-400' : 'text-theme-text-primary'
378
+ isSelected ? 'text-accent' : 'text-theme-text-primary'
379
379
  )}
380
380
  >
381
381
  {repo.name}
@@ -413,7 +413,7 @@ function LocalChartCard({ chart, onSelect }: LocalChartCardProps) {
413
413
  <img
414
414
  src={chart.icon}
415
415
  alt=""
416
- className="w-10 h-10 rounded object-contain bg-white/10 p-1"
416
+ className="w-10 h-10 rounded object-contain bg-theme-elevated p-1"
417
417
  onError={(e) => {
418
418
  (e.target as HTMLImageElement).style.display = 'none'
419
419
  }}
@@ -476,7 +476,7 @@ function ArtifactHubChartCard({ chart, onSelect }: ArtifactHubChartCardProps) {
476
476
  <img
477
477
  src={chart.logoUrl}
478
478
  alt=""
479
- className="w-12 h-12 rounded object-contain bg-white/10 p-1 shrink-0"
479
+ className="w-12 h-12 rounded object-contain bg-theme-elevated p-1 shrink-0"
480
480
  onError={(e) => {
481
481
  (e.target as HTMLImageElement).style.display = 'none'
482
482
  }}