diff --git a/packages/k8s-ui/package.json b/packages/k8s-ui/package.json index 18cdad4a0..563dc9aac 100644 --- a/packages/k8s-ui/package.json +++ b/packages/k8s-ui/package.json @@ -72,6 +72,7 @@ "lucide-react": ">=0.400.0", "react": ">=18.0.0", "react-dom": ">=18.0.0", + "tailwind-merge": ">=2", "yaml": ">=2.0.0" }, "devDependencies": { @@ -88,6 +89,7 @@ "lucide-react": "^1.12.0", "react": "^19.2.4", "react-dom": "^19.2.4", + "tailwind-merge": "^3.5.0", "typescript": "^6.0.2", "vitest": "^4.1.5", "yaml": "^2.8.3" diff --git a/packages/k8s-ui/src/components/ui/Tooltip.test.tsx b/packages/k8s-ui/src/components/ui/Tooltip.test.tsx new file mode 100644 index 000000000..0f48069e2 --- /dev/null +++ b/packages/k8s-ui/src/components/ui/Tooltip.test.tsx @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { renderToString } from 'react-dom/server' +import { Tooltip } from './Tooltip' + +// Pins the wrapper-className merge contract: a caller passing +// `wrapperClassName="block"` MUST override the default `inline-flex` +// on the trigger span. Without `twMerge`, plain `clsx` concatenation +// emits both `inline-flex` and `block`, and stylesheet ordering picks +// the wrong one — breaks ChartBrowser's truncation flow because the +// wrapper stays `inline-flex` and the child `truncate` never engages. +describe('Tooltip wrapper className', () => { + it('lets the caller override the default display utility via twMerge', () => { + const html = renderToString( + + child + , + ) + // Caller wins on the display group — twMerge drops the conflicting + // default. Assert each class independently; emit order is a + // twMerge implementation detail and varies across versions. + expect(html).toContain('block') + expect(html).toContain('max-w-full') + expect(html).not.toContain('inline-flex') + }) + + it('keeps the default display utility when no caller override is supplied', () => { + const html = renderToString( + + child + , + ) + expect(html).toContain('inline-flex max-w-full') + }) + + it('merges arbitrary non-conflicting utilities from the caller alongside the defaults', () => { + const html = renderToString( + + child + , + ) + expect(html).toContain('inline-flex') + expect(html).toContain('max-w-full') + expect(html).toContain('min-w-0') + expect(html).toContain('flex-1') + }) +}) diff --git a/packages/k8s-ui/src/components/ui/Tooltip.tsx b/packages/k8s-ui/src/components/ui/Tooltip.tsx index d55bcaf5e..8aef7b5ab 100644 --- a/packages/k8s-ui/src/components/ui/Tooltip.tsx +++ b/packages/k8s-ui/src/components/ui/Tooltip.tsx @@ -1,6 +1,7 @@ import { ReactNode, useState, useRef, useEffect, useCallback } from 'react' import { createPortal } from 'react-dom' import { clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' import { computeTooltipPosition } from './tooltip-position' // Module-level singleton coordinator: only one Tooltip can be visible @@ -199,7 +200,14 @@ export function Tooltip({ <> { + it('uses segment initials when separators are present', () => { + expect(computeUserInitials('mary.kohli')).toBe('MK') + expect(computeUserInitials('mary_kohli')).toBe('MK') + expect(computeUserInitials('mary-kohli')).toBe('MK') + }) + + it('caps segment initials at 2 even with many separators', () => { + expect(computeUserInitials('a.b.c.d')).toBe('AB') + }) + + it('falls back to leading letters when no separators', () => { + expect(computeUserInitials('mkohli')).toBe('MK') + expect(computeUserInitials('alice')).toBe('AL') + }) + + it('returns a single letter for single-character usernames', () => { + expect(computeUserInitials('a')).toBe('A') + }) + + it('strips the @-domain before computing', () => { + expect(computeUserInitials('mary.kohli@example.com')).toBe('MK') + expect(computeUserInitials('mkohli@example.com')).toBe('MK') + }) + + it('uppercases the result', () => { + expect(computeUserInitials('alice')).toBe('AL') + expect(computeUserInitials('ALICE')).toBe('AL') + expect(computeUserInitials('aLiCe')).toBe('AL') + }) + + it('returns empty string for null/undefined/empty inputs', () => { + expect(computeUserInitials(null)).toBe('') + expect(computeUserInitials(undefined)).toBe('') + expect(computeUserInitials('')).toBe('') + }) + + it('handles consecutive separators without producing empty segments', () => { + expect(computeUserInitials('mary..kohli')).toBe('MK') + expect(computeUserInitials('mary__kohli')).toBe('MK') + }) + + it('handles email-only usernames with @ as the first character', () => { + expect(computeUserInitials('@example.com')).toBe('') + }) + + it('does not include separator characters in the fallback', () => { + // Leading/trailing separators must not leak into the avatar + // circle as ".U", "-A", "_O" — non-letters get filtered before + // the slice, not after. + expect(computeUserInitials('.user')).toBe('US') + expect(computeUserInitials('-admin')).toBe('AD') + expect(computeUserInitials('_ops')).toBe('OP') + }) + + it('takes leading letters of the segment, not the whole localPart, for trailing/leading separator inputs', () => { + // Single-segment inputs must fall back to leading letters of + // the SEGMENT, not the raw localPart — otherwise `'mary.'` + // returns `'MA'` from the wrong slice and inconsistency with + // `'mary.kohli'` shows up only on partial inputs. + expect(computeUserInitials('mary.')).toBe('MA') + expect(computeUserInitials('.mary')).toBe('MA') + }) + + it('returns empty for inputs with no letters', () => { + // The avatar circle has no glyph to render for separator-only, + // digit-only, or punctuation-only inputs — return `''` so the + // caller can fall back to a silhouette. + expect(computeUserInitials('..')).toBe('') + expect(computeUserInitials('123')).toBe('') + expect(computeUserInitials('_')).toBe('') + expect(computeUserInitials('---')).toBe('') + }) + + it('drops leading whitespace before computing initials', () => { + // Leading whitespace must not leak into the avatar circle as + // a pair of blank glyphs — the truthy `' '` would defeat the + // caller's `{initials || }` fallback. + expect(computeUserInitials(' alice')).toBe('AL') + expect(computeUserInitials('\talice')).toBe('AL') + }) + + it('skips digits and punctuation interleaved with letters', () => { + expect(computeUserInitials('m1k')).toBe('MK') + expect(computeUserInitials('a$b$c')).toBe('AB') + }) +}) diff --git a/packages/k8s-ui/src/utils/user-initials.ts b/packages/k8s-ui/src/utils/user-initials.ts new file mode 100644 index 000000000..4f547f26a --- /dev/null +++ b/packages/k8s-ui/src/utils/user-initials.ts @@ -0,0 +1,41 @@ +/** + * Computes a 1- or 2-character avatar label for a username. + * + * Rules (in order): + * 1. Strip the @-domain from the local-part — domains never carry + * useful identity for an in-app avatar. + * 2. Drop everything that isn't a letter (separators like `.`, + * `_`, `-`, digits, punctuation). The avatar circle can only + * render a meaningful glyph for letters; rendering `.U` or + * `12` looks broken. + * 3. If the cleaned local-part contains separator-bounded + * segments (`.`, `_`, `-`), use the first letter of each + * segment (max 2). e.g. `"mary.kohli"` → `"MK"`. + * 4. Otherwise use the first 1-2 letters of the cleaned + * local-part. e.g. `"mkohli"` → `"MK"`. + * 5. Always uppercase. + * 6. Returns `''` when no letters survive — the caller falls back + * to a silhouette / `?` icon. + */ +export function computeUserInitials(username: string | null | undefined): string { + if (!username) return '' + const localPart = username.split('@')[0] + if (!localPart) return '' + // Split on the canonical separators first so segment-based + // initials still work, then drop any non-letter characters per + // segment so leading punctuation can't leak into the result. + const segments = localPart + .split(/[._-]/) + .map(s => s.replace(/[^a-zA-Z]/g, '')) + .filter(Boolean) + if (segments.length === 0) return '' + if (segments.length >= 2) { + return segments + .slice(0, 2) + .map(s => s[0].toUpperCase()) + .join('') + } + // Single segment (no usable separators) — surface up to two + // leading letters so e.g. `mkohli` produces `MK` instead of `M`. + return segments[0].slice(0, 2).toUpperCase() +} diff --git a/web/src/components/UserMenu.tsx b/web/src/components/UserMenu.tsx index ece485dae..cf10513ae 100644 --- a/web/src/components/UserMenu.tsx +++ b/web/src/components/UserMenu.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback } from 'react' import { User, LogOut } from 'lucide-react' import { useAuthMe } from '../api/client' import { useQueryClient } from '@tanstack/react-query' +import { computeUserInitials } from '@skyhook-io/k8s-ui/utils/user-initials' export function UserMenu() { const { data: authMe } = useAuthMe() @@ -40,12 +41,7 @@ export function UserMenu() { return null } - const initials = authMe.username - .split('@')[0] - .split(/[._-]/) - .slice(0, 2) - .map(s => s[0]?.toUpperCase() || '') - .join('') + const initials = computeUserInitials(authMe.username) return (
diff --git a/web/src/components/helm/ChartBrowser.tsx b/web/src/components/helm/ChartBrowser.tsx index 784872149..333d4742f 100644 --- a/web/src/components/helm/ChartBrowser.tsx +++ b/web/src/components/helm/ChartBrowser.tsx @@ -425,7 +425,9 @@ function LocalChartCard({ chart, onSelect }: LocalChartCardProps) { )}
-

{chart.name}

+ +

{chart.name}

+
{chart.deprecated && ( deprecated @@ -489,7 +491,9 @@ function ArtifactHubChartCard({ chart, onSelect }: ArtifactHubChartCardProps) { {/* Name and org */}
-

{chart.name}

+ +

{chart.name}

+