diff --git a/apps/sim/app/(auth)/auth-layout-client.tsx b/apps/sim/app/(auth)/auth-layout-client.tsx
deleted file mode 100644
index 57c83fa152b..00000000000
--- a/apps/sim/app/(auth)/auth-layout-client.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-'use client'
-
-import { usePathname } from 'next/navigation'
-import { DesktopTitleBarController } from '@/app/_shell/desktop-title-bar'
-import { AuthShell } from '@/app/(auth)/components'
-
-export default function AuthLayoutClient({ children }: { children: React.ReactNode }) {
- const isLogin = usePathname() === '/login'
-
- return (
- <>
- {isLogin && }
- {children}
- >
- )
-}
diff --git a/apps/sim/app/(auth)/components/auth-shell.tsx b/apps/sim/app/(auth)/components/auth-shell.tsx
index d4dd4f06521..36085dc52ad 100644
--- a/apps/sim/app/(auth)/components/auth-shell.tsx
+++ b/apps/sim/app/(auth)/components/auth-shell.tsx
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
-import { cn } from '@sim/emcn'
import Link from 'next/link'
+import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
import { LogoMark, SimWordmark } from '@/app/(landing)/components/navbar/components'
interface AuthShellProps {
@@ -8,8 +8,6 @@ interface AuthShellProps {
children: ReactNode
/** Optional element pinned to the bottom of the shell (e.g. the support footer). */
footer?: ReactNode
- /** Reserve the native macOS title-bar lane for the desktop login route. */
- reserveDesktopTitleBar?: boolean
}
/**
@@ -21,18 +19,19 @@ interface AuthShellProps {
* the canvas/`--text-primary` surface, and renders a logo-only header that reuses
* the landing {@link LogoMark} + {@link SimWordmark} at the same nav gutters. The
* single content column is centered and capped for a calm single-form layout.
+ *
+ * The shell also owns the macOS traffic-light lane, unconditionally — every surface that
+ * wears it (the `(auth)` routes, the CLI auth handoff, the invite pages) sits outside
+ * workspace chrome and draws its logo where the lights are. Gating this per route left
+ * whichever surface was overlooked drawing underneath them, and a route list could not
+ * cover a dynamic segment like `/invite/[id]` anyway. Off the desktop shell
+ * `--desktop-title-bar-height` is `0px`, so the reservation and the drag strip both
+ * collapse to nothing and `.desktop-title-bar-page` is exactly `min-h-screen`.
*/
-export function AuthShell({ children, footer, reserveDesktopTitleBar = false }: AuthShellProps) {
+export function AuthShell({ children, footer }: AuthShellProps) {
return (
-
- {reserveDesktopTitleBar && (
-
- )}
+
+
diff --git a/apps/sim/app/(auth)/layout.tsx b/apps/sim/app/(auth)/layout.tsx
index d0fdf75a493..833c56b430c 100644
--- a/apps/sim/app/(auth)/layout.tsx
+++ b/apps/sim/app/(auth)/layout.tsx
@@ -1,10 +1,10 @@
import type { Metadata } from 'next'
-import AuthLayoutClient from '@/app/(auth)/auth-layout-client'
+import { AuthShell } from '@/app/(auth)/components'
export const metadata: Metadata = {
robots: { index: false, follow: false },
}
export default function AuthLayout({ children }: { children: React.ReactNode }) {
- return {children}
+ return {children}
}
diff --git a/apps/sim/app/(auth)/reset-password/reset-password-content.tsx b/apps/sim/app/(auth)/reset-password/reset-password-content.tsx
index 1886557bf6f..e459ed070a3 100644
--- a/apps/sim/app/(auth)/reset-password/reset-password-content.tsx
+++ b/apps/sim/app/(auth)/reset-password/reset-password-content.tsx
@@ -80,7 +80,9 @@ function ResetPasswordContent() {
export default function ResetPasswordPage() {
return (
- Loading… }>
+
Loading… }
+ >
)
diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx
index dfd0428f30b..de337cc5c3e 100644
--- a/apps/sim/app/(auth)/signup/signup-form.tsx
+++ b/apps/sim/app/(auth)/signup/signup-form.tsx
@@ -490,7 +490,9 @@ export default function SignupPage({
emailSignupEnabled,
}: SignupFormProps) {
return (
- Loading…}>
+ Loading…}
+ >
+
+
{/* Header component */}
diff --git a/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx b/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx
index 8f55e4c5552..a964d796cb0 100644
--- a/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx
+++ b/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx
@@ -1,8 +1,10 @@
import { Skeleton } from '@sim/emcn'
+import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
export default function ChatLoading() {
return (
-
+
+
diff --git a/apps/sim/app/(interfaces)/chat/components/loading-state/loading-state.tsx b/apps/sim/app/(interfaces)/chat/components/loading-state/loading-state.tsx
index 178b20aac59..dc4fb3d9000 100644
--- a/apps/sim/app/(interfaces)/chat/components/loading-state/loading-state.tsx
+++ b/apps/sim/app/(interfaces)/chat/components/loading-state/loading-state.tsx
@@ -1,8 +1,10 @@
import { Skeleton } from '@sim/emcn'
+import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
export function ChatLoadingState() {
return (
-
+
+
diff --git a/apps/sim/app/(interfaces)/chat/components/voice-interface/voice-interface.tsx b/apps/sim/app/(interfaces)/chat/components/voice-interface/voice-interface.tsx
index eaab45992a2..242ea8bdff1 100644
--- a/apps/sim/app/(interfaces)/chat/components/voice-interface/voice-interface.tsx
+++ b/apps/sim/app/(interfaces)/chat/components/voice-interface/voice-interface.tsx
@@ -14,6 +14,7 @@ import {
MAX_CHAT_SESSION_MS,
SAMPLE_RATE,
} from '@/lib/speech/config'
+import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
const ParticlesVisualization = dynamic(
() =>
@@ -524,10 +525,11 @@ export function VoiceInterface({
return (
+
+
diff --git a/apps/sim/app/(landing)/components/logo-shell/logo-shell.tsx b/apps/sim/app/(landing)/components/logo-shell/logo-shell.tsx
index 545d2599da0..64c25cbc09d 100644
--- a/apps/sim/app/(landing)/components/logo-shell/logo-shell.tsx
+++ b/apps/sim/app/(landing)/components/logo-shell/logo-shell.tsx
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react'
import { cn } from '@sim/emcn'
import Link from 'next/link'
+import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
import { LogoMark, SimWordmark } from '@/app/(landing)/components/navbar/components'
/**
@@ -26,7 +27,8 @@ interface LogoShellProps {
export function LogoShell({ children, center = false, footer }: LogoShellProps) {
return (
-
+
+
diff --git a/apps/sim/app/_shell/desktop-title-bar-controller.test.tsx b/apps/sim/app/_shell/desktop-title-bar-controller.test.tsx
new file mode 100644
index 00000000000..97e176a88f9
--- /dev/null
+++ b/apps/sim/app/_shell/desktop-title-bar-controller.test.tsx
@@ -0,0 +1,86 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockGetDesktopBridge } = vi.hoisted(() => ({ mockGetDesktopBridge: vi.fn() }))
+
+vi.mock('@/lib/desktop', () => ({ getDesktopBridge: mockGetDesktopBridge }))
+
+import {
+ DESKTOP_TITLE_BAR_ATTRIBUTE,
+ DesktopTitleBarController,
+} from '@/app/_shell/desktop-title-bar'
+
+let container: HTMLDivElement
+let root: Root
+
+/**
+ * A bridge whose `getState` never settles, which is the window this guards: the gap
+ * between mount and the async state arriving is exactly when the old code clobbered
+ * whatever mode another owner had already established.
+ */
+function pendingBridge() {
+ return {
+ windowState: {
+ onStateChange: vi.fn(() => vi.fn()),
+ getState: vi.fn(() => new Promise(() => {})),
+ },
+ }
+}
+
+function mount() {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+ act(() => root.render( ))
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ document.documentElement.removeAttribute(DESKTOP_TITLE_BAR_ATTRIBUTE)
+ Object.defineProperty(navigator, 'userAgent', {
+ value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
+ configurable: true,
+ })
+})
+
+afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+ document.documentElement.removeAttribute(DESKTOP_TITLE_BAR_ATTRIBUTE)
+})
+
+describe('DesktopTitleBarController', () => {
+ it('leaves an established mode alone while its own state is still pending', () => {
+ mockGetDesktopBridge.mockReturnValue(pendingBridge())
+ // Native fullscreen, set by WorkspaceChrome. A lane-aware overlay mounting here used
+ // to seed `inset` first, snapping the traffic-light lane back on and jumping the
+ // content until the async state corrected it — or permanently, if `getState` rejected.
+ document.documentElement.setAttribute(DESKTOP_TITLE_BAR_ATTRIBUTE, 'fullscreen')
+
+ mount()
+
+ expect(document.documentElement.getAttribute(DESKTOP_TITLE_BAR_ATTRIBUTE)).toBe('fullscreen')
+ })
+
+ it('seeds inset when no owner has set a mode yet', () => {
+ mockGetDesktopBridge.mockReturnValue(pendingBridge())
+
+ mount()
+
+ expect(document.documentElement.getAttribute(DESKTOP_TITLE_BAR_ATTRIBUTE)).toBe('inset')
+ })
+
+ it('clears the marker off the desktop shell', () => {
+ mockGetDesktopBridge.mockReturnValue(null)
+ document.documentElement.setAttribute(DESKTOP_TITLE_BAR_ATTRIBUTE, 'inset')
+
+ mount()
+
+ expect(document.documentElement.hasAttribute(DESKTOP_TITLE_BAR_ATTRIBUTE)).toBe(false)
+ })
+})
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 4b0c822220d..dd06b4dd8df 100644
--- a/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts
+++ b/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts
@@ -1,19 +1,42 @@
/**
* @vitest-environment node
*/
-import { readFileSync } from 'node:fs'
+import { readdirSync, readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
-/** Anchored to this file, not `process.cwd()`, which only resolves from `apps/sim`. */
-const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), 'utf8')
+/** Raw file contents, anchored to this file rather than `process.cwd()`. */
+const readRaw = (relativePath: string) =>
+ readFileSync(new URL(relativePath, import.meta.url), 'utf8')
-const authLayout = read('../(auth)/auth-layout-client.tsx')
+/**
+ * Source with comments removed.
+ *
+ * EVERY assertion here runs on stripped source — which is why {@link read} strips at the
+ * point of reading rather than leaving it to each callsite. These files document the very
+ * shapes they enforce, in both directions:
+ *
+ * - a negative like `not.toContain('min-h-screen')` matches the prose explaining why
+ * `min-h-screen` is gone, and fails on correct code;
+ * - a positive like `toContain('desktop-title-bar-page')` matches the TSDoc naming the
+ * class, and passes even after the class is deleted from the markup.
+ *
+ * The second is the dangerous one, and stripping only negatives left it live.
+ */
+const stripComments = (source: string) =>
+ source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
+
+/** Every audit constant below reads through this, so no assertion can match prose. */
+const read = (relativePath: string) => stripComments(readRaw(relativePath))
+
+const authLayout = read('../(auth)/layout.tsx')
const authShell = read('../(auth)/components/auth-shell.tsx')
const workspaceChrome = read(
'../workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx'
)
const sidebar = read('../workspace/[workspaceId]/w/components/sidebar/sidebar.tsx')
const globalStyles = read('../_styles/globals.css')
+const desktopTitleBar = read('../_shell/desktop-title-bar.tsx')
+const logoShell = read('../(landing)/components/logo-shell/logo-shell.tsx')
const pageHeaderBar = read('../../components/page-header-bar.ts')
const resourceHeader = read(
'../workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx'
@@ -23,18 +46,32 @@ const mothershipView = read(
)
describe('desktop title-bar surface audit', () => {
- it('applies the safe-area shell only when the auth route is login', () => {
- expect(authLayout).toContain("usePathname() === '/login'")
- expect(authLayout).toContain('reserveDesktopTitleBar={isLogin}')
- expect(authShell).toContain(
- "reserveDesktopTitleBar ? 'desktop-title-bar-page' : 'min-h-screen'"
- )
+ it('reserves the lane for every surface wearing the auth shell', () => {
+ // Signup, reset-password, sso, verify, the CLI handoff and the invite pages all wear
+ // this shell and sit under the same traffic lights. Reserving only on /login left the
+ // rest drawing their logo beneath them; per-route gating could not cover
+ // `/invite/[id]` either, so the shell owns the lane unconditionally.
+ expect(authShell).toContain('desktop-title-bar-page')
+ expect(authShell).toContain(' ')
+ expect(authShell).not.toContain('reserveDesktopTitleBar')
+ expect(authShell).not.toContain('min-h-screen')
+ expect(authLayout).toContain('')
+ })
+
+ it('keeps the lane on the shell the non-auth desktop surfaces wear', () => {
+ // `LogoShell` backs not-found, the interfaces shell (chat, resume), the desktop handoff
+ // and the public-file gates. It needs its own assertion rather than relying on the
+ // enumeration: it sits under the `app/(landing)/` prefix allowlist, and stripping its
+ // reservation would also strip its last viewport token, so the sweep would fall silent
+ // on the very shell those surfaces depend on.
+ expect(logoShell).toContain('desktop-title-bar-page')
+ expect(logoShell).toContain(' ')
})
it('mounts a real drag surface across login and workspace title-bar lanes', () => {
const dragRegion = globalStyles.match(/\.desktop-window-drag-region\s*\{([^}]*)\}/)?.[1]
- expect(authShell).toContain('desktop-login-window-drag-region')
+ expect(desktopTitleBar).toContain('desktop-login-window-drag-region')
expect(workspaceChrome).toContain('desktop-workspace-window-drag-region')
expect(workspaceChrome).toContain("isCollapsed ? 'h-[var(--desktop-title-bar-height)]' : 'h-2'")
// The sidebar's lane strip composes the same two classes instead of
@@ -101,12 +138,7 @@ describe('desktop title-bar surface audit', () => {
// (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,
- ''
- )
+ const rule = globalStyles.match(/\.desktop-title-bar-page \{[^}]*\}/)?.[0] ?? ''
expect(rule).toContain('padding-top: var(--desktop-title-bar-height)')
expect(rule).toContain('min-height: 100vh')
expect(rule).not.toContain('margin-top')
@@ -139,3 +171,200 @@ describe('desktop title-bar surface audit', () => {
expect(resourceHeader).not.toMatch(/py-\[8\.5px\]/)
})
})
+
+/**
+ * Every full-viewport page root outside workspace chrome, and why it is safe.
+ *
+ * A root that fills the viewport and is NOT lane-aware draws its top chrome underneath
+ * the macOS traffic lights, because the pre-paint script marks the lane on every desktop
+ * route whether or not the page reserves it. That is one bug that has now surfaced four
+ * separate times — workspace headers, signup, the CLI handoff, the OAuth error page —
+ * each found by a person hitting it rather than by a check.
+ *
+ * So the check enumerates instead of listing what to look at: any new full-viewport root
+ * fails here until it either composes `.desktop-title-bar-page` or is added below with a
+ * reason.
+ *
+ * Known limit: a root is in scope because of how it claims the viewport, so deleting the
+ * reservation outright — class gone, nothing put back — also drops the file from the check.
+ * That regression is loud rather than silent (the surface stops being full height, which is
+ * plainly visible), and closing it properly means treating every route entry point as a
+ * window root, which pulls in seven account/organization/selfhost pages that each need
+ * their own assessment. Worth doing; not worth guessing at here. Reaching for this allowlist should feel like a claim you have to defend — the
+ * entry that read "marketing chrome, not reachable in the desktop shell" was false, and
+ * hid four live surfaces behind one unverified sentence.
+ *
+ * Granularity is per FILE, not per JSX root: a file holding two full-viewport roots still
+ * passes if only one reserves the lane. Catching that needs an AST pass, which is not
+ * worth the weight here — the check's job is to stop a whole surface being forgotten,
+ * which is how every instance of this bug actually shipped.
+ */
+const LANE_EXEMPT: Record = {
+ 'app/(landing)/components/landing-shell/landing-shell.tsx':
+ 'Marketing chrome. Verified: every consumer lives under app/(landing)/, and the desktop shell boots to /login or a workspace with no path to those routes.',
+ 'app/playground/page.tsx':
+ 'Verified dev-only: the page calls notFound() unless NEXT_PUBLIC_ENABLE_PLAYGROUND is set.',
+ 'app/workspace/[workspaceId]/components/session-expired/session-expired.tsx':
+ 'Centres its content (`items-center justify-center`) with no chrome in the lane, and is a transient sign-out notice.',
+ 'app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx':
+ 'The `fixed inset-0` here is the scrim, which carries no content. The panel itself is separately positioned at `top-[15%]`, well clear of the lane.',
+ 'app/_shell/desktop-update-gate.tsx':
+ 'Blocking overlay that centres its content, with no chrome in the lane. The lights stay usable regardless: under `titleBarStyle: hiddenInset` macOS draws them above the web contents, so web UI cannot cover them — this bug class is app chrome sitting *under* the lights, not the reverse.',
+ 'app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx':
+ 'Renders , which owns the workspace lane and its drag region (sidebar.tsx). Padding this root too would double the reservation.',
+}
+
+/**
+ * Shells that reserve the lane for whatever they wrap.
+ *
+ * Matched as JSX usage (` = {
+ 'app/(landing)/':
+ 'Marketing routes and their overlays. Verified: the desktop shell boots to /login or a workspace and has no navigation path here. `logo-shell.tsx` lives under this prefix but is lane-aware on its own merits, since it is used well outside landing.',
+}
+
+const isExempt = (file: string) =>
+ file in LANE_EXEMPT || Object.keys(LANE_EXEMPT_PREFIXES).some((prefix) => file.startsWith(prefix))
+
+/**
+ * Surfaces a layout returns *instead of* its lane-aware chrome, not inside it.
+ *
+ * Ancestor resolution is static, so it credits any file under a layout that mentions a
+ * lane-aware shell. That is wrong for an early return: `workspace/[workspaceId]/layout.tsx`
+ * returns ` ` at the top and only reaches `` much
+ * later, so at runtime the denied page has no chrome and must reserve for itself. Without
+ * this, a regression there would read as inherited and pass.
+ *
+ * `SessionExpired` is deliberately absent: it renders as a sibling *within* the chrome
+ * tree, so its inherited coverage is real.
+ */
+const SELF_RESERVE_REQUIRED = new Set([
+ // Both of its render paths bypass the chrome, so its own reservation is the only one:
+ // `layout.tsx` returns it before reaching ``, and
+ // `WorkspaceHostProvider` — an ancestor of the chrome, not a descendant — returns it
+ // instead of its children on a client-side 403. Neither is a double reservation.
+ 'app/workspace/[workspaceId]/components/workspace-access-denied.tsx',
+])
+
+/** Every file under `app/`, so ancestor layouts can be resolved without extra fs calls. */
+const ALL_APP_FILES = new Set(
+ readdirSync(new URL('../', import.meta.url), { recursive: true, encoding: 'utf8' }).map(
+ (f) => `app/${f}`
+ )
+)
+
+/** The `layout.tsx` files wrapping a route, nearest first. */
+function ancestorLayouts(file: string): string[] {
+ const parts = file.split('/')
+ const layouts: string[] = []
+ for (let i = parts.length - 1; i > 0; i--) {
+ const candidate = `${parts.slice(0, i).join('/')}/layout.tsx`
+ if (candidate !== file && ALL_APP_FILES.has(candidate)) layouts.push(candidate)
+ }
+ return layouts
+}
+
+/**
+ * A file that DEFINES a lane-aware shell, rather than wearing one.
+ *
+ * Such a file sits under the very layout that renders it, so ancestor resolution would
+ * call it nested inside itself and report a double reservation. The shell is the reason
+ * that layout is lane-aware in the first place.
+ */
+const DEFINES_LANE_SHELL = /export function (AuthShell|LogoShell|InterfacesShell|WorkspaceChrome)\b/
+
+const reservesLane = (source: string) =>
+ source.includes('desktop-title-bar-page') || LANE_AWARE_SHELL_USAGE.test(source)
+
+const sourceOf = (file: string) => read(`../${file.slice('app/'.length)}`)
+
+/** Full-viewport roots outside a lane-aware layout, paired with whether they reserve. */
+function viewportRoots() {
+ return [...ALL_APP_FILES]
+ .filter((f) => f.endsWith('.tsx'))
+ .map((file) => {
+ const source = sourceOf(file)
+ return {
+ file,
+ fillsViewport: FILLS_VIEWPORT.test(source),
+ self: reservesLane(source),
+ escapesPadding: ESCAPES_ANCESTOR_PADDING.test(source),
+ inherited:
+ !SELF_RESERVE_REQUIRED.has(file) &&
+ !DEFINES_LANE_SHELL.test(source) &&
+ !ESCAPES_ANCESTOR_PADDING.test(source) &&
+ ancestorLayouts(file).some((l) => reservesLane(sourceOf(l))),
+ }
+ })
+ .filter((r) => r.fillsViewport)
+}
+
+describe('desktop traffic-light lane coverage', () => {
+ it('leaves no full-viewport root unaccounted for', () => {
+ // Covered means the root reserves the lane itself OR sits inside a layout that does —
+ // chat and the workspace overlays inherit theirs, and requiring the file itself to
+ // reserve would force a second, doubled reservation on every one of them.
+ const unaccounted = viewportRoots()
+ .filter((r) => !r.self && !r.inherited && !isExempt(r.file))
+ .map((r) => r.file)
+
+ expect(unaccounted).toEqual([])
+ })
+
+ it('never reserves the lane twice by nesting', () => {
+ // A root inside a lane-aware layout that reserves again gets two lots of padding, two
+ // drag strips and two controllers. Shipped exactly that on the resume loading skeleton,
+ // and the file-local check could not see it.
+ const doubled = viewportRoots()
+ .filter((r) => r.self && r.inherited && !r.escapesPadding)
+ .map((r) => r.file)
+
+ expect(doubled).toEqual([])
+ })
+
+ it('never reserves the lane without also making it draggable', () => {
+ const appDir = new URL('../', import.meta.url)
+ const orphaned = readdirSync(appDir, { recursive: true, encoding: 'utf8' })
+ .filter((f) => f.endsWith('.tsx'))
+ .map((f) => `app/${f}`)
+ .filter((file) => {
+ const source = read(`../${file.slice('app/'.length)}`)
+ // The class alone clears the lights but leaves the strip undraggable, so the
+ // window loses its title bar on that page. Shipped that way twice in this PR.
+ return source.includes('desktop-title-bar-page') && !/ {
- it('reserves traffic-light space only for desktop login on macOS', () => {
- expect(supportsDesktopTitleBar('/login', 'Macintosh', true)).toBe(true)
- expect(supportsDesktopTitleBar('/workspace/ws/home', 'Macintosh', true)).toBe(false)
- expect(supportsDesktopTitleBar('/signup', 'Macintosh', true)).toBe(false)
- expect(supportsDesktopTitleBar('/desktop/connect', 'Macintosh', true)).toBe(false)
- expect(supportsDesktopTitleBar('/login', 'Windows NT 10.0', true)).toBe(false)
- expect(supportsDesktopTitleBar('/login', 'Macintosh', false)).toBe(false)
+ it('reserves traffic-light space on macOS desktop, and nowhere else', () => {
+ // No route check by design — mounting the controller is the signal, and only
+ // `AuthShell` mounts it. A route list could not cover `/invite/[id]`.
+ expect(supportsDesktopTitleBar('Macintosh', true)).toBe(true)
+ expect(supportsDesktopTitleBar('Windows NT 10.0', true)).toBe(false)
+ expect(supportsDesktopTitleBar('Macintosh', false)).toBe(false)
})
it('sets, updates, and removes the shared document marker', () => {
diff --git a/apps/sim/app/_shell/desktop-title-bar.tsx b/apps/sim/app/_shell/desktop-title-bar.tsx
index 0e2dc1971f0..8a8aa01f7b6 100644
--- a/apps/sim/app/_shell/desktop-title-bar.tsx
+++ b/apps/sim/app/_shell/desktop-title-bar.tsx
@@ -1,18 +1,26 @@
'use client'
import { useEffect } from 'react'
-import { usePathname } from 'next/navigation'
import { getDesktopBridge } from '@/lib/desktop'
export type DesktopTitleBarMode = 'fullscreen' | 'inset' | null
-/** Only the macOS desktop login route reserves space outside workspace chrome. */
-export function supportsDesktopTitleBar(
- pathname: string,
- userAgent: string,
- hasDesktopBridge: boolean
-): boolean {
- return hasDesktopBridge && /Mac/i.test(userAgent) && pathname === '/login'
+/** The document marker every lane owner reads and writes. */
+export const DESKTOP_TITLE_BAR_ATTRIBUTE = 'data-sim-desktop-title-bar'
+
+/**
+ * Whether this surface reserves the macOS traffic-light lane itself.
+ *
+ * There is no route check: the caller mounting {@link DesktopTitleBarController} IS the
+ * signal. Only `AuthShell` mounts it, and every surface wearing that shell — the `(auth)`
+ * routes, the CLI auth handoff, the invite pages — sits outside workspace chrome and must
+ * clear the lights. Workspace routes never render it; `WorkspaceChrome` owns the lane
+ * there through its own listener, and two owners would fight over the attribute.
+ *
+ * A route list was the previous shape and could not survive `/invite/[id]`.
+ */
+export function supportsDesktopTitleBar(userAgent: string, hasDesktopBridge: boolean): boolean {
+ return hasDesktopBridge && /Mac/i.test(userAgent)
}
export function applyDesktopTitleBarMode(
@@ -20,29 +28,56 @@ export function applyDesktopTitleBarMode(
mode: DesktopTitleBarMode
): void {
if (mode === null) {
- root.removeAttribute('data-sim-desktop-title-bar')
+ root.removeAttribute(DESKTOP_TITLE_BAR_ATTRIBUTE)
return
}
- root.setAttribute('data-sim-desktop-title-bar', mode)
+ root.setAttribute(DESKTOP_TITLE_BAR_ATTRIBUTE, mode)
}
/**
- * Keeps the macOS login inset correct across native fullscreen transitions.
- * Workspace routes retain their existing WorkspaceChrome-owned listener.
+ * Reserves the macOS traffic-light lane for a full-viewport surface outside workspace
+ * chrome. Pair it with `desktop-title-bar-page` on the surface's own root.
+ *
+ * The two halves ship together on purpose. Reserving the space without this leaves the
+ * lane visually clear but undraggable — the window loses its title bar on that page —
+ * and that is exactly what happened when `/oauth-error` and the public-file view were
+ * given the class alone. The surface audit enforces the pairing.
*/
-export function DesktopTitleBarController() {
- const pathname = usePathname()
+export function DesktopTitleBarLane() {
+ return (
+ <>
+
+
+ >
+ )
+}
+/**
+ * Keeps the inset correct across native fullscreen transitions, where the traffic lights
+ * disappear and the lane must collapse. Rendered by `AuthShell`; workspace routes retain
+ * their existing WorkspaceChrome-owned listener.
+ */
+export function DesktopTitleBarController() {
useEffect(() => {
const bridge = getDesktopBridge()
const root = document.documentElement
- if (!supportsDesktopTitleBar(pathname, navigator.userAgent, Boolean(bridge))) {
+ if (!supportsDesktopTitleBar(navigator.userAgent, Boolean(bridge))) {
applyDesktopTitleBarMode(root, null)
return
}
const windowState = bridge?.windowState
- applyDesktopTitleBarMode(root, 'inset')
+ /**
+ * Seed `inset` only when nobody has established a mode yet.
+ *
+ * The pre-paint script sets this before first paint and `WorkspaceChrome` maintains it
+ * for workspace routes, so writing unconditionally clobbered whatever they had:
+ * mounting a lane-aware overlay during native fullscreen forced the traffic-light lane
+ * back on and jumped the content, and left it wrong entirely if `getState()` rejected.
+ */
+ if (!root.hasAttribute(DESKTOP_TITLE_BAR_ATTRIBUTE)) {
+ applyDesktopTitleBarMode(root, 'inset')
+ }
if (!windowState) return
let disposed = false
@@ -61,7 +96,7 @@ export function DesktopTitleBarController() {
disposed = true
unsubscribe()
}
- }, [pathname])
+ }, [])
return null
}
diff --git a/apps/sim/app/f/[token]/public-file-view.tsx b/apps/sim/app/f/[token]/public-file-view.tsx
index 5d1db37b68e..d9da924dfce 100644
--- a/apps/sim/app/f/[token]/public-file-view.tsx
+++ b/apps/sim/app/f/[token]/public-file-view.tsx
@@ -5,6 +5,7 @@ import { Chip } from '@sim/emcn'
import { Download } from '@sim/emcn/icons'
import Link from 'next/link'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
+import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
import { SimWordmark } from '@/app/(landing)/components/navbar/components'
import { buildProvenance } from '@/app/f/[token]/utils'
import { FileViewer } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
@@ -65,8 +66,9 @@ export function PublicFileView({
)
return (
-
-
+
+
+
{!brand.logoUrl && (
<>
diff --git a/apps/sim/app/oauth-error/page.tsx b/apps/sim/app/oauth-error/page.tsx
index 8b85f0bab51..6af0b5e71cd 100644
--- a/apps/sim/app/oauth-error/page.tsx
+++ b/apps/sim/app/oauth-error/page.tsx
@@ -1,4 +1,5 @@
import type { Metadata } from 'next'
+import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
export const metadata: Metadata = {
title: 'Sign-in couldn’t be completed',
@@ -36,7 +37,8 @@ export default async function OAuthErrorPage({ searchParams }: OAuthErrorPagePro
const code = typeof params.error === 'string' ? params.error : undefined
return (
-
+
+
Couldn’t complete that
{messageForError(code)}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-access-denied.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-access-denied.tsx
index cf391833684..99a54bc57b0 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/workspace-access-denied.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-access-denied.tsx
@@ -1,9 +1,11 @@
import { ChipLink } from '@sim/emcn'
import { CircleAlert } from '@sim/emcn/icons'
+import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
export function WorkspaceAccessDenied() {
return (
-
+
+
diff --git a/apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx
index cbed424d13b..2ffdcd6d9bb 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx
@@ -2,6 +2,7 @@
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
+import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
import { useWorkspaceFileRecord } from '@/hooks/queries/workspace-files'
const logger = createLogger('FileViewer')
@@ -20,7 +21,8 @@ export function FileViewer() {
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace&t=${file.size}`
return (
-