mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-22 05:19:54 +08:00
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:
@@ -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 */
|
||||
|
||||
@@ -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');
|
||||
|
||||
+9
-4
@@ -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'>
|
||||
|
||||
+2
-1
@@ -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>
|
||||
|
||||
+10
-2
@@ -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} />
|
||||
|
||||
+10
-7
@@ -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>
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user