diff --git a/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts b/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts index 50287ceaa35..4b0c822220d 100644 --- a/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts +++ b/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts @@ -14,6 +14,13 @@ const workspaceChrome = read( ) const sidebar = read('../workspace/[workspaceId]/w/components/sidebar/sidebar.tsx') const globalStyles = read('../_styles/globals.css') +const pageHeaderBar = read('../../components/page-header-bar.ts') +const resourceHeader = read( + '../workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx' +) +const mothershipView = read( + '../workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx' +) describe('desktop title-bar surface audit', () => { it('applies the safe-area shell only when the auth route is login', () => { @@ -65,4 +72,70 @@ describe('desktop title-bar surface audit', () => { expect(sidebar).toContain('[[data-sim-desktop-title-bar=inset]_&]:pt-[var(') expect(sidebar).not.toMatch(/\[\[data-sim-desktop-title-bar=inset\]_&\]:pt-\d/) }) + + it('defines the content-pane lane once, defaulting to zero', () => { + // A `:root` default keeps the variable defined for bars that render outside + // `.workspace-content-shell` (the standalone settings shell at /account, + // /organization/[id], /selfhost; the landing tables preview). An undefined var() + // inside calc() is invalid at computed-value time and drops padding-top entirely. + expect(globalStyles).toMatch(/:root\s*\{[^}]*--workspace-content-title-bar-inset:\s*0px/s) + // The pane owns the lane in both arrangements where the sidebar is not there to + // own it: collapsed to zero width, and slid away for a fullscreen route (which + // leaves the sidebar expanded in the store, so the collapsed selector alone misses + // it and /upgrade's back chip lands under the traffic lights). + expect(globalStyles).toContain('.workspace-content-shell[data-sidebar-collapsed],') + expect(globalStyles).toContain('.workspace-content-shell[data-content-fullscreen] {') + expect(workspaceChrome).toContain('data-content-fullscreen={isFullscreen || undefined}') + }) + + it('sizes the peek card to its content, capped against the lane', () => { + // Pinning both edges made the card full height, so a short list (settings) left a + // tall empty slab over the content. It now hugs its content and caps at the pane + // height less the lane and the bottom gutter, so a long list still scrolls. + expect(workspaceChrome).toContain('max-h-[calc(100%-var(--desktop-title-bar-height)-8px)]') + expect(workspaceChrome).not.toMatch(/PEEK_CARD_CHROME[\s\S]{0,240}?bottom-2/) + }) + + it('reserves the login lane inside the box, never as a collapsing margin', () => { + // `body` carries `min-height: 100vh`, and a `margin-top` here collapses through it + // (body is a plain block box, so it opens no BFC) and displaces body itself. The + // document then measured one full lane taller than the viewport, which is what made + // the desktop login page scroll. Verified live: 40px of overflow, now 0. + // Comments are stripped first: the rule documents why `margin-top` is wrong, and a + // raw `not.toContain` would match that prose instead of a declaration. + const rule = (globalStyles.match(/\.desktop-title-bar-page \{[^}]*\}/)?.[0] ?? '').replace( + /\/\*[\s\S]*?\*\//g, + '' + ) + expect(rule).toContain('padding-top: var(--desktop-title-bar-height)') + expect(rule).toContain('min-height: 100vh') + expect(rule).not.toContain('margin-top') + }) + + it('drops the content pane border where the pane meets the window edge', () => { + // Collapsing the sidebar in the desktop shell takes the pane's padding to 0, so a + // retained border and radius drew a hairline outline inset from the square window. + const flush = '[[data-sim-desktop-title-bar=inset]_[data-sidebar-collapsed]_&]:' + expect(workspaceChrome).toContain(`${flush}rounded-none`) + expect(workspaceChrome).toContain(`${flush}border-0`) + }) + + it('clears the lane for panels that embed pages away from the lights', () => { + // The mothership panel is the right half of the pane and embeds whole pages + // (KnowledgeBase et al) whose header bars reserve the lane. It inherits the + // variable, so without this reset those bars gain the inset while sitting nowhere + // near the traffic lights. + expect(mothershipView).toContain('[--workspace-content-title-bar-inset:0px]') + }) + + it('reserves that lane in every top-of-pane header bar', () => { + // Both top-bar geometries must compose the shared lane padding. A bare + // `pt-`/`py-[8.5px]` in either is the bug: the bar then draws under the traffic + // lights and the sidebar expander whenever the sidebar is collapsed on desktop. + expect(pageHeaderBar).toContain('pt-[calc(8.5px+var(--workspace-content-title-bar-inset))]') + expect(pageHeaderBar).toContain('TITLE_BAR_LANE_PT') + + expect(resourceHeader).toContain('TITLE_BAR_LANE_PT') + expect(resourceHeader).not.toMatch(/py-\[8\.5px\]/) + }) }) diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index 87c1a884809..1ba253d00b5 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -14,6 +14,7 @@ --sidebar-collapsed-width: 51px; /* icon rail on web; desktop overrides to 0 before first paint */ --sidebar-expanded-width: 248px; /* 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 */ --desktop-title-bar-inset-x: 0px; /* clearance past the traffic lights; desktop overrides */ --desktop-title-bar-control-offset: 0px; /* centres a lane control; desktop overrides */ --desktop-title-bar-control-size: 0px; /* control seated in the lane; desktop overrides */ @@ -43,7 +44,7 @@ /* Shadow scale */ --shadow-subtle: 0 2px 4px 0 rgba(0, 0, 0, 0.08); --shadow-medium: 0 4px 12px rgba(0, 0, 0, 0.1); - --shadow-overlay: 0 16px 48px rgba(0, 0, 0, 0.15); + --shadow-overlay: 0 10px 30px rgba(0, 0, 0, 0.11); --shadow-kbd: 0 4px 0 0 rgba(48, 48, 48, 1); --shadow-kbd-sm: 0 2px 0 0 rgba(48, 48, 48, 1); --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04); @@ -75,8 +76,14 @@ html[data-sim-desktop-title-bar="inset"] { /** The macOS desktop login shell reserves the native traffic-light lane. */ .desktop-title-bar-page { - margin-top: var(--desktop-title-bar-height); - min-height: calc(100vh - var(--desktop-title-bar-height)); + /* Reserve the lane with padding INSIDE the box, never `margin-top` + a + `calc(100vh - lane)` height. `body` carries `min-height: 100vh` of its own, and + a top margin here has nothing to collapse against (body is a block box with no + padding, border, or BFC), so it collapses through and displaces body itself — + leaving a document one full lane taller than the viewport. Global + `box-sizing: border-box` keeps this padding inside the 100vh. */ + padding-top: var(--desktop-title-bar-height); + min-height: 100vh; } .desktop-window-drag-region { @@ -122,11 +129,10 @@ html[data-sim-desktop-title-bar="inset"] letter-spacing: 0.02em; } -.workspace-content-shell { - --workspace-content-title-bar-inset: 0px; -} - -.workspace-content-shell[data-sidebar-collapsed] { +/* The pane owns the lane whenever the sidebar is not there to own it: collapsed to + zero width, or slid away for a fullscreen route. */ +.workspace-content-shell[data-sidebar-collapsed], +.workspace-content-shell[data-content-fullscreen] { --workspace-content-title-bar-inset: var(--desktop-title-bar-height); } @@ -173,6 +179,14 @@ html[data-sim-desktop-title-bar="inset"] transition: none; } +/* The card is a flex column sized to its content, so the shell must be allowed to + shrink for the sidebar's own scroll region to bound itself once the card hits its + max height. Docked, this element is not a flex item and the rule is inert. */ +.sidebar-shell-outer[data-peek] .sidebar-shell-inner { + min-height: 0; + flex-shrink: 1; +} + .sidebar-container span, .sidebar-container .text-small { transition: opacity 120ms ease; @@ -583,7 +597,7 @@ html.sidebar-booting .sidebar-shell-inner { --scrollbar-thumb-hover-color: #6a6a6a; /* Shadow scale - dark */ - --shadow-overlay: 0 16px 48px rgba(0, 0, 0, 0.4); + --shadow-overlay: 0 10px 30px rgba(0, 0, 0, 0.3); } } diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx index 5753feabdcb..d4099a6a6f7 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx @@ -1,4 +1,6 @@ import type { ReactNode } from 'react' +import { cn } from '@sim/emcn' +import { PAGE_HEADER_BAR } from '@/components/page-header-bar' interface CredentialDetailLayoutProps { /** Back link rendered at the start of the fixed action bar. */ @@ -17,7 +19,7 @@ interface CredentialDetailLayoutProps { export function CredentialDetailLayout({ back, actions, children }: CredentialDetailLayoutProps) { return (
-
+
{back} {actions ?
{actions}
: null}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index f6b464404cd..a28d735a1f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -31,6 +31,7 @@ import { } from '@sim/emcn' import { ArrowUpLeft } from 'lucide-react' import { createPortal } from 'react-dom' +import { TITLE_BAR_LANE_PT } from '@/components/page-header-bar' import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input' import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text' @@ -131,7 +132,10 @@ export const ResourceHeader = memo(function ResourceHeader({ return (
diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx index 7f206c26da2..6609facab82 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx @@ -22,9 +22,18 @@ const SLIDE_TRANSITION = * The peek card's floating chrome. * * Every value is an existing token: `rounded-lg` is `--radius`, matching the content - * pane it floats beside; `--border` is that pane's border; `shadow-overlay` and - * `--z-modal` are what the app's other edge-anchored panels use. The card's fill is - * the sidebar's own `--surface-1`, so docked and floating are the same surface. + * pane it floats beside; `--border` is that pane's border; `--z-modal` is what the + * app's other edge-anchored panels use. The card's fill is the sidebar's own + * `--surface-1`, so docked and floating are the same surface. + * + * Deliberately unshadowed. It separates on the same `--border` hairline the content + * pane beside it uses; `--shadow-overlay` reads as too heavy at this size, where the + * card abuts the window edge rather than floating over the middle of the page. + * + * The card hugs its content and caps at the pane height less the lane and the bottom + * gutter — pinning both edges left a tall empty slab below short lists. It is a flex + * column so the shell can shrink inside that cap and the sidebar's own scroll region + * still bounds itself; see the `[data-peek]` rule in `globals.css`. * * `w-auto` shrink-wraps the inner shell, which `[data-peek]` has already put at the * expanded width. It must not be a length: `width` cannot interpolate to or from @@ -32,7 +41,7 @@ const SLIDE_TRANSITION = * card widens as it appears and leaves a shrinking ghost on retract. */ const PEEK_CARD_CHROME = - 'absolute top-[var(--desktop-title-bar-height)] bottom-2 left-2 z-[var(--z-modal)] w-auto origin-top-left rounded-lg border border-[var(--border)] shadow-overlay' + 'absolute top-[var(--desktop-title-bar-height)] left-2 z-[var(--z-modal)] flex max-h-[calc(100%-var(--desktop-title-bar-height)-8px)] w-auto flex-col origin-top-left rounded-lg border border-[var(--border)]' /** * Peek card enter/exit — the popper idiom rather than a slide, since the card is @@ -57,6 +66,20 @@ const PEEK_CARD_EXIT = cn( /** The docked rail: in flow, width-animated by the collapse toggle. */ const SIDEBAR_SHELL_IN_FLOW = cn('transition-[width]', SLIDE_TRANSITION) +/** + * The content pane's own chrome, dropped when the pane sits flush to the window. + * + * Collapsing the sidebar in the desktop shell takes the surrounding padding to `0`, + * which puts the pane hard against the window edge — and its border and radius then + * draw a hairline outline with rounded corners inset from the square window frame. + * + * Keyed off the ancestor attributes rather than React state on purpose: the title-bar + * attribute is written pre-paint, so a state-driven rule would flash the border on + * first paint before hydration settles. + */ +const CONTENT_PANE_FLUSH = + '[[data-sim-desktop-title-bar=inset]_[data-sidebar-collapsed]_&]:rounded-none [[data-sim-desktop-title-bar=inset]_[data-sidebar-collapsed]_&]:border-0' + interface WorkspaceChromeProps { children: React.ReactNode /** Cookie-derived collapse state from the server layout; seeds the sidebar's first render. */ @@ -295,8 +318,16 @@ export function WorkspaceChrome({ isCollapsed && '[[data-sim-desktop-title-bar=inset]_&]:p-0' )} data-sidebar-collapsed={isCollapsed || undefined} + /* A fullscreen route slides the sidebar away without collapsing it, so the pane + inherits the traffic-light lane the same way a collapsed sidebar does. */ + data-content-fullscreen={isFullscreen || undefined} > -
+
{children}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx index 01a1ddd3d69..48957e8fcd4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx @@ -137,6 +137,10 @@ export const MothershipView = memo( className={cn( 'relative z-10 flex h-full flex-col overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-200 ease-[cubic-bezier(0.25,0.1,0.25,1)]', isCollapsed ? 'w-0 min-w-0 border-l-0' : 'w-1/2 border-l', + /* This panel is the right half of the pane, never under the traffic lights, + yet it embeds whole pages whose header bars reserve that lane. Zeroing the + inherited variable here keeps their top bars flush inside the panel. */ + '[--workspace-content-title-bar-inset:0px]', className )} > diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail-fallback.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail-fallback.tsx index e634e7806e7..df7af0e6c8a 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail-fallback.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail-fallback.tsx @@ -2,6 +2,7 @@ import { ChipLink } from '@sim/emcn' import { ArrowLeft } from 'lucide-react' +import { PAGE_HEADER_BAR } from '@/components/page-header-bar' interface IntegrationBlockDetailFallbackProps { workspaceId: string @@ -23,7 +24,7 @@ export function IntegrationBlockDetailFallback({ }: IntegrationBlockDetailFallbackProps) { return (
-
+
Integrations diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index 0bbf99b3630..6ad76f5996e 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -6,6 +6,7 @@ import { ArrowLeft, ArrowRight, Plus } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' +import { PAGE_HEADER_BAR } from '@/components/page-header-bar' import { blockTypeToIconMap, type Integration, @@ -138,7 +139,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration return (
-
+
Integrations diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-tabs-header/integration-tabs-header.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-tabs-header/integration-tabs-header.tsx index 66fc7fb2f22..b4d71e20bab 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-tabs-header/integration-tabs-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/components/integration-tabs-header/integration-tabs-header.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react' import { ChipLink } from '@sim/emcn' +import { PAGE_HEADER_BAR } from '@/components/page-header-bar' interface IntegrationTabsHeaderProps { active: 'integrations' | 'skills' @@ -18,7 +19,7 @@ export function IntegrationTabsHeader({ rightSlot, }: IntegrationTabsHeaderProps) { return ( -
+
Integrations diff --git a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx index 446469a2dc6..20e698d4a2c 100644 --- a/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx +++ b/apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx @@ -5,6 +5,7 @@ import { ArrowLeft, Chip, toast } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import { useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' +import { PAGE_HEADER_BAR } from '@/components/page-header-bar' import { useSession } from '@/lib/auth/auth-client' import { getUpgradeCardCta, @@ -99,7 +100,7 @@ export function Upgrade({ workspaceId }: UpgradeProps) { return (
-
+
Back @@ -192,7 +193,7 @@ export function Upgrade({ workspaceId }: UpgradeProps) { return (
-
+
Back diff --git a/apps/sim/components/page-header-bar.ts b/apps/sim/components/page-header-bar.ts new file mode 100644 index 00000000000..1c1fb8e5ce9 --- /dev/null +++ b/apps/sim/components/page-header-bar.ts @@ -0,0 +1,25 @@ +/** + * Top padding for a bar sitting at the very top of the workspace content pane. + * + * Folds in `--workspace-content-title-bar-inset`, the height that pane must leave + * clear for the desktop shell's inset title bar. That variable is `0px` everywhere + * except the macOS desktop app with the sidebar collapsed — the one arrangement where + * the pane, rather than the sidebar, sits beneath the traffic lights and the sidebar + * expander. Without it a top bar draws underneath both, which hides its controls and + * can leave them unclickable. + * + * Compose this rather than writing `pt-[8.5px]`: the app has two top-bar geometries + * ({@link PAGE_HEADER_BAR} and the `Resource` header's bordered variant), and the lane + * math has to stay identical across them. + */ +export const TITLE_BAR_LANE_PT = 'pt-[calc(8.5px+var(--workspace-content-title-bar-inset))]' + +/** + * The top-of-page header bar worn by the surfaces that put a back chip, tab switcher, + * or page actions above their content. `Resource`-based pages (tables, files, logs, + * knowledge, scheduled tasks) use their own bordered bar and compose + * {@link TITLE_BAR_LANE_PT} directly. + * + * Single source of truth for this geometry — never re-derive it per page. + */ +export const PAGE_HEADER_BAR = `flex flex-shrink-0 items-center bg-[var(--bg)] px-4 ${TITLE_BAR_LANE_PT} pb-[8.5px]` diff --git a/apps/sim/components/settings/settings-header.tsx b/apps/sim/components/settings/settings-header.tsx index 8e5f92bc49f..98d507b3d01 100644 --- a/apps/sim/components/settings/settings-header.tsx +++ b/apps/sim/components/settings/settings-header.tsx @@ -13,7 +13,8 @@ import { useRef, useState, } from 'react' -import { Chip, ChipInput, ChipLink, Search, Tooltip } from '@sim/emcn' +import { Chip, ChipInput, ChipLink, cn, Search, Tooltip } from '@sim/emcn' +import { PAGE_HEADER_BAR } from '@/components/page-header-bar' const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect @@ -123,7 +124,7 @@ export function SettingsHeaderShell({ children }: { children: ReactNode }) { return (
-
+
{back ? ( configRef?.current.back?.onSelect()}> {back.text}