mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(desktop): clear the traffic lights on every full-viewport surface, and enumerate them in CI (#6109)
* fix(desktop): clear the traffic lights on every auth-shell surface
Only /login reserved the macOS traffic-light lane, so signup drew its logo
underneath the lights — and so did reset-password, sso, verify, the CLI auth
handoff, and the invite pages. The pre-paint script marks the lane on every
desktop route, so any surface that did not reserve it overlapped.
Ownership moves to `AuthShell`, which is the single source of truth for the frame
all of these wear, and it now reserves unconditionally. Per-route gating was the
wrong shape rather than merely incomplete: `/invite/[id]` is a dynamic segment, so
no route list could have covered it. `supportsDesktopTitleBar` therefore drops its
pathname argument — the caller mounting the controller is the signal, and only
`AuthShell` mounts it. Workspace routes never render it and keep their existing
`WorkspaceChrome`-owned listener, so the two never contend for the attribute.
Off the desktop shell `--desktop-title-bar-height` is `0px`, so the reservation and
the drag strip collapse to nothing and `.desktop-title-bar-page` is exactly the
`min-h-screen` these surfaces had before — web is unchanged. With the prop gone the
client auth layout was a bare passthrough, so the route layout renders the shell
directly and the passthrough is deleted.
Measured in the Electron renderer over CDP across /signup, /login, /reset-password
and /cli/auth: lane 40px, logo top 56px, zero overflow on each. /invite/[id]
redirects to login when signed out and was not measured directly.
The surface audit gains a `stripComments` helper that every negative assertion runs
through. These files document the shapes they avoid, so a bare `not.toContain` was
matching the prose explaining the fix and failing on correct code.
* fix(desktop): cover the remaining traffic-light overlaps, and enumerate them in CI
Fixing signup by hand would have been the fourth time this bug was found by a person
hitting it. The audit now enumerates instead of listing what to inspect: it walks every
`.tsx` outside workspace chrome, flags each full-viewport root, and fails unless that
root either composes `.desktop-title-bar-page` or appears in an allowlist with a written
reason. A brand-new page that fills the viewport fails on arrival — verified by adding
one, and by reverting each fix below.
Running it found three more surfaces already overlapping:
- `/oauth-error` is Better Auth's `onAPIError.errorURL`, which is precisely where desktop
OAuth failures land, so the one page a user sees when sign-in breaks drew its content
under the lights.
- `/f/[token]` public file view, same shell family, same origin, reachable in the window.
- The signup and reset-password Suspense fallbacks are viewport-tall *inside* the
lane-reserving shell, so the page overflowed by the lane while the split chunk loaded.
A placeholder needs no viewport height.
Four surfaces are allowlisted with reasons: the two landing shells (the desktop shell
boots to /login or a workspace and has no path to marketing routes), the dev-only
playground, and the embedded resume interface.
Measured over CDP: /oauth-error reserves 40px with zero overflow.
* fix(desktop): cover the shells behind the allowlist, and make the guard fail
Greptile was right on both counts, and the first one is worse than reported.
`LogoShell` was allowlisted as "marketing chrome, not reachable in the desktop
shell". That claim was simply false: it is the frame for `not-found`, the interfaces
shell, the desktop handoff shell, and the public-file access gates — so the password,
email, and SSO gates for `/f/[token]` all still drew under the traffic lights. The
allowlist existed to make risk visible and instead hid four surfaces behind one
unverified sentence. It now carries two entries, both checked: the landing shell (every
consumer lives under `app/(landing)/`) and the playground (calls `notFound()` unless
`NEXT_PUBLIC_ENABLE_PLAYGROUND` is set).
The lane's two halves also travelled separately, so `/oauth-error` and the public-file
view reserved the space without the drag strip — clearing the lights but leaving the
window with no title bar on those pages. `DesktopTitleBarLane` now ships both together
and the audit enforces the pairing.
Both new checks were unfailable when first written, and mutation testing is the only
reason that surfaced:
- the pairing check matched `DesktopTitleBarLane` anywhere in the file, so the import
line satisfied it after the JSX was deleted;
- the coverage check matched `LogoShell` anywhere, so a shell's own definition file
self-certified as covered.
Both now match JSX usage (`/<LogoShell\b/`). Reverting either fix, and deleting either
half of the lane, now fails.
Measured over CDP: /oauth-error and the LogoShell-based 404 each reserve 40px, carry the
drag strip, and overflow by zero, with the logo at 56px.
* test(desktop): strip comments at read time so positive assertions can fail
Cursor caught the mirror of a trap this file already documents. `stripComments` was
applied to negative assertions only, so a positive like
`toContain('desktop-title-bar-page')` still ran on raw source — and `AuthShell`'s TSDoc
names that class, so deleting it from the markup left the assertion passing on a broken
lane reservation.
Stripping now happens in `read`, so every audit constant is comment-free and no
assertion in either direction can match prose. Verified: deleting the class from the
markup while leaving the TSDoc intact now fails two tests, where it previously failed
none.
* fix(desktop): audit workspace routes too, and cover the three that bypass the chrome
Greptile was right that the blanket `workspace/` exclusion was load-bearing in the wrong
direction. It assumed every workspace route reaches the lane through `WorkspaceChrome`,
and three do not: the workspace landing route, the access-denied early return, and the
workflow error boundary.
The exclusion is gone, so workspace files are audited like everything else and
`WorkspaceChrome` joins the lane-aware shells — the normal route passes through its
layout, and the three exceptions had to be dealt with on their merits:
- the landing route (status card and spinner) and access-denied now reserve the lane;
- the error boundary is allowlisted, verified: it renders `<Sidebar>`, which already owns
the workspace lane and its drag region, so padding that root would double it.
Their content was centred, so the lights were never covering text — the real gap was that
none of them rendered a drag strip, leaving the window immovable on those screens.
The guard's granularity is per file, not per JSX root: `workspace/page.tsx` holds two
full-viewport roots and still passes if only one reserves the lane. Verified by mutation
and documented rather than papered over — catching it needs an AST pass, and the check's
job is to stop a whole surface being forgotten, which is how every instance of this bug
has actually shipped.
* fix(desktop): teach the audit about nesting, and stop the resume skeleton double-reserving
Four findings, all correct, and the first is a bug this PR introduced.
The resume loading skeleton reserved the lane while already rendering inside
`(interfaces)/layout.tsx` -> `InterfacesShell` -> `LogoShell`, which reserves it too. Two
lots of padding, two drag strips, two controllers. It came from adding the lane there
before `LogoShell` became lane-aware and never reconciling the two. The skeleton now
reserves nothing and is no longer viewport-tall either — nesting a viewport-tall root
inside a viewport-tall shell overflowed even before this PR.
The public-file header pinned `sticky top-0`, which parks it inside the reserved lane and
under the lights. It now sticks below the lane, inert on web where the variable is `0px`.
Both audit gaps were real:
- The check was file-local, so it could not see the doubling above. It now resolves
ancestor layouts: a root counts as covered when it reserves OR sits inside a layout that
does, and reserving on both levels is its own failure. That also stops the check
demanding a second reservation from chat and the workspace overlays, which correctly
inherit theirs.
- Detection only matched `min-h-screen`/`h-screen`, so `fixed inset-0` roots never
entered it. Now included. With nesting understood, that addition resolved to a single
genuinely uncovered file rather than the ten it flagged beforehand.
Two allowlist entries added, both reasoned rather than assumed: the landing prefix (dozens
of files, one justification), and the desktop update gate — it centres its content, and
under `hiddenInset` macOS draws the lights above the web contents, so web UI cannot cover
them. This bug class is app chrome sitting under the lights, never the reverse.
Verified by mutation: reintroducing the double reservation fails the new check.
* test(desktop): do not credit inherited coverage across a layout's early return
Ancestor resolution is static, so it credits any file under a layout that mentions a
lane-aware shell. That is wrong when the layout returns the surface *instead of* its
chrome: `workspace/[workspaceId]/layout.tsx` returns `<WorkspaceAccessDenied />` at the
top and only reaches `<WorkspaceChrome>` far below, so at runtime the denied page has no
chrome at all. The page does reserve the lane today, but a regression would have read as
inherited and passed.
`SessionExpired` is deliberately not listed: it renders as a sibling within the chrome
tree, so its inherited coverage is real. The distinction is which side of the early return
the surface sits on, not which directory it lives in.
Verified by reverting the access-denied page exactly as described — it now fails.
* test(desktop): count the lane class itself as a viewport claim
Cursor caught the audit failing to watch exactly the files this PR converted. Detection
keyed on `min-h-screen`/`h-screen`/`fixed inset-0`, but converting a surface to
`.desktop-title-bar-page` removes those tokens — the class supplies `min-height: 100vh`
itself. So `/oauth-error`, the public-file view and `AuthShell` dropped out of the check
entirely, and a nested class-only reservation could ship green.
That also means the doubled-reservation check had never actually fired. The mutation I
used to "verify" it removed the lane component as well, so the pairing check caught it and
the doubled check was never exercised. It now fires on Cursor's exact scenario: a nested
class-only reservation, correctly paired, inside a lane-aware shell.
Pulling those files back in exposed a second-order bug: a shell's own definition file sits
under the layout that renders it, so ancestor resolution called `AuthShell` nested inside
itself. Shell definitions are excluded from inheritance.
One limit stays, documented rather than papered over: a root is in scope because of how it
claims the viewport, so deleting the reservation outright drops the file from the check.
That regression is loud, not silent — the surface stops being full height. Closing it
properly means treating every route entry point as a window root, which pulls in seven
account/organization/selfhost pages needing individual assessment. Worth doing separately;
allowlisting them on assumptions is the mistake that produced the `LogoShell` hole.
* fix(desktop): a fixed root escapes ancestor padding — chat drew under the lights
Cursor's sharpest catch, and the audit was actively hiding the bug rather than
missing it. `position: fixed` resolves against the viewport, not the parent, so a
lane-aware shell's `padding-top` never moves it. The chat surfaces sit inside
`LogoShell` and still painted at viewport top, under the traffic lights, while the
check reported them covered — and adding the correct reservation would then have
tripped the nested-reservation check, so the audit pushed toward the wrong answer.
Roots matching `fixed inset-0` no longer inherit coverage and are exempt from the
doubled check. That reclassified seven surfaces, each decided on evidence:
- chat, its loading boundary, the loading state and the voice interface are
full-window roots at `z-[100]` with their own top chrome — all now reserve;
- the file viewer wraps a full-bleed `<iframe>` whose content starts at viewport
top — now reserves;
- session-expired centres its content with nothing in the lane, and the search
modal's `fixed inset-0` is only its scrim (the panel sits at `top-[15%]`) — both
allowlisted with that reasoning rather than an assumption.
Verified by reverting chat to a bare `fixed inset-0` inside `LogoShell`: it is
flagged now and was called covered before.
* fix(desktop): stop the lane controller clobbering a mode another owner set
A regression I introduced last round. `DesktopTitleBarController` seeded `inset`
unconditionally on mount, before its own `getState()` resolved. That was harmless while
only `AuthShell` mounted it — but giving the file viewer a lane put a controller inside
workspace for the first time, exactly where this PR's own comments say `WorkspaceChrome`
owns the mode. Opening a file during native fullscreen therefore snapped the traffic-light
lane back on and jumped the content, and left it wrong permanently if `getState()`
rejected, since the rejection is swallowed.
It now seeds only when no owner has established a mode. The pre-paint script sets the
marker before first paint and `WorkspaceChrome` maintains it, so the unconditional write
was never the thing making the lane correct — it was only ever able to make it wrong.
Adds a controller test covering the case directly: mount during `fullscreen` with a
`getState` that never settles, and the mode survives. Verified it fails against the
previous behaviour.
* test(desktop): assert the lane on LogoShell directly, not via the sweep
Cursor found the one shell the enumeration could go silent on. `LogoShell` sits under the
`app/(landing)/` prefix allowlist, and this PR replaced its `min-h-screen` with
`desktop-title-bar-page` — so stripping the reservation also strips its last viewport
token and the sweep stops watching it. Doubly exempt, and it is the shell behind
not-found, the interfaces shell (chat, resume), the desktop handoff and the public-file
gates.
`AuthShell` already had a dedicated assertion; `LogoShell` now has the same. Verified by
stripping its reservation: caught now, silent before.
This is the documented enumeration limit made concrete rather than a new class of problem
— a root leaves the sweep when it stops claiming the viewport. The general fix is still to
treat route entry points as window roots, which pulls in seven unassessed pages and
belongs in its own change.
* docs(desktop): record why access-denied reserving is not a double reservation
Review read `WorkspaceHostProvider` as rendering the denied page inside the chrome. It is
the other way round: the provider wraps `<WorkspaceChrome>` in the layout, and its 403
branch returns the denied page instead of its children, so the chrome never mounts. The
server-side early return does the same thing higher up.
Both paths therefore bypass the chrome and the page's own reservation is the only one.
Recording that next to the entry, since the file lives under a workspace path where
inherited coverage is the norm and the reading is an easy one to repeat.
This commit is contained in:
@@ -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 && <DesktopTitleBarController />}
|
||||
<AuthShell reserveDesktopTitleBar={isLogin}>{children}</AuthShell>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'light relative flex flex-col bg-[var(--bg)] text-[var(--text-primary)]',
|
||||
reserveDesktopTitleBar ? 'desktop-title-bar-page' : 'min-h-screen'
|
||||
)}
|
||||
>
|
||||
{reserveDesktopTitleBar && (
|
||||
<div aria-hidden className='desktop-login-window-drag-region desktop-window-drag-region' />
|
||||
)}
|
||||
<div className='light desktop-title-bar-page relative flex flex-col bg-[var(--bg)] text-[var(--text-primary)]'>
|
||||
<DesktopTitleBarLane />
|
||||
<header>
|
||||
<nav className='mx-auto flex w-full max-w-[1446px] items-center px-12 py-4 max-sm:px-5 max-lg:px-8'>
|
||||
<Link href='/' aria-label='Sim home' className='flex h-[30px] items-center'>
|
||||
|
||||
@@ -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 <AuthLayoutClient>{children}</AuthLayoutClient>
|
||||
return <AuthShell>{children}</AuthShell>
|
||||
}
|
||||
|
||||
@@ -80,7 +80,9 @@ function ResetPasswordContent() {
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className='flex h-screen items-center justify-center'>Loading…</div>}>
|
||||
<Suspense
|
||||
fallback={<div className='flex min-h-[320px] items-center justify-center'>Loading…</div>}
|
||||
>
|
||||
<ResetPasswordContent />
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -490,7 +490,9 @@ export default function SignupPage({
|
||||
emailSignupEnabled,
|
||||
}: SignupFormProps) {
|
||||
return (
|
||||
<Suspense fallback={<div className='flex h-screen items-center justify-center'>Loading…</div>}>
|
||||
<Suspense
|
||||
fallback={<div className='flex min-h-[320px] items-center justify-center'>Loading…</div>}
|
||||
>
|
||||
<SignupFormContent
|
||||
githubAvailable={githubAvailable}
|
||||
googleAvailable={googleAvailable}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
AGENT_STREAM_PROTOCOL_HEADER,
|
||||
AGENT_STREAM_PROTOCOL_V1,
|
||||
} from '@/lib/workflows/streaming/agent-stream-protocol'
|
||||
import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
|
||||
import {
|
||||
ChatErrorState,
|
||||
ChatHeader,
|
||||
@@ -440,7 +441,8 @@ export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='light fixed inset-0 z-[100] flex flex-col bg-[var(--bg)] text-[var(--text-primary)]'>
|
||||
<div className='light desktop-title-bar-page fixed inset-0 z-[100] flex flex-col bg-[var(--bg)] text-[var(--text-primary)]'>
|
||||
<DesktopTitleBarLane />
|
||||
{/* Header component */}
|
||||
<ChatHeader chatConfig={chatConfig} starCount={starCount} />
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Skeleton } from '@sim/emcn'
|
||||
import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
|
||||
|
||||
export default function ChatLoading() {
|
||||
return (
|
||||
<div className='light fixed inset-0 z-[100] flex flex-col bg-[var(--bg)] text-[var(--text-primary)]'>
|
||||
<div className='light desktop-title-bar-page fixed inset-0 z-[100] flex flex-col bg-[var(--bg)] text-[var(--text-primary)]'>
|
||||
<DesktopTitleBarLane />
|
||||
<div className='border-[var(--border-1)] border-b px-4 py-3'>
|
||||
<div className='mx-auto flex max-w-3xl items-center justify-between'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Skeleton } from '@sim/emcn'
|
||||
import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
|
||||
|
||||
export function ChatLoadingState() {
|
||||
return (
|
||||
<div className='light fixed inset-0 z-[100] flex flex-col bg-[var(--bg)]'>
|
||||
<div className='light desktop-title-bar-page fixed inset-0 z-[100] flex flex-col bg-[var(--bg)]'>
|
||||
<DesktopTitleBarLane />
|
||||
<div className='flex flex-1 items-center justify-center px-4'>
|
||||
<div className='w-full max-w-[410px]'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'light fixed inset-0 z-[100] flex flex-col bg-[var(--bg)] text-[var(--text-primary)]',
|
||||
'light desktop-title-bar-page fixed inset-0 z-[100] flex flex-col bg-[var(--bg)] text-[var(--text-primary)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<DesktopTitleBarLane />
|
||||
<div className='flex flex-1 flex-col items-center justify-center px-8'>
|
||||
<div className='relative mb-16'>
|
||||
<ParticlesVisualization
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Skeleton } from '@sim/emcn'
|
||||
|
||||
export default function ResumeLoading() {
|
||||
return (
|
||||
<div className='min-h-screen bg-background'>
|
||||
<div className='bg-background'>
|
||||
<div className='border-b px-4 py-3'>
|
||||
<div className='mx-auto flex max-w-[1200px] items-center justify-between'>
|
||||
<Skeleton className='h-[24px] w-[80px] rounded-[4px]' />
|
||||
|
||||
@@ -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 (
|
||||
<div className='light relative flex min-h-screen flex-col bg-[var(--bg)] text-[var(--text-primary)]'>
|
||||
<div className='light desktop-title-bar-page relative flex flex-col bg-[var(--bg)] text-[var(--text-primary)]'>
|
||||
<DesktopTitleBarLane />
|
||||
<header>
|
||||
<nav className='mx-auto flex w-full max-w-[1460px] items-center px-20 py-4 max-sm:px-5 max-lg:px-8'>
|
||||
<Link href='/' aria-label='Sim home' className='flex h-[30px] items-center'>
|
||||
|
||||
@@ -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<never>(() => {})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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(<DesktopTitleBarController />))
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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('<DesktopTitleBarLane />')
|
||||
expect(authShell).not.toContain('reserveDesktopTitleBar')
|
||||
expect(authShell).not.toContain('min-h-screen')
|
||||
expect(authLayout).toContain('<AuthShell>')
|
||||
})
|
||||
|
||||
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('<DesktopTitleBarLane />')
|
||||
})
|
||||
|
||||
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<string, string> = {
|
||||
'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 <Sidebar>, 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 (`<AuthShell`), never as a bare identifier: an import line, or a
|
||||
* shell's own definition file mentioning its name, would otherwise self-certify as
|
||||
* covered. Both mistakes were in the first draft of this check and made it unfailable.
|
||||
*/
|
||||
/**
|
||||
* How a root claims the whole window.
|
||||
*
|
||||
* `fixed inset-0` counts: it covers the lights as completely as `h-screen`. So does
|
||||
* `desktop-title-bar-page` itself, which sets `min-height: 100vh` — without that arm, every
|
||||
* surface this PR converted lost its `min-h-screen` token and dropped out of the check
|
||||
* entirely, leaving the files most likely to regress unwatched.
|
||||
*/
|
||||
/**
|
||||
* A root positioned against the viewport rather than its parent.
|
||||
*
|
||||
* `position: fixed` ignores an ancestor's `padding-top`, so a lane-aware shell does NOT
|
||||
* cover it — the chat surfaces sat inside `LogoShell` and still painted at viewport top,
|
||||
* under the lights, while this check called them covered. Such a root must reserve for
|
||||
* itself, and reserving must not then read as a double reservation.
|
||||
*/
|
||||
const ESCAPES_ANCESTOR_PADDING = /fixed inset-0/
|
||||
|
||||
const FILLS_VIEWPORT = /\b(min-h-screen|h-screen)\b|fixed inset-0|desktop-title-bar-page/
|
||||
|
||||
const LANE_AWARE_SHELL_USAGE = /<(AuthShell|LogoShell|WorkspaceChrome|InterfacesShell)\b/
|
||||
|
||||
/**
|
||||
* Route trees whose every surface is accounted for by one reason.
|
||||
*
|
||||
* Prefix form exists because the landing group is dozens of files sharing a single
|
||||
* justification; listing them individually would be noise, not scrutiny.
|
||||
*/
|
||||
const LANE_EXEMPT_PREFIXES: Record<string, string> = {
|
||||
'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 `<WorkspaceAccessDenied />` at the top and only reaches `<WorkspaceChrome>` 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 `<WorkspaceChrome>`, 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') && !/<DesktopTitleBarLane\b/.test(source)
|
||||
})
|
||||
|
||||
expect(orphaned).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,13 +5,12 @@ import { describe, expect, it } from 'vitest'
|
||||
import { applyDesktopTitleBarMode, supportsDesktopTitleBar } from '@/app/_shell/desktop-title-bar'
|
||||
|
||||
describe('desktop title bar', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -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 DesktopTitleBarLane() {
|
||||
return (
|
||||
<>
|
||||
<DesktopTitleBarController />
|
||||
<div aria-hidden className='desktop-login-window-drag-region desktop-window-drag-region' />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
const pathname = usePathname()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className='light flex min-h-screen flex-col bg-[var(--bg)]'>
|
||||
<header className='sticky top-0 z-10 flex items-center justify-between gap-4 border-[var(--border)] border-b bg-[var(--bg)] px-4 py-3'>
|
||||
<div className='light desktop-title-bar-page flex flex-col bg-[var(--bg)]'>
|
||||
<DesktopTitleBarLane />
|
||||
<header className='sticky top-[var(--desktop-title-bar-height)] z-10 flex items-center justify-between gap-4 border-[var(--border)] border-b bg-[var(--bg)] px-4 py-3'>
|
||||
<div className='flex min-w-0 items-center gap-3'>
|
||||
{!brand.logoUrl && (
|
||||
<>
|
||||
|
||||
@@ -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 (
|
||||
<main className='flex min-h-screen items-center justify-center px-6'>
|
||||
<main className='desktop-title-bar-page flex items-center justify-center px-6'>
|
||||
<DesktopTitleBarLane />
|
||||
<div className='max-w-sm text-center'>
|
||||
<h1 className='font-semibold text-foreground text-lg'>Couldn’t complete that</h1>
|
||||
<p className='mt-2 text-muted-foreground text-sm'>{messageForError(code)}</p>
|
||||
|
||||
@@ -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 (
|
||||
<main className='flex min-h-screen items-center justify-center bg-[var(--surface-1)] p-6'>
|
||||
<main className='desktop-title-bar-page flex items-center justify-center bg-[var(--surface-1)] p-6'>
|
||||
<DesktopTitleBarLane />
|
||||
<div className='flex max-w-md flex-col items-center gap-3 text-center'>
|
||||
<div className='flex size-10 items-center justify-center rounded-full bg-[var(--surface-3)]'>
|
||||
<CircleAlert className='size-[18px] text-[var(--text-icon)]' aria-hidden />
|
||||
|
||||
@@ -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 (
|
||||
<div className='fixed inset-0 z-50 bg-[var(--bg)]'>
|
||||
<div className='desktop-title-bar-page fixed inset-0 z-50 bg-[var(--bg)]'>
|
||||
<DesktopTitleBarLane />
|
||||
<iframe
|
||||
src={serveUrl}
|
||||
className='h-full w-full border-0'
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createWorkspaceContract } from '@/lib/api/contracts/workspaces'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { recoverFromStaleSession } from '@/lib/auth/stale-session-recovery'
|
||||
import { WorkspaceRecencyStorage } from '@/lib/core/utils/browser-storage'
|
||||
import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar'
|
||||
import { useWorkspacesWithMetadata } from '@/hooks/queries/workspace'
|
||||
|
||||
const logger = createLogger('WorkspacePage')
|
||||
@@ -44,7 +45,8 @@ function WorkspaceStatusCard({
|
||||
onPrimary,
|
||||
}: WorkspaceStatusCardProps) {
|
||||
return (
|
||||
<main className='flex h-screen w-full items-center justify-center bg-[var(--surface-1)] p-6'>
|
||||
<main className='desktop-title-bar-page flex w-full items-center justify-center bg-[var(--surface-1)] p-6'>
|
||||
<DesktopTitleBarLane />
|
||||
<div className='flex max-w-md flex-col items-center gap-3 text-center'>
|
||||
<div className='flex size-10 items-center justify-center rounded-full bg-[var(--surface-3)]'>
|
||||
<CircleAlert className='size-[18px] text-[var(--text-icon)]' aria-hidden />
|
||||
@@ -201,7 +203,8 @@ export default function WorkspacePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-screen w-full items-center justify-center'>
|
||||
<div className='desktop-title-bar-page flex w-full items-center justify-center'>
|
||||
<DesktopTitleBarLane />
|
||||
<div
|
||||
className='size-[18px] animate-spin rounded-full'
|
||||
style={{
|
||||
|
||||
Reference in New Issue
Block a user