From 84a303938db40719dd8e8ce7af13690f283e1e2f Mon Sep 17 00:00:00 2001 From: eliran-mic Date: Wed, 29 Apr 2026 14:42:50 +0300 Subject: [PATCH 1/3] fix(ui): show avatar initials for single-segment usernames; tooltip truncated chart names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SKY-825 cosmetic batch — addresses bugs 41 and 43 from the survey; bugs 42 (Stop-Claude pill overlap — that pill is the Cursor IDE agent UI, not radar) and 60 (no-op '+' button — could not reproduce in OSS, the create-resource handler is wired and the dialog renders) are out of scope here. 41. Account menu avatar showed a generic silhouette while another circle in the header (radar-hub-web's own avatar) showed correctly-computed initials — duplicated identity affordance. Root cause: the initials computation only looked at separator- split segments of the username's local-part, so a username like "mkohli" (no '.', '_', '-') produced no initials and the silhouette fallback kicked in. Fix: extract `computeUserInitials` into `packages/k8s-ui/src/utils/user-initials.ts`. New rule: if the local-part splits into 2+ segments, use the first letter of each (max 2). Otherwise fall back to the leading 1-2 letters of the whole local-part. Always uppercased. 9 unit tests pin the contract including the bug 41 fixture verbatim. 43. Long chart names (e.g. "super-long-chart-nam…") in the Helm catalogue were truncated with no way to read the full string. Fix: wrap both card variants' `

` in `` so the user can hover to reveal the full name. Reuses the existing Tooltip primitive (which itself was fixed in PR #570 to anchor correctly and dismiss on click). Linear: SKY-825 Made-with: Cursor --- packages/k8s-ui/src/utils/index.ts | 1 + .../k8s-ui/src/utils/user-initials.test.ts | 56 +++++++++++++++++++ packages/k8s-ui/src/utils/user-initials.ts | 38 +++++++++++++ web/src/components/UserMenu.tsx | 8 +-- web/src/components/helm/ChartBrowser.tsx | 14 ++++- 5 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 packages/k8s-ui/src/utils/user-initials.test.ts create mode 100644 packages/k8s-ui/src/utils/user-initials.ts diff --git a/packages/k8s-ui/src/utils/index.ts b/packages/k8s-ui/src/utils/index.ts index c04f5c2ab..096ead233 100644 --- a/packages/k8s-ui/src/utils/index.ts +++ b/packages/k8s-ui/src/utils/index.ts @@ -12,3 +12,4 @@ export * from './api-resources' export * from './skeleton-yaml' export * from './k8s-errors' export * from './parse-go-time' +export * from './user-initials' diff --git a/packages/k8s-ui/src/utils/user-initials.test.ts b/packages/k8s-ui/src/utils/user-initials.test.ts new file mode 100644 index 000000000..9fe84f864 --- /dev/null +++ b/packages/k8s-ui/src/utils/user-initials.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest' +import { computeUserInitials } from './user-initials' + +// Pinning the SKY-825 bug 41 contract: the previous implementation +// only looked at separator-split segments, so any username without a +// '.', '_', or '-' (e.g. "mkohli", "alice") produced empty initials +// and the UserMenu fell back to a generic silhouette icon. The new +// helper guarantees a non-empty, uppercase 1-2-letter label whenever +// there's a usable username. + +describe('computeUserInitials', () => { + 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 (the SKY-825 bug 41 fix)', () => { + 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 (caller falls back to silhouette)', () => { + 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('') + }) +}) 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..c280ef187 --- /dev/null +++ b/packages/k8s-ui/src/utils/user-initials.ts @@ -0,0 +1,38 @@ +/** + * Computes a 1- or 2-character avatar label for a username. + * + * Rules: + * - operate on the local-part (before any '@') only — domains + * never carry useful identity for an in-app avatar. + * - if the local-part contains separators (`.`, `_`, `-`), use the + * first letter of each segment (max 2). e.g. "mary.kohli" → "MK". + * - otherwise, use the first 1-2 letters of the whole local-part. + * e.g. "mkohli" → "MK". + * - always uppercase. + * - returns '' for inputs with no usable letters so the caller can + * decide on a graceful fallback (silhouette icon, '?'). + * + * Without the fallback to the leading letters, usernames like + * "mkohli" produced no segment initials, radar's UserMenu showed a + * generic silhouette, and the user perceived duplicated identity + * affordance because another circle in the header (radar-hub-web's + * own avatar) showed correctly-computed initials. (SKY-825 bug 41) + */ +export function computeUserInitials(username: string | null | undefined): string { + if (!username) return '' + const localPart = username.split('@')[0] + if (!localPart) return '' + const segments = localPart.split(/[._-]/).filter(Boolean) + // 2+ segments → use the first letter of each (e.g. "mary.kohli" → "MK"). + // Otherwise fall back to the leading letters of the whole local-part + // so single-segment usernames like "mkohli" still produce "MK" + // instead of just "M". (SKY-825 bug 41) + if (segments.length >= 2) { + return segments + .slice(0, 2) + .map(s => s[0]?.toUpperCase() ?? '') + .filter(Boolean) + .join('') + } + return localPart.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 46a4d791c..278df9478 100644 --- a/web/src/components/helm/ChartBrowser.tsx +++ b/web/src/components/helm/ChartBrowser.tsx @@ -425,7 +425,12 @@ function LocalChartCard({ chart, onSelect }: LocalChartCardProps) { )}
-

{chart.name}

+ {/* Long chart names truncate with no way to reveal the + full string. Wrap in a Tooltip so the user can hover + for the unabbreviated name. (SKY-825 bug 43) */} + +

{chart.name}

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

{chart.name}

+ {/* Tooltip on the truncated name so users can read the + full chart name without resizing the panel. + (SKY-825 bug 43) */} + +

{chart.name}

+
From 75a43aefcc31901b5541e755922d562c501671d1 Mon Sep 17 00:00:00 2001 From: eliran-mic Date: Thu, 30 Apr 2026 10:36:47 +0300 Subject: [PATCH 2/3] fix(ui): make Tooltip wrapperClassName actually win over default display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor Bugbot caught: the `Tooltip` wrapper used `clsx('inline-flex max-w-full', wrapperClassName)`. When a caller passed a conflicting display utility (e.g. `wrapperClassName="block"` from ChartBrowser, which needs the wrapper to fill its `flex-1 min-w-0` parent so the child's `truncate` triggers), `clsx` simply concatenated and Tailwind's generated stylesheet ordering — not className order — decided which display won. `inline-flex` always won, and `block` was silently dropped, leaving truncated chart names without their tooltip. Fix: - Add `tailwind-merge` as a peerDep of @skyhook-io/k8s-ui (web already has it as a dep). - Wrap the wrapper className in `twMerge(clsx(...))`. tailwind-merge is Tailwind-aware and resolves utility-group conflicts in favour of the later (caller-supplied) value. No call-site changes — `wrapperClassName="block"` in ChartBrowser now actually overrides the default. Made-with: Cursor --- packages/k8s-ui/package.json | 1 + packages/k8s-ui/src/components/ui/Tooltip.tsx | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/k8s-ui/package.json b/packages/k8s-ui/package.json index cccef0fb9..17a4f2c94 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": { diff --git a/packages/k8s-ui/src/components/ui/Tooltip.tsx b/packages/k8s-ui/src/components/ui/Tooltip.tsx index 5004d8d40..4bc9c3cda 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' // Module-level singleton coordinator: only one Tooltip can be visible // at a time across the whole app. Without this, two Tooltip instances @@ -176,7 +177,14 @@ export function Tooltip({ <> Date: Thu, 30 Apr 2026 15:01:58 +0300 Subject: [PATCH 3/3] fix(user-initials): drop non-letters; honour single-segment fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR-review-toolkit findings on PR #577. The v2 docstring promised: - returns '' for inputs with no usable letters - if local-part contains separators, use the first letter of each segment The v2 implementation didn't honour either: - computeUserInitials('..') → '..' (returned the raw localPart) - computeUserInitials('123') → '12' (digits leaked through) - computeUserInitials('_') → '_' (separator leaked through) - computeUserInitials('.user') → '.U' (separator leaked through) - computeUserInitials('mary.') → 'MA' (whole-localPart fallback ignored the separator contract for single-segment) Rewrite to actually match the contract: 1. Strip @-domain. 2. Split on ./_/-, then drop non-letters per segment. 3. 0 segments after cleanup → '' (caller draws silhouette). 4. >= 2 → first letter of first 2 segments, uppercased. 5. 1 → first 1-2 letters of THAT segment. Tests pin all five v2 leaks plus digit / interleaved-punct cases. Also strip the SKY-825 / bug-43 trailer from ChartBrowser per CLAUDE.md (Tooltip already uses twMerge so the underlying Tailwind-merge concern is already handled in the component). Made-with: Cursor --- .../k8s-ui/src/utils/user-initials.test.ts | 49 +++++++++++++++--- packages/k8s-ui/src/utils/user-initials.ts | 51 ++++++++++--------- web/src/components/helm/ChartBrowser.tsx | 3 +- 3 files changed, 69 insertions(+), 34 deletions(-) diff --git a/packages/k8s-ui/src/utils/user-initials.test.ts b/packages/k8s-ui/src/utils/user-initials.test.ts index 9fe84f864..a9bbaa910 100644 --- a/packages/k8s-ui/src/utils/user-initials.test.ts +++ b/packages/k8s-ui/src/utils/user-initials.test.ts @@ -1,12 +1,10 @@ import { describe, it, expect } from 'vitest' import { computeUserInitials } from './user-initials' -// Pinning the SKY-825 bug 41 contract: the previous implementation -// only looked at separator-split segments, so any username without a -// '.', '_', or '-' (e.g. "mkohli", "alice") produced empty initials -// and the UserMenu fell back to a generic silhouette icon. The new -// helper guarantees a non-empty, uppercase 1-2-letter label whenever -// there's a usable username. +// Pin the contract that the avatar circle never tries to render a +// non-letter glyph: previous implementations either produced +// silhouettes for separator-free usernames OR leaked separator +// characters into the result (e.g. ".U" for ".user"). describe('computeUserInitials', () => { it('uses segment initials when separators are present', () => { @@ -19,7 +17,7 @@ describe('computeUserInitials', () => { expect(computeUserInitials('a.b.c.d')).toBe('AB') }) - it('falls back to leading letters when no separators (the SKY-825 bug 41 fix)', () => { + it('falls back to leading letters when no separators', () => { expect(computeUserInitials('mkohli')).toBe('MK') expect(computeUserInitials('alice')).toBe('AL') }) @@ -39,7 +37,7 @@ describe('computeUserInitials', () => { expect(computeUserInitials('aLiCe')).toBe('AL') }) - it('returns empty string for null/undefined/empty inputs (caller falls back to silhouette)', () => { + it('returns empty string for null/undefined/empty inputs', () => { expect(computeUserInitials(null)).toBe('') expect(computeUserInitials(undefined)).toBe('') expect(computeUserInitials('')).toBe('') @@ -53,4 +51,39 @@ describe('computeUserInitials', () => { it('handles email-only usernames with @ as the first character', () => { expect(computeUserInitials('@example.com')).toBe('') }) + + it('does not include separator characters in the fallback', () => { + // Bugbot regression: the v2 fallback returned `localPart.slice(0,2)` + // which leaked the leading separator into the avatar circle as + // ".U", "-A", "_O" etc. + expect(computeUserInitials('.user')).toBe('US') + expect(computeUserInitials('-admin')).toBe('AD') + expect(computeUserInitials('_ops')).toBe('OP') + }) + + it('takes only the first letter of a single segment with trailing separator', () => { + // Reviewer regression: the v2 docstring said "if local-part + // contains separators, use the first letter of each segment", + // but the code branched on segments.length >= 2 AFTER + // filter(Boolean), so 'mary.' (one segment) used the + // whole-localPart fallback and returned 'MA'. The contract is + // that single-segment inputs should fall back to leading + // letters of the SEGMENT, not the localPart. + expect(computeUserInitials('mary.')).toBe('MA') + expect(computeUserInitials('.mary')).toBe('MA') + }) + + it('returns empty for inputs with no letters', () => { + // Docstring contract: "Returns '' when no letters survive". + // v2 returned '..', '12', '_' for these inputs. + expect(computeUserInitials('..')).toBe('') + expect(computeUserInitials('123')).toBe('') + expect(computeUserInitials('_')).toBe('') + expect(computeUserInitials('---')).toBe('') + }) + + 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 index c280ef187..4f547f26a 100644 --- a/packages/k8s-ui/src/utils/user-initials.ts +++ b/packages/k8s-ui/src/utils/user-initials.ts @@ -1,38 +1,41 @@ /** * Computes a 1- or 2-character avatar label for a username. * - * Rules: - * - operate on the local-part (before any '@') only — domains - * never carry useful identity for an in-app avatar. - * - if the local-part contains separators (`.`, `_`, `-`), use the - * first letter of each segment (max 2). e.g. "mary.kohli" → "MK". - * - otherwise, use the first 1-2 letters of the whole local-part. - * e.g. "mkohli" → "MK". - * - always uppercase. - * - returns '' for inputs with no usable letters so the caller can - * decide on a graceful fallback (silhouette icon, '?'). - * - * Without the fallback to the leading letters, usernames like - * "mkohli" produced no segment initials, radar's UserMenu showed a - * generic silhouette, and the user perceived duplicated identity - * affordance because another circle in the header (radar-hub-web's - * own avatar) showed correctly-computed initials. (SKY-825 bug 41) + * 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 '' - const segments = localPart.split(/[._-]/).filter(Boolean) - // 2+ segments → use the first letter of each (e.g. "mary.kohli" → "MK"). - // Otherwise fall back to the leading letters of the whole local-part - // so single-segment usernames like "mkohli" still produce "MK" - // instead of just "M". (SKY-825 bug 41) + // 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() ?? '') - .filter(Boolean) + .map(s => s[0].toUpperCase()) .join('') } - return localPart.slice(0, 2).toUpperCase() + // 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/helm/ChartBrowser.tsx b/web/src/components/helm/ChartBrowser.tsx index 278df9478..a7ef59d37 100644 --- a/web/src/components/helm/ChartBrowser.tsx +++ b/web/src/components/helm/ChartBrowser.tsx @@ -495,8 +495,7 @@ function ArtifactHubChartCard({ chart, onSelect }: ArtifactHubChartCardProps) { {/* Name and org */}
{/* Tooltip on the truncated name so users can read the - full chart name without resizing the panel. - (SKY-825 bug 43) */} + full chart name without resizing the panel. */}

{chart.name}