fix(sidebar): align credits chip with panel toggle and square the collapsed rail (#6691)

* fix(sidebar): align credits chip with panel toggle and square the collapsed rail

* fix(sidebar): center collapsed rail chips in the rail

* fix(sidebar): shrink collapsed rail to 48px so chips center without shifting on toggle
This commit is contained in:
Justin Blumencranz
2026-08-13 20:01:52 -07:00
committed by GitHub
parent 9aa36a424f
commit c6047efc4e
11 changed files with 115 additions and 54 deletions
+1 -1
View File
@@ -11,7 +11,7 @@
*/
:root {
--sidebar-width: 0px; /* 0 outside workspace; blocking script always sets actual value on workspace pages */
--sidebar-collapsed-width: 51px; /* icon rail on web; desktop overrides to 0 before first paint */
--sidebar-collapsed-width: 48px; /* icon rail on web; desktop overrides to 0 before first paint */
--sidebar-expanded-width: 238px; /* SIDEBAR_WIDTH.DEFAULT; the width to restore to, held even while collapsed */
--desktop-title-bar-height: 0px; /* macOS traffic-light lane; desktop overrides before first paint */
--workspace-content-title-bar-inset: 0px; /* lane the content pane must leave clear; only non-zero when the pane, not the sidebar, sits under it */
+1 -1
View File
@@ -75,7 +75,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
// The macOS desktop shell overlays native traffic lights on the
// workspace. Mark it before first paint so the sidebar reserves
// its inset title-bar lane without a post-hydration layout shift.
var collapsedSidebarWidth = 51;
var collapsedSidebarWidth = 48;
try {
if (window.simDesktop && /Mac/i.test(navigator.userAgent)) {
document.documentElement.setAttribute('data-sim-desktop-title-bar', 'inset');
@@ -7,13 +7,18 @@ export const RESOURCE_TAB_ICON_CLASS = 'size-[16px] text-[var(--text-icon)]'
/** Shared geometry for the resource header and controls positioned over it. */
export const RESOURCE_HEADER_CLASSES = {
layout:
'[--resource-header-controls-height:43px] [--resource-header-end-inset:16px] [--resource-header-fixed-reserve:54px]',
'[--resource-header-controls-height:43px] [--resource-header-end-inset:16px] [--resource-header-fixed-reserve:54px] [--resource-header-toggle-size:30px]',
bar: 'h-[calc(var(--resource-header-controls-height)_+_1px)]',
controls: 'h-[var(--resource-header-controls-height)]',
contentTop: 'top-[8.5px]',
overlay: 'absolute top-0 flex h-[var(--resource-header-controls-height)] items-center',
startPadding: 'pl-[var(--resource-header-end-inset)]',
endPadding: 'pr-[var(--resource-header-fixed-reserve)]',
endPosition: 'right-[var(--resource-header-end-inset)]',
adjacentEndPosition: 'right-[var(--resource-header-fixed-reserve)]',
/**
* Sits a control 1px clear of the overlaid 30px collapse toggle the same
* chip-to-chip gap the sidebar header cluster uses (`gap-[1px]`), so the
* credits chip and the toggle read as one cluster across both surfaces.
*/
adjacentEndPosition:
'right-[calc(var(--resource-header-end-inset)_+_var(--resource-header-toggle-size)_+_1px)]',
emptyAddOffset: '-translate-x-1.5',
} as const
@@ -283,21 +283,32 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
setIsResourceCollapsed(true)
}, [clearWidth])
const clearResourceActivity = useCallback((resourceId: string) => {
setResourceActivityIds((current) => {
if (!current.has(resourceId)) return current
const next = new Set(current)
next.delete(resourceId)
return next
})
}, [])
const expandResource = () => {
userOwnsResourceViewRef.current = true
const activeResourceId = activeResourceParamRef.current
if (activeResourceId) clearResourceActivity(activeResourceId)
setIsResourceCollapsed(false)
}
const selectResourceFromUser = useCallback(
(resourceId: string) => {
userOwnsResourceViewRef.current = true
setResourceActivityIds((current) => {
if (!current.has(resourceId)) return current
const next = new Set(current)
next.delete(resourceId)
return next
})
clearResourceActivity(resourceId)
if (effectiveActiveResourceIdRef.current === resourceId) return
effectiveActiveResourceIdRef.current = resourceId
activeResourceParamRef.current = resourceId
setActiveResourceId(resourceId)
},
[setActiveResourceId]
[setActiveResourceId, clearResourceActivity]
)
const addResourceFromUser = useCallback(
@@ -581,9 +592,17 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
{showEmptyState && (
<div
className={cn(
'absolute z-10',
RESOURCE_HEADER_CLASSES.contentTop,
RESOURCE_HEADER_CLASSES.adjacentEndPosition
'z-10',
RESOURCE_HEADER_CLASSES.overlay,
// Collapsed, the expand toggle overlays this corner, so the chip
// yields the fixed reserve; open, the toggle lives in the panel's
// corner and the chip takes the standard end inset itself.
isResourceCollapsed
? RESOURCE_HEADER_CLASSES.adjacentEndPosition
: RESOURCE_HEADER_CLASSES.endPosition,
skipResourceTransition
? 'transition-none'
: 'transition-[right] duration-200 [transition-timing-function:cubic-bezier(0.25,0.1,0.25,1)]'
)}
>
<CreditsChip />
@@ -690,34 +709,14 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps)
</MothershipResourcesProvider>
<div
className={cn(
'absolute top-0 z-30 flex items-center',
RESOURCE_HEADER_CLASSES.controls,
RESOURCE_HEADER_CLASSES.endPosition
)}
className={cn('z-30', RESOURCE_HEADER_CLASSES.overlay, RESOURCE_HEADER_CLASSES.endPosition)}
>
<Button
variant='ghost'
size={null}
type='button'
onClick={
isResourceCollapsed
? () => {
userOwnsResourceViewRef.current = true
const activeResourceId = activeResourceParamRef.current
if (activeResourceId) {
setResourceActivityIds((current) => {
if (!current.has(activeResourceId)) return current
const next = new Set(current)
next.delete(activeResourceId)
return next
})
}
setIsResourceCollapsed(false)
}
: collapseResource
}
className='size-[30px] rounded-[8px] hover-hover:bg-[var(--surface-active)]'
onClick={isResourceCollapsed ? expandResource : collapseResource}
className='size-[var(--resource-header-toggle-size)] rounded-[8px] hover-hover:bg-[var(--surface-active)]'
aria-label={isResourceCollapsed ? 'Expand resource view' : 'Collapse resource view'}
>
<span className='relative'>
@@ -16,6 +16,7 @@ import { File, Folder, MoreHorizontal, Pencil, Plus, SquareArrowUpRight } from '
import Link from 'next/link'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
import { SIDEBAR_RAIL_CHIP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { interleaveSiblings } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
import type { WorkspaceFileFolderApi } from '@/hooks/queries/workspace-file-folders'
@@ -209,7 +210,7 @@ export function CollapsedSidebarMenu({
<button
type='button'
aria-label={ariaLabel}
className={chipVariants({ fullWidth: true })}
className={cn(chipVariants({ fullWidth: true }), SIDEBAR_RAIL_CHIP_CLASS)}
>
{icon}
</button>
@@ -31,6 +31,7 @@ import {
SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
SIDEBAR_ITEM_GAP_CLASS,
SIDEBAR_RAIL_CHIP_CLASS,
SIDEBAR_SECTION_GAP_CLASS,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
@@ -305,7 +306,11 @@ export function SettingsSidebar({
)}
>
<SidebarTooltip label='Back' enabled={showCollapsedTooltips}>
<button type='button' onClick={handleBack} className={chipVariants({ fullWidth: true })}>
<button
type='button'
onClick={handleBack}
className={cn(chipVariants({ fullWidth: true }), SIDEBAR_RAIL_CHIP_CLASS)}
>
{/* The 16px slot every settings row gives its icon, so Back's label starts on their baseline. */}
<span aria-hidden className={cn(chipIconSlotClass, 'text-[var(--text-icon)]')}>
<ChevronLeft className='size-[14px]' />
@@ -352,7 +357,10 @@ export function SettingsSidebar({
(item.id === 'inbox'
? !inboxEntitled
: !subscriptionAccess.hasUsableMaxAccess)
const itemClassName = chipVariants({ active, fullWidth: true })
const itemClassName = cn(
chipVariants({ active, fullWidth: true }),
SIDEBAR_RAIL_CHIP_CLASS
)
const content = (
<>
<Icon className={chipContentIconClass} />
@@ -24,7 +24,10 @@ import { getDesktopUpdates } from '@/lib/desktop'
import { getUserColor } from '@/lib/workspaces/colors'
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
import { SIDEBAR_ITEM_GAP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
import {
SIDEBAR_ITEM_GAP_CLASS,
SIDEBAR_RAIL_CHIP_CLASS,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
import { useUserProfile } from '@/hooks/queries/user-profile'
import { useWorkspaceInvitePolicy } from '@/hooks/use-workspace-invite-policy'
@@ -237,11 +240,11 @@ export function SidebarFooter({
<button
type='button'
data-item-id='profile'
className={
isCollapsed
? cn(chipVariants({ fullWidth: true }), 'min-w-0')
: cn(chipVariants(), 'max-w-full')
}
className={cn(
chipVariants({ fullWidth: isCollapsed }),
isCollapsed ? 'min-w-0' : 'max-w-full',
SIDEBAR_RAIL_CHIP_CLASS
)}
>
{avatar}
{profile ? (
@@ -293,7 +296,7 @@ export function SidebarFooter({
than the rail, and a shrinking chip would be squeezed onto the avatar.
Holding its size pushes it past the edge, where the aside's clip hides
it until there is room. */
className='flex-shrink-0'
className={cn('flex-shrink-0', SIDEBAR_RAIL_CHIP_CLASS)}
/>
</DropdownMenuTrigger>
</SidebarTooltip>
@@ -30,6 +30,7 @@ import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/
import { CreateWorkspaceModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/create-workspace-modal/create-workspace-modal'
import { ViewInvitationsMenuItem } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-menu-item'
import { ViewInvitationsModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal'
import { SIDEBAR_RAIL_CHIP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
import { invitationKeys } from '@/hooks/queries/invitations'
import {
type Workspace,
@@ -442,7 +443,7 @@ function WorkspaceHeaderImpl({
type='button'
aria-label='Expand sidebar'
onClick={onExpandSidebar}
className={chipVariants({ fullWidth: true })}
className={cn(chipVariants({ fullWidth: true }), SIDEBAR_RAIL_CHIP_CLASS)}
>
<div className='relative flex size-[16px] flex-shrink-0 items-center justify-center'>
{activeWorkspaceFull?.logoUrl ? (
@@ -30,6 +30,34 @@ export const SIDEBAR_ITEM_GAP_CLASS = 'gap-[1px]'
export const SIDEBAR_DIVIDER_PAD_ABOVE_CLASS = 'pb-2'
export const SIDEBAR_DIVIDER_PAD_BELOW_CLASS = 'pt-2'
/**
* Rail-chip geometry for the collapsed sidebar, gated by the `group/rail`
* marker on the sidebar `aside` (the hover-peek card drops `data-collapsed`,
* so peeked rows stay expanded). Collapses a full-width row to the natural
* icon-only chip 32px, the chip's own `px-2` + 16px glyph, matching the
* header's Search/Collapse pills. Only the WIDTH is overridden: the rail is
* 48px precisely so that the plain 8px item gutter centers the chip
* ((48 32) / 2 = 8) and puts the glyph (24px in) on the rail's midline
* the same 24px column the expanded rows use, so NOTHING moves on toggle.
* At the previous 51px rail those goals were mutually exclusive by 1.5px
* (rail midline 25.5 vs glyph column 24), which produced either a
* left-biased rail or a drift on toggle; keep the rail width and this chip
* width commensurate (rail = chip + 2 × gutter) if either ever changes.
* Collapsing, the width tweens down to 32px on the 175ms curve the rail
* closes on; expanding targets `auto` (not interpolable), so the chip snaps
* to the still-narrow rail's width and stretch-tracks it open. The duration
* is `!important` because the aside zeroes chip transition durations
* (`[&_.group.cursor-pointer]:duration-0`) for instant hover fills colors
* are excluded from the property list here, so hover fills keep snapping.
*/
export const SIDEBAR_RAIL_CHIP_CLASS = [
'transition-[width]',
'![transition-duration:175ms]',
'[transition-timing-function:cubic-bezier(0.25,0.1,0.25,1)]',
'motion-reduce:!transition-none',
'group-data-[collapsed]/rail:w-[32px]',
].join(' ')
/**
* Nested-selector variants for cmdk-based surfaces (e.g. the search modal).
* Written as complete literal strings so Tailwind's JIT can detect them.
@@ -77,6 +77,7 @@ import {
SIDEBAR_DIVIDER_PAD_ABOVE_CLASS,
SIDEBAR_DIVIDER_PAD_BELOW_CLASS,
SIDEBAR_ITEM_GAP_CLASS,
SIDEBAR_RAIL_CHIP_CLASS,
SIDEBAR_SECTION_GAP_CLASS,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
import {
@@ -355,6 +356,7 @@ const SidebarNavItem = memo(function SidebarNavItem({
leftIcon={item.icon}
active={active}
fullWidth
className={SIDEBAR_RAIL_CHIP_CLASS}
onClick={
item.onClick
? (e) => {
@@ -374,6 +376,7 @@ const SidebarNavItem = memo(function SidebarNavItem({
leftIcon={item.icon}
active={active}
fullWidth
className={SIDEBAR_RAIL_CHIP_CLASS}
onClick={item.onClick}
>
{item.label}
@@ -1351,7 +1354,7 @@ export const Sidebar = memo(function Sidebar({
/>
<div className='relative h-full'>
<aside
className='sidebar-container relative h-full overflow-hidden bg-[var(--surface-1)] [&_.group.cursor-pointer]:duration-0'
className='group/rail sidebar-container relative h-full overflow-hidden bg-[var(--surface-1)] [&_.group.cursor-pointer]:duration-0'
data-collapsed={isCollapsed || undefined}
aria-label='Workspace sidebar'
onClick={handleSidebarClick}
@@ -1404,11 +1407,24 @@ export const Sidebar = memo(function Sidebar({
* between them. `gap-[1px]` rather than `gap-px`: the `px` spacing key
* is remapped to `--border-width`, which thins to 0.5px on hidpi so
* hairline rules stay hairlines.
*
* The expanded width is EXPLICIT (2 icon chips × 32px + the 1px gap;
* 32px when the desktop inset title bar hides the collapse chip), never
* `auto`: `w-0 → auto` cannot interpolate, so on expand the cluster
* snapped to full width while the rail was still 51px wide and since
* the cluster refuses to flex-shrink (min-width: auto) while the
* workspace chip's wrapper is `min-w-0 flex-1`, the workspace chip
* crushed to zero and the hover-filled Search chip landed exactly under
* the cursor on the workspace icon: a visible flash on every expand.
* With both endpoints explicit, the width tweens in step with the rail
* and the workspace chip keeps its space throughout.
*/}
<div
className={cn(
'flex h-[30px] items-center gap-[1px] overflow-hidden transition-all duration-200',
isCollapsed && 'w-0 opacity-0'
'flex h-[30px] items-center gap-[1px] overflow-hidden transition-all duration-200 [transition-timing-function:cubic-bezier(0.25,0.1,0.25,1)]',
isCollapsed
? 'w-0 opacity-0'
: 'w-[65px] [[data-sim-desktop-title-bar=inset]_&]:w-[32px]'
)}
>
<SidebarTooltip
+1 -1
View File
@@ -26,7 +26,7 @@ export const SIDEBAR_WIDTH = {
DEFAULT: 238,
MIN: 238,
/** Width when sidebar is collapsed to icon-only mode */
COLLAPSED: 51,
COLLAPSED: 48,
/** Maximum is 30% of viewport, enforced dynamically */
MAX_PERCENTAGE: 0.3,
} as const