From 2410e6ed80cf1b567b64eb27a64a7a8125136802 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Tue, 4 Aug 2026 19:27:26 -0300 Subject: [PATCH 1/2] fix: workspace tab unread badge rendering and priority - Render exactly one badge per workspace tab, by priority: logged-out warning, then mention count, then unread dot. Previously a logged-out server with unread messages stacked two badges side by side. - Show the unread-without-mentions state as the same 16px ghost badge as the mention count, with a drawn 2px dot instead of a text glyph, sized to match the macOS dock badge bullet. - Remove the text-color override that referenced a nonexistent token (--rcx-color-font-white), which made badge content inherit the tab's dark text color instead of Fuselage's white. Same fix applied to the ServerSwitcher notification tint. - Centre-anchor the floated badges on a fixed point so every variant (dot, warning, wide count) lands on the same spot on vertical tabs. - Document why the ghost variant is used: its background flips to a light gray in the dark palette, keeping badges visible on the dark tab strip where the server's level-1 badge color would blend in. --- src/ui/components/TabBar/WorkspaceTab.tsx | 39 ++++++-- src/ui/components/TabBar/index.spec.tsx | 103 +++++++++++++++++++- src/ui/components/TabBar/styles.tsx | 55 ++++++++--- src/ui/components/TopBar/ServerSwitcher.tsx | 5 +- 4 files changed, 176 insertions(+), 26 deletions(-) diff --git a/src/ui/components/TabBar/WorkspaceTab.tsx b/src/ui/components/TabBar/WorkspaceTab.tsx index faf768421f..d3538eab98 100644 --- a/src/ui/components/TabBar/WorkspaceTab.tsx +++ b/src/ui/components/TabBar/WorkspaceTab.tsx @@ -1,4 +1,10 @@ -import type { DragEvent, FocusEvent, KeyboardEvent, MouseEvent } from 'react'; +import type { + DragEvent, + FocusEvent, + KeyboardEvent, + MouseEvent, + ReactNode, +} from 'react'; import { useContext, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; @@ -93,6 +99,7 @@ const WorkspaceTab = ({ const mentionCount = typeof badge === 'number' && badge > 0 ? badge : undefined; const displayCount = formatMentionCount(mentionCount); + const hasUnreadMessages = badge !== undefined && typeof badge !== 'number'; const shortcutSuffix = shortcutNumber && Number(shortcutNumber) >= 1 && Number(shortcutNumber) <= 9 @@ -104,7 +111,7 @@ const WorkspaceTab = ({ return ` — ${t('tabBar.unreadMessage', { count: mentionCount })}`; } - if (badge === '•') { + if (hasUnreadMessages) { return ` — ${t('tabBar.unreadMessages')}`; } @@ -164,13 +171,21 @@ const WorkspaceTab = ({ const isVertical = orientation === 'vertical'; const showLabel = !compact && !isVertical; - const badges = ( - <> - {displayCount && {displayCount}} - {!displayCount && badge === '•' && } - {!userLoggedIn && !} - - ); + // Exactly one badge at a time: a logged-out server's unread state is stale, + // so the login warning wins; otherwise a mention count beats the plain + // unread dot. + const badgeElement = ((): ReactNode => { + if (!userLoggedIn) { + return !; + } + if (displayCount) { + return {displayCount}; + } + if (hasUnreadMessages) { + return ; + } + return null; + })(); return ( <> @@ -212,7 +227,11 @@ const WorkspaceTab = ({ {showLabel && isShortcutVisible && shortcutNumber && ( {shortcutNumber} )} - {isVertical ? {badges} : badges} + {isVertical ? ( + {badgeElement} + ) : ( + badgeElement + )} diff --git a/src/ui/components/TabBar/index.spec.tsx b/src/ui/components/TabBar/index.spec.tsx index 7737d85d67..5e04c45bcb 100644 --- a/src/ui/components/TabBar/index.spec.tsx +++ b/src/ui/components/TabBar/index.spec.tsx @@ -151,7 +151,12 @@ describe('TabBar', () => { renderTabBar(, { preloadedState: buildState({ servers: [ - { url: 'https://a.rocket.chat/', title: 'Server A', badge: 150 }, + { + url: 'https://a.rocket.chat/', + title: 'Server A', + badge: 150, + userLoggedIn: true, + }, ], }), }); @@ -178,6 +183,44 @@ describe('TabBar', () => { expect(badges[0]).toHaveTextContent(''); }); + it('shows only the login warning when a logged-out server also has unread messages', () => { + const { container } = renderTabBar(, { + preloadedState: buildState({ + servers: [ + { + url: 'https://a.rocket.chat/', + title: 'Server A', + badge: '•', + userLoggedIn: false, + }, + ], + }), + }); + + const badges = container.querySelectorAll('.rcx-badge'); + expect(badges).toHaveLength(1); + expect(badges[0]).toHaveTextContent('!'); + }); + + it('shows the mention count instead of the unread dot when the badge is a number', () => { + const { container } = renderTabBar(, { + preloadedState: buildState({ + servers: [ + { + url: 'https://a.rocket.chat/', + title: 'Server A', + badge: 3, + userLoggedIn: true, + }, + ], + }), + }); + + const badges = container.querySelectorAll('.rcx-badge'); + expect(badges).toHaveLength(1); + expect(badges[0]).toHaveTextContent('3'); + }); + it('renders initials as a fallback when there is no favicon', () => { renderTabBar(, { preloadedState: buildState({ @@ -339,7 +382,12 @@ describe('TabBar', () => { renderTabBar(, { preloadedState: buildState({ servers: [ - { url: 'https://a.rocket.chat/', title: 'Server A', badge: 97 }, + { + url: 'https://a.rocket.chat/', + title: 'Server A', + badge: 97, + userLoggedIn: true, + }, ], }), }); @@ -384,12 +432,61 @@ describe('TabBar', () => { renderTabBar(, { preloadedState: buildState({ servers: [ - { url: 'https://a.rocket.chat/', title: 'Server A', badge: 5 }, + { + url: 'https://a.rocket.chat/', + title: 'Server A', + badge: 5, + userLoggedIn: true, + }, ], }), }); expect(screen.getByText('5')).toBeInTheDocument(); }); + + // Every badge — the unread '•' dot, the '!' warning, a wider mention + // count — must share one centre point so size and variant never change + // where the badge appears. An edge-anchored wrapper would let width leak + // into placement and shift narrower badges off the others' centre. + it('centres every badge on the same point regardless of size or variant', () => { + const getWrapperOffsets = (badgeProps: Record) => { + const { unmount } = renderTabBar(, { + preloadedState: buildState({ + servers: [ + { + url: 'https://a.rocket.chat/', + title: 'Server A', + ...badgeProps, + }, + ], + }), + }); + + const tab = screen.getByRole('tab'); + const wrapper = tab.querySelector('div'); + const style = wrapper ? getComputedStyle(wrapper) : null; + const offsets = { + top: style?.top, + left: style?.left, + }; + + unmount(); + return offsets; + }; + + const dot = getWrapperOffsets({ badge: '•', userLoggedIn: true }); + const warning = getWrapperOffsets({ userLoggedIn: false }); + const mentionCount = getWrapperOffsets({ badge: 42, userLoggedIn: true }); + + // The anchor is declared in px and paired with translate(-50%, -50%), so + // an identical top/left across variants means an identical centre. (jsdom + // resolves the percentage transform against each badge's measured box, so + // the declared anchor — not the computed matrix — is what to compare.) + expect(dot.top).toBe('4px'); + expect(dot.left).toBe('28px'); + expect(warning).toEqual(dot); + expect(mentionCount).toEqual(dot); + }); }); }); diff --git a/src/ui/components/TabBar/styles.tsx b/src/ui/components/TabBar/styles.tsx index 1b4813b158..f4803e764d 100644 --- a/src/ui/components/TabBar/styles.tsx +++ b/src/ui/components/TabBar/styles.tsx @@ -364,31 +364,62 @@ export const ShortcutChip = styled.span` opacity: 0.7; `; +/* Colors come from the Fuselage Badge variant tokens. The ghost variant is + deliberate: its background (stroke-dark) flips to a light gray in the dark + palette, keeping the badge visible on the dark tab strip, where the + server's secondary/level-1 badge color would blend in. Overriding the text + color here once broke the badge by referencing a token that does not + exist. */ export const TabBadge = styled(Badge)` flex-shrink: 0; - color: var(--rcx-color-font-white); box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.2); `; +/* Unread-without-mentions badge: same footprint as the count badge, with a + drawn dot instead of a text glyph. The dot matches the macOS dock badge + (dock.setBadge('•') renders its bullet at ~1/8 of the badge diameter → + 2px inside the 16px badge) and uses currentColor so it follows the + variant's font token. */ +export const UnreadDot = styled(TabBadge)` + align-items: center; + + &::before { + content: ''; + display: block; + width: 2px; + height: 2px; + border-radius: 50%; + background: currentColor; + } +`; + /* Floats the mention/warning badges over the top-right corner of a vertical - tab, matching the sidebar's ServerButton badge placement. */ + tab, matching the sidebar's ServerButton badge placement. + + Anchors the badge's CENTRE to a fixed point on the tab, so a badge of any + size or variant lands in the same visual spot. The anchor is the centre of + the standard 16px badge sitting on the corner, which is the reference + placement; a wider mention count then grows outward from that same centre + rather than shifting position. + + Anchoring an edge instead (e.g. right: 0) pins the wrapper's right edge and + lets content grow leftward, so badge width leaks into placement and a + narrower badge lands off-centre from a wider one. */ +const BADGE_ANCHOR_X = '28px'; +const BADGE_ANCHOR_Y = '4px'; + export const BadgeWrapper = styled.div` position: absolute; - top: 0; - right: 0; + top: ${BADGE_ANCHOR_Y}; + left: ${BADGE_ANCHOR_X}; display: flex; + align-items: center; + justify-content: center; gap: 2px; - transform: translate(30%, -30%); + transform: translate(-50%, -50%); pointer-events: none; `; -/* Small unread indicator for tabs that have unread messages but no mention - count (badge === '•'), in both horizontal and vertical layouts. */ -export const UnreadDot = styled(TabBadge)` - min-width: 8px; - min-height: 8px; -`; - export const WindowControlsGroup = styled.div` display: flex; flex-direction: row; diff --git a/src/ui/components/TopBar/ServerSwitcher.tsx b/src/ui/components/TopBar/ServerSwitcher.tsx index 6ada202d68..570fd28b0f 100644 --- a/src/ui/components/TopBar/ServerSwitcher.tsx +++ b/src/ui/components/TopBar/ServerSwitcher.tsx @@ -56,7 +56,10 @@ const Trigger = styled.button<{ hasNotification: boolean }>` --rcx-badge-colors-ghost-background-color, var(--rcx-color-stroke-dark, var(--rcx-color-neutral-700)) ); - color: var(--rcx-color-font-white); + color: var( + --rcx-badge-colors-ghost-color, + var(--rcx-color-font-pure-white, #ffffff) + ); `}; } `; From e98ba9fa3596c82726103e5f5545f2d55f5adfaa Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Wed, 5 Aug 2026 09:50:58 -0300 Subject: [PATCH 2/2] fix: keep the plain 8px unread ball on horizontal tabs The badge-with-drawn-dot unread indicator is meant for the sidebar layout. Horizontal tabs go back to the plain 8px ball, picked by orientation in WorkspaceTab. --- src/ui/components/TabBar/WorkspaceTab.tsx | 11 ++++++++-- src/ui/components/TabBar/index.spec.tsx | 26 +++++++++++++++++++++++ src/ui/components/TabBar/styles.tsx | 24 +++++++++++++++------ 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/ui/components/TabBar/WorkspaceTab.tsx b/src/ui/components/TabBar/WorkspaceTab.tsx index d3538eab98..7b5e685494 100644 --- a/src/ui/components/TabBar/WorkspaceTab.tsx +++ b/src/ui/components/TabBar/WorkspaceTab.tsx @@ -28,6 +28,7 @@ import { Tab, TabBadge, UnreadDot, + UnreadDotBadge, } from './styles'; import type { TabOrientation } from './styles'; @@ -173,7 +174,9 @@ const WorkspaceTab = ({ // Exactly one badge at a time: a logged-out server's unread state is stale, // so the login warning wins; otherwise a mention count beats the plain - // unread dot. + // unread dot. The unread indicator itself depends on the layout: the + // sidebar (vertical) gets the badge with the drawn dot, the tab strip + // keeps the plain 8px ball. const badgeElement = ((): ReactNode => { if (!userLoggedIn) { return !; @@ -182,7 +185,11 @@ const WorkspaceTab = ({ return {displayCount}; } if (hasUnreadMessages) { - return ; + return isVertical ? ( + + ) : ( + + ); } return null; })(); diff --git a/src/ui/components/TabBar/index.spec.tsx b/src/ui/components/TabBar/index.spec.tsx index 5e04c45bcb..cd1ea1b2c2 100644 --- a/src/ui/components/TabBar/index.spec.tsx +++ b/src/ui/components/TabBar/index.spec.tsx @@ -183,6 +183,32 @@ describe('TabBar', () => { expect(badges[0]).toHaveTextContent(''); }); + it('renders the unread indicator as the 8px ball in horizontal mode and the full badge in vertical mode', () => { + const server = { + url: 'https://a.rocket.chat/', + title: 'Server A', + badge: '•' as const, + userLoggedIn: true, + }; + + const horizontal = renderTabBar(, { + preloadedState: buildState({ servers: [server] }), + }); + const horizontalBadge = horizontal.container.querySelector('.rcx-badge'); + expect(horizontalBadge).not.toBeNull(); + expect(getComputedStyle(horizontalBadge as Element).width).toBe('8px'); + horizontal.unmount(); + + const vertical = renderTabBar(, { + preloadedState: buildState({ servers: [server] }), + }); + const verticalBadge = vertical.container.querySelector('.rcx-badge'); + expect(verticalBadge).not.toBeNull(); + // The vertical indicator keeps the standard badge box (Fuselage's own + // 1rem minimum, not loaded in this environment) instead of the 8px ball. + expect(getComputedStyle(verticalBadge as Element).width).not.toBe('8px'); + }); + it('shows only the login warning when a logged-out server also has unread messages', () => { const { container } = renderTabBar(, { preloadedState: buildState({ diff --git a/src/ui/components/TabBar/styles.tsx b/src/ui/components/TabBar/styles.tsx index f4803e764d..f5a89227d2 100644 --- a/src/ui/components/TabBar/styles.tsx +++ b/src/ui/components/TabBar/styles.tsx @@ -375,12 +375,12 @@ export const TabBadge = styled(Badge)` box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.2); `; -/* Unread-without-mentions badge: same footprint as the count badge, with a - drawn dot instead of a text glyph. The dot matches the macOS dock badge - (dock.setBadge('•') renders its bullet at ~1/8 of the badge diameter → - 2px inside the 16px badge) and uses currentColor so it follows the - variant's font token. */ -export const UnreadDot = styled(TabBadge)` +/* Unread-without-mentions indicator for vertical (sidebar) tabs: same + footprint as the count badge, with a drawn dot instead of a text glyph. + The dot matches the macOS dock badge (dock.setBadge('•') renders its + bullet at ~1/8 of the badge diameter → 2px inside the 16px badge) and + uses currentColor so it follows the variant's font token. */ +export const UnreadDotBadge = styled(TabBadge)` align-items: center; &::before { @@ -393,6 +393,18 @@ export const UnreadDot = styled(TabBadge)` } `; +/* Unread-without-mentions indicator for horizontal tabs: a plain 8px ball. + Badge ships min-width/min-height of 1rem plus 2px/4px padding; without + resetting the padding and the fixed box, an 8px dot renders as a 16x8 + pill. */ +export const UnreadDot = styled(TabBadge)` + width: 8px; + min-width: 8px; + height: 8px; + min-height: 8px; + padding: 0; +`; + /* Floats the mention/warning badges over the top-right corner of a vertical tab, matching the sidebar's ServerButton badge placement.