fix(desktop): titlebar clusters — macOS Y nudge, 24px targets, 13.9px icons

Left cluster gets a macOS-only translate to sit on the traffic-light row.
All titlebar tools use 24×24 hit areas with 13.9px Codicons (inline size
beats unlayered codicon.css). Clusters share one flex shell with no gap —
buttons abut and the hit target is the spacing.
This commit is contained in:
Brooklyn Nicholson
2026-08-10 04:24:42 -05:00
parent 92dd230c8c
commit d1f2e32b13
12 changed files with 130 additions and 39 deletions
+3 -2
View File
@@ -2,6 +2,7 @@ import type * as React from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import { TitlebarIcon } from '@/app/shell/titlebar-icon'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
@@ -28,7 +29,7 @@ import {
urlSlugTitleLabel,
useLinkTitle
} from '@/lib/external-link'
import { FileImage, FileText, FolderOpen, Link2, Loader2, RefreshCw } from '@/lib/icons'
import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons'
import { downloadGatewayMediaFile, isRemoteGateway } from '@/lib/media'
import { normalize } from '@/lib/text'
import { fmtDayTime } from '@/lib/time'
@@ -329,7 +330,7 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
size="icon-titlebar"
variant="ghost"
>
{refreshing ? <Loader2 className="animate-spin" /> : <RefreshCw />}
{refreshing ? <TitlebarIcon name="loading" spinning /> : <TitlebarIcon name="refresh" />}
</Button>
</Tip>
}
+3 -3
View File
@@ -746,13 +746,13 @@ export function ContribController() {
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-[calc(var(--titlebar-controls-left,14px)+(var(--titlebar-control-size,1.25rem)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,5.5rem)+0.75rem)] [-webkit-app-region:drag]"
className="pointer-events-none absolute inset-y-0 left-[calc(var(--titlebar-controls-left,14px)+(var(--titlebar-control-size,24px)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,5.5rem)+0.75rem)] [-webkit-app-region:drag]"
/>
<TitlebarSlot
area="titleBar.left"
className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]"
style={{
left: 'max(calc(var(--workspace-left, 0px) + 0.5rem), calc(var(--titlebar-controls-left, 14px) + 2 * var(--titlebar-control-size, 1.25rem) + 1rem))'
left: 'max(calc(var(--workspace-left, 0px) + 0.5rem), calc(var(--titlebar-controls-left, 14px) + 2 * var(--titlebar-control-size, 24px) + 1rem))'
}}
/>
<TitlebarSlot
@@ -764,7 +764,7 @@ export function ContribController() {
className="pointer-events-auto absolute z-10 flex w-max items-center gap-2 [-webkit-app-region:no-drag]"
style={{
right:
'max(calc(var(--workspace-right, 0px) + 0.5rem), calc(var(--titlebar-tools-right, 0.75rem) + 4 * (var(--titlebar-control-size, 1.25rem) + 0.25rem) + 0.5rem))'
'max(calc(var(--workspace-right, 0px) + 0.5rem), calc(var(--titlebar-tools-right, 0.75rem) + 4 * var(--titlebar-control-size, 24px) + 0.5rem))'
}}
/>
</div>
+5 -9
View File
@@ -112,7 +112,7 @@ import { useSessionStateCache } from '../session/hooks/use-session-state-cache'
import { startWorkspaceSession } from '../session/workspace-session-target'
import { useOverlayRouting } from '../shell/hooks/use-overlay-routing'
import { useWindowControlsOverlayWidth } from '../shell/hooks/use-window-controls-overlay-width'
import { titlebarControlsPosition } from '../shell/titlebar'
import { titlebarControlsPosition, titlebarControlsYNudge, titlebarToolsWidthCss } from '../shell/titlebar'
import { TitlebarControls } from '../shell/titlebar-controls'
import { UpdatesOverlay } from '../updates-overlay'
@@ -967,11 +967,6 @@ export function ContribWiring({ children }: { children: ReactNode }) {
const rightTitlebarTools = useTitlebarToolContributions('right')
const connection = useStore($connection)
const controlsPos = titlebarControlsPosition(connection?.windowButtonPosition, Boolean(connection?.isFullscreen))
// Exact vertical centering: titlebarControlsPosition() returns
// (TITLEBAR_HEIGHT - TITLEBAR_CONTROL_HEIGHT) / 2, but TitlebarControls
// also applies a hard translate-y-0.5 (+2px) to its clusters. Cancel that
// constant so cluster center == bar center — measured, not eyeballed.
const controlsTranslateY = 2
// Windows/WSLg reserve native min/max/close on the right (AppShell parity:
// prefer the live WCO measurement, fall back to the static reservation).
const measuredOverlayWidth = useWindowControlsOverlayWidth()
@@ -982,11 +977,11 @@ export function ContribWiring({ children }: { children: ReactNode }) {
// sits ABOVE the grid, so AppShell's pane-width anchoring doesn't apply.
const SYSTEM_TOOL_COUNT = 4
const paneToolCount = rightTitlebarTools.filter(tool => !tool.hidden).length
const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * (var(--titlebar-control-size) + 0.25rem))`
const systemToolsWidth = titlebarToolsWidthCss(SYSTEM_TOOL_COUNT)
const titlebarToolsWidth =
paneToolCount > 0
? `calc(${systemToolsWidth} + ${paneToolCount} * (var(--titlebar-control-size) + 0.25rem))`
? `calc(${systemToolsWidth} + ${titlebarToolsWidthCss(paneToolCount)})`
: systemToolsWidth
return (
@@ -996,7 +991,8 @@ export function ContribWiring({ children }: { children: ReactNode }) {
style={
{
'--titlebar-controls-left': `${controlsPos.left}px`,
'--titlebar-controls-top': `${controlsPos.top - controlsTranslateY}px`,
'--titlebar-controls-top': `${controlsPos.top}px`,
'--titlebar-controls-y-nudge': titlebarControlsYNudge(connection?.windowButtonPosition),
'--titlebar-tools-right': titlebarToolsRight,
'--titlebar-tools-width': titlebarToolsWidth,
'--shell-preview-toolbar-gap': systemToolsWidth
+2 -2
View File
@@ -2,8 +2,8 @@ import { useStore } from '@nanostores/react'
import { type CSSProperties, useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import { TitlebarIcon } from '@/app/shell/titlebar-icon'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { chatMessageText } from '@/lib/chat-messages'
@@ -390,7 +390,7 @@ export function HudShell() {
type="button"
variant="ghost"
>
<Codicon name="screen-normal" />
<TitlebarIcon name="screen-normal" />
</Button>
</Tip>
</div>
@@ -1,8 +1,8 @@
import { type CSSProperties, type ReactNode, useEffect } from 'react'
import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar'
import { TitlebarIcon } from '@/app/shell/titlebar-icon'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { translateNow } from '@/i18n'
import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers'
import { triggerHaptic } from '@/lib/haptics'
@@ -113,7 +113,7 @@ export function OverlayView({
size="icon-titlebar"
variant="ghost"
>
<Codicon name="close" size="1rem" />
<TitlebarIcon name="close" />
</Button>
</div>
@@ -6,7 +6,6 @@ import { hudTargetSessionId } from '@/app/hud/handoff'
import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
import { resetLayoutTree } from '@/components/pane-shell/tree/store'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
@@ -23,7 +22,8 @@ import {
import { appViewForPath, isOverlayView } from '../routes'
import { titlebarButtonClass } from './titlebar'
import { titlebarButtonClass, titlebarIconSizeCss, titlebarToolClusterClass, TITLEBAR_ICON_BADGE_SCALE } from './titlebar'
import { TitlebarIcon } from './titlebar-icon'
export interface TitlebarTool {
id: string
@@ -61,12 +61,12 @@ function LayoutGlyph({ modHeld }: { modHeld: boolean }) {
return (
<>
<span className={cn('inline-flex', modHeld && 'group-hover/tool:hidden')}>
<Codicon name="layout" />
<TitlebarIcon name="layout" />
</span>
<span className={cn('relative hidden', modHeld && 'group-hover/tool:inline-flex')}>
<Codicon name="layout" />
<TitlebarIcon name="layout" />
<span className="absolute -bottom-1 -right-1.5 grid place-items-center rounded-full bg-(--ui-bg-chrome) p-px">
<Codicon className="-scale-x-100" name="refresh" size="0.5625rem" />
<TitlebarIcon className="-scale-x-100" name="refresh" size={titlebarIconSizeCss(TITLEBAR_ICON_BADGE_SCALE)} />
</span>
</span>
</>
@@ -128,7 +128,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
const leftToolbarTools: TitlebarTool[] = [
{
actionId: 'view.toggleSidebar',
icon: <Codicon name="layout-sidebar-left" />,
icon: <TitlebarIcon name="layout-sidebar-left" />,
id: 'sidebar',
label: leftEdge.open ? t.titlebar.hideSidebar : t.titlebar.showSidebar,
onSelect: () => {
@@ -138,7 +138,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
},
{
actionId: 'view.flipPanes',
icon: <Codicon name="arrow-swap" />,
icon: <TitlebarIcon name="arrow-swap" />,
id: 'flip-panes',
label: t.titlebar.swapSidebarSides,
onSelect: () => {
@@ -151,7 +151,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
const rightSidebarTool: TitlebarTool = {
actionId: 'view.toggleRightSidebar',
icon: <Codicon name="layout-sidebar-right" />,
icon: <TitlebarIcon name="layout-sidebar-right" />,
id: 'right-sidebar',
label: rightEdge.open ? t.titlebar.hideRightSidebar : t.titlebar.showRightSidebar,
onSelect: () => {
@@ -188,7 +188,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
// crowds the ⌘⇧H hint off the tooltip. Label only — the hint is appended
// from the action registry, same as every other tool here.
actionId: 'view.toggleHud',
icon: <Codicon name="comment-discussion" />,
icon: <TitlebarIcon name="comment-discussion" />,
id: 'hud',
label: t.titlebar.enterHud,
onSelect: () => {
@@ -198,14 +198,14 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
},
{
active: hapticsMuted,
icon: <Codicon name={hapticsMuted ? 'mute' : 'unmute'} />,
icon: <TitlebarIcon name={hapticsMuted ? 'mute' : 'unmute'} />,
id: 'haptics',
label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics,
onSelect: toggleHaptics
},
{
actionId: 'nav.settings',
icon: <Codicon name="settings-gear" />,
icon: <TitlebarIcon name="settings-gear" />,
id: 'settings',
label: t.titlebar.openSettings,
onSelect: () => {
@@ -230,7 +230,10 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
<>
<div
aria-label={t.shell.windowControls}
className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-70 flex translate-y-0.5 flex-row items-center gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
className={cn(
titlebarToolClusterClass,
'left-(--titlebar-controls-left) top-(--titlebar-controls-top) translate-y-(--titlebar-controls-y-nudge)'
)}
>
{leftToolbarTools
.filter(tool => !tool.hidden)
@@ -250,7 +253,10 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
{visiblePaneTools.length > 0 && (
<div
aria-label={t.shell.paneControls}
className="fixed top-[calc(var(--titlebar-controls-top)+var(--right-rail-top-inset,0px))] right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0))] z-70 flex flex-row items-center gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
className={cn(
titlebarToolClusterClass,
'top-[calc(var(--titlebar-controls-top)+var(--right-rail-top-inset,0px))] right-[calc(var(--titlebar-tools-right)+var(--shell-preview-toolbar-gap,0))]'
)}
>
{visiblePaneTools.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
@@ -260,7 +266,7 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
<div
aria-label={t.shell.appControls}
className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-70 flex flex-row items-center justify-end gap-x-1 pointer-events-auto select-none [-webkit-app-region:no-drag]"
className={cn(titlebarToolClusterClass, 'right-(--titlebar-tools-right) top-(--titlebar-controls-top)')}
>
{visibleSystemTools.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
@@ -0,0 +1,19 @@
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
import { titlebarIconSizeCss } from './titlebar'
/** Titlebar-cluster glyph — 13.9px inline (see titlebarIconSizeCss). */
export function TitlebarIcon({
className,
name,
size = titlebarIconSizeCss(),
spinning
}: {
className?: string
name: string
size?: number | string
spinning?: boolean
}) {
return <Codicon className={cn('leading-none', className)} name={name} size={size} spinning={spinning} />
}
+33 -1
View File
@@ -2,11 +2,29 @@ import { describe, expect, it } from 'vitest'
import {
TITLEBAR_CONTROL_OFFSET_X,
TITLEBAR_CONTROL_SIZE,
TITLEBAR_EDGE_INSET,
TITLEBAR_FALLBACK_WINDOW_BUTTON_X,
titlebarControlsPosition
TITLEBAR_ICON_SIZE,
TITLEBAR_MAC_TRAFFIC_LIGHTS_Y_NUDGE,
titlebarControlsPosition,
titlebarControlsYNudge,
titlebarIconSizeCss,
titlebarToolsWidthCss
} from './titlebar'
describe('titlebar sizing', () => {
it('uses 24×24 hit targets and 13.9px glyphs', () => {
expect(TITLEBAR_CONTROL_SIZE).toBe(24)
expect(TITLEBAR_ICON_SIZE).toBe(13.9)
expect(titlebarIconSizeCss()).toBe('13.9px')
})
it('reserves width from abutting hit targets only', () => {
expect(titlebarToolsWidthCss(4)).toBe('calc(4 * var(--titlebar-control-size))')
})
})
describe('titlebarControlsPosition', () => {
it('offsets controls from visible traffic lights', () => {
expect(titlebarControlsPosition({ x: 24, y: 10 }).left).toBe(24 + TITLEBAR_CONTROL_OFFSET_X)
@@ -24,3 +42,17 @@ describe('titlebarControlsPosition', () => {
expect(titlebarControlsPosition(undefined).left).toBe(TITLEBAR_FALLBACK_WINDOW_BUTTON_X + TITLEBAR_CONTROL_OFFSET_X)
})
})
describe('titlebarControlsYNudge', () => {
it('nudges the left cluster on macOS when traffic lights are visible', () => {
expect(titlebarControlsYNudge({ x: 24, y: 10 })).toBe(TITLEBAR_MAC_TRAFFIC_LIGHTS_Y_NUDGE)
})
it('stays flat on Windows/Linux and macOS fullscreen', () => {
expect(titlebarControlsYNudge(null)).toBe('0px')
})
it('nudges while macOS window-button position is still unknown', () => {
expect(titlebarControlsYNudge(undefined)).toBe(TITLEBAR_MAC_TRAFFIC_LIGHTS_Y_NUDGE)
})
})
+31 -2
View File
@@ -2,22 +2,51 @@ import type { HermesConnection } from '@/global'
export const TITLEBAR_HEIGHT = 34
export const MACOS_TRAFFIC_LIGHTS_HEIGHT = 14
export const TITLEBAR_ICON_SIZE = 12
/** Titlebar tool hit target (both axes). */
export const TITLEBAR_CONTROL_SIZE = 24
/** Codicon glyph box in titlebar clusters — optical match to traffic-light row. */
export const TITLEBAR_ICON_SIZE = 13.9
export const TITLEBAR_ICON_BADGE_SCALE = 0.65
export const TITLEBAR_CONTROL_OFFSET_X = 74
export const TITLEBAR_CONTROL_HEIGHT = 22
export const TITLEBAR_CONTROL_HEIGHT = TITLEBAR_CONTROL_SIZE
export const TITLEBAR_CONTROLS_TOP = (TITLEBAR_HEIGHT - TITLEBAR_CONTROL_HEIGHT) / 2
/** Inline font-size for titlebar Codicons — beats unlayered codicon.css `font: 16px`. */
export function titlebarIconSizeCss(scale = 1): string {
return `${TITLEBAR_ICON_SIZE * scale}px`
}
export const TITLEBAR_FALLBACK_WINDOW_BUTTON_X = 24
// Edge inset used when no left-side native controls take up that space —
// Windows/Linux (native overlay is on the right) and macOS fullscreen
// (traffic lights are hidden). Matches the right-cluster's 0.75rem padding.
export const TITLEBAR_EDGE_INSET = 14
// macOS traffic-light row only: nudge the left toolbar cluster down to sit on
// the same optical center as the native buttons. null windowButtonPosition means
// Windows/Linux (controls on the right) or macOS fullscreen (lights hidden).
export const TITLEBAR_MAC_TRAFFIC_LIGHTS_Y_NUDGE = 'calc(var(--spacing) * 0.9)'
export function titlebarControlsYNudge(
windowButtonPosition: HermesConnection['windowButtonPosition'] | undefined
): string {
return windowButtonPosition !== null ? TITLEBAR_MAC_TRAFFIC_LIGHTS_Y_NUDGE : '0px'
}
// Titlebar palette only. All sizing/radius/cursor/centering come from the
// shared <Button size="icon-titlebar"> (used polymorphically via asChild) —
// Button is the single source of button styling.
export const titlebarButtonClass =
'text-muted-foreground/85 hover:bg-(--ui-control-hover-background) hover:text-foreground'
/** Shared flex shell for left/right/pane titlebar tool rows — no gap; 24px buttons abut. */
export const titlebarToolClusterClass =
'fixed z-70 flex flex-row items-center pointer-events-auto select-none [-webkit-app-region:no-drag]'
/** Width reserved for N abutting titlebar tool buttons. */
export function titlebarToolsWidthCss(toolCount: number): string {
return `calc(${toolCount} * var(--titlebar-control-size))`
}
export const titlebarHeaderBaseClass =
'pointer-events-none relative z-3 flex h-(--titlebar-height) w-full min-w-0 shrink-0 items-center justify-start gap-3 overflow-hidden border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))] pr-[calc(var(--titlebar-tools-right,0.75rem)+var(--titlebar-tools-width,0px)+0.75rem)]'
+1 -1
View File
@@ -50,7 +50,7 @@ const buttonVariants = cva(
'icon-sm': 'size-8 rounded-[4px]',
'icon-lg': 'size-10 rounded-[4px]',
'icon-titlebar':
'h-(--titlebar-control-height) w-(--titlebar-control-size) rounded-[4px] [&_.codicon]:text-[0.875rem]'
'titlebar-icon-button h-(--titlebar-control-height) w-(--titlebar-control-size) rounded-[4px] [&_svg:not([class*="size-"])]:size-(--titlebar-icon-size)'
}
},
compoundVariants: [
+1 -1
View File
@@ -14,7 +14,7 @@ export function Codicon({ className, name, size, spinning, style, ...props }: Co
<i
aria-hidden="true"
className={cn('codicon', `codicon-${name}`, spinning && 'codicon-modifier-spin', className)}
style={{ fontSize: size, ...style }}
style={{ ...(size != null && size !== '' ? { fontSize: size } : {}), ...style }}
{...props}
/>
)
+10 -2
View File
@@ -3,6 +3,13 @@
@import 'tw-shimmer';
@import 'katex/dist/katex.min.css';
@import '@vscode/codicons/dist/codicon.css';
/* Titlebar clusters: 24×24 hit targets, 13.9px glyphs. codicon.css is
unlayered (`font: 16px/1`); Tailwind utilities in @layer lose that fight. */
.titlebar-icon-button .codicon[class*='codicon-'] {
font-size: var(--titlebar-icon-size);
}
@custom-variant dark (&:is(.dark *));
/* Blanket reduced-motion override: kill ALL CSS animations and transitions
@@ -468,8 +475,9 @@
--sidebar-width: 14.8125rem;
--chat-min-width: 28rem;
--titlebar-control-size: 1.25rem;
--titlebar-control-height: 1.375rem;
--titlebar-control-size: 24px;
--titlebar-control-height: 24px;
--titlebar-icon-size: 13.9px;
--sidebar-content-inline-padding: 1rem;
--sidebar: var(--dt-sidebar-bg);