refactor(desktop): name the overlay z-index ladder

DESIGN.md already said app-wide surfaces must not compete through ad-hoc
z-index literals, and the code disagreed: three overlapping numbering
schemes, and comments narrating the fight ("defaults to z-130, renders
UNDER the onboarding overlay (z-1300) ... bump it above with z-[1310]").
Picking a number meant reading someone else's near miss.

Name the rungs — modal, over-modal, switcher, and the boot chain — and
point the call sites at them. Every rung keeps the exact value it had, so
nothing moves; what changes is that the next overlay has a name to reach
for instead of a number to guess. Local stacking within a component stays
on plain z-10/z-20.
This commit is contained in:
Brooklyn Nicholson 2026-07-27 15:48:09 -05:00
parent 9ae3bd73c9
commit c7ef4c192d
20 changed files with 67 additions and 41 deletions

View file

@ -192,7 +192,12 @@ Notes:
semantics when unifying appearance.
- Respect `AppShell` overlay ownership. Persistent terminal/content layers,
route overlays, dialogs, and boot surfaces must not compete through ad-hoc
z-index literals.
z-index literals. Pick a rung of the ladder in `styles.css` instead —
`--z-modal-backdrop` / `--z-modal` / `--z-modal-popover`, `--z-over-modal`
(toasts, tooltips, command surfaces) and `--z-over-modal-content`,
`--z-switcher-backdrop` / `--z-switcher`, then the boot chain
`--z-connecting``--z-onboarding``--z-setup``--z-crash`. Plain
`z-10`/`z-20` are still right for stacking *within* one component.
## Iconography & brand

View file

@ -119,7 +119,7 @@ export function BaseBranchPicker({
<span className="shrink-0">{parts.after}</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="z-[140] min-w-(--radix-popover-trigger-width) p-0">
<PopoverContent align="start" className="z-(--z-modal-popover) min-w-(--radix-popover-trigger-width) p-0">
<Command filter={(searchValue, search) => (searchValue.toLowerCase().includes(search.toLowerCase()) ? 1 : 0)}>
<CommandInput autoFocus placeholder={p.baseBranchPlaceholder} />
<CommandList className="max-h-64">

View file

@ -875,13 +875,13 @@ export function CommandPalette() {
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
<DialogPrimitive.Portal>
{/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */}
<DialogPrimitive.Overlay className="fixed inset-0 z-[200]" />
<DialogPrimitive.Overlay className="fixed inset-0 z-(--z-over-modal)" />
<DialogPrimitive.Content
aria-describedby={undefined}
className={cn(
HUD_POSITION,
HUD_SURFACE,
'z-[210] w-[min(34rem,calc(100vw-2rem))] overflow-hidden duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95'
'z-(--z-over-modal-content) w-[min(34rem,calc(100vw-2rem))] overflow-hidden duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95'
)}
>
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>

View file

@ -31,9 +31,9 @@ export function ProviderPicker() {
<ChevronDown className="size-3" />
</button>
</DropdownMenuTrigger>
{/* The picker lives inside the pet-gen Dialog (z-130) and portals to body,
so lift its menu above the dialog or it opens behind it. */}
<DropdownMenuContent align="start" className="z-[140]">
{/* The picker lives inside the pet-gen Dialog and portals to body, so its
menu needs the rung above the modal or it opens behind the dialog. */}
<DropdownMenuContent align="start" className="z-(--z-modal-popover)">
{providers.map(provider => (
<DropdownMenuItem
className="flex items-center gap-1.5"

View file

@ -46,7 +46,7 @@ export function SessionSwitcher() {
<>
{/* Transparent click-catcher: click-away closes, but no dim/blur. */}
<div
className="fixed inset-0 z-[219]"
className="fixed inset-0 z-(--z-switcher-backdrop)"
onMouseDown={e => {
e.preventDefault()
closeSwitcher()
@ -56,7 +56,7 @@ export function SessionSwitcher() {
className={cn(
HUD_POSITION,
HUD_SURFACE,
'dt-portal-scrollbar z-[220] max-h-[min(22rem,64vh)] w-[min(19rem,calc(100vw-2rem))] select-none overflow-y-auto p-1'
'dt-portal-scrollbar z-(--z-switcher) max-h-[min(22rem,64vh)] w-[min(19rem,calc(100vw-2rem))] select-none overflow-y-auto p-1'
)}
>
{sessions.map((session, i) => {

View file

@ -284,7 +284,7 @@ export function BootFailureOverlay() {
if (view === 'connect') {
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="fixed inset-0 z-(--z-setup) flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="flex max-h-[86vh] w-full max-w-[46rem] flex-col overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous">
{/* Subtle back affordance (projects/overlay idiom): muted foreground
on hover, no divider. */}
@ -307,7 +307,7 @@ export function BootFailureOverlay() {
}
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="fixed inset-0 z-(--z-setup) flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous">
<div className="flex items-start gap-3 px-5 py-4">
<ErrorIcon className="mt-0.5" size="1.25rem" />

View file

@ -398,7 +398,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
if (state.setupChoice) {
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 p-4 backdrop-blur-md">
<div className="fixed inset-0 z-(--z-setup) flex items-center justify-center bg-background/90 p-4 backdrop-blur-md">
<div className="w-full max-w-2xl rounded-xl border border-(--stroke-nous) bg-card p-8 shadow-nous">
<div className="flex items-start gap-4">
<BrandMark className="size-11 shrink-0" />
@ -478,7 +478,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
const platformLabel = ups.platform === 'darwin' ? 'macOS' : ups.platform === 'linux' ? 'Linux' : ups.platform
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md">
<div className="fixed inset-0 z-(--z-setup) flex items-center justify-center bg-background/90 backdrop-blur-md">
<div className="w-full max-w-xl rounded-xl border border-(--stroke-nous) bg-card p-8 shadow-nous">
<h2 className="text-xl font-semibold tracking-tight">{copy.oneTimeTitle}</h2>
<p className="mt-2 text-sm text-muted-foreground">{copy.unsupportedDesc(platformLabel)}</p>
@ -547,7 +547,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
const currentElapsed = typeof currentStartedAt === 'number' ? formatElapsed(now - currentStartedAt) : ''
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md p-4">
<div className="fixed inset-0 z-(--z-setup) flex items-center justify-center bg-background/90 backdrop-blur-md p-4">
<div className="flex w-full max-w-2xl max-h-[90vh] flex-col rounded-xl border border-(--stroke-nous) bg-card shadow-nous">
{/* Header -- always visible, never scrolls */}
<div className="flex flex-shrink-0 items-start gap-4 p-8 pb-4">

View file

@ -56,7 +56,7 @@ function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) {
const { t } = useI18n()
return (
<div className="fixed inset-0 z-[1500] grid place-items-center bg-(--ui-chat-surface-background) p-6">
<div className="fixed inset-0 z-(--z-crash) grid place-items-center bg-(--ui-chat-surface-background) p-6">
<ErrorState
className="w-full max-w-[28rem]"
description={error.message || t.errors.boundaryDesc}

View file

@ -222,7 +222,7 @@ export function FirstRunRemoteForm({ onBack }: FirstRunRemoteFormProps) {
}
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 p-4 backdrop-blur-md">
<div className="fixed inset-0 z-(--z-setup) flex items-center justify-center bg-background/90 p-4 backdrop-blur-md">
<div className="flex w-full max-w-xl flex-col rounded-xl border border-(--stroke-nous) bg-card p-8 shadow-nous">
<div className="flex items-start gap-4">
<BrandMark className="size-11 shrink-0" />

View file

@ -10,7 +10,7 @@ import { BootFailureOverlay } from './boot-failure-overlay'
import { GatewayConnectingOverlay } from './gateway-connecting-overlay'
// Repro for the "remote gateway → stuck on CONNECTING, no way to settings"
// report. The connecting overlay (z-1200, full-screen, pointer-events on) used
// report. The connecting overlay (full-screen, pointer-events on) used
// to be shown whenever `gatewayState !== 'open' && !boot.error`. The ONLY escape
// hatch — BootFailureOverlay, which has "Use local gateway" / "Sign in" /
// "Retry" — only renders when `boot.error` is set.

View file

@ -141,7 +141,7 @@ export function GatewayConnectingOverlay() {
return (
<div
className={cn(
'fixed inset-0 z-[1200] grid place-items-center bg-(--ui-chat-surface-background) transition-opacity duration-500 ease-out',
'fixed inset-0 z-(--z-connecting) grid place-items-center bg-(--ui-chat-surface-background) transition-opacity duration-500 ease-out',
overlayHidden ? 'pointer-events-none opacity-0' : 'opacity-100'
)}
>

View file

@ -28,10 +28,10 @@ interface ModelPickerDialogProps {
onSelect: (selection: { provider: string; model: string }) => void
profile?: string
/**
* Optional class to apply to DialogContent. Use to override z-index when
* stacking the picker on top of another fixed overlay (e.g. the desktop
* onboarding overlay, which sits at z-1300; the default Dialog z-130 ends
* up rendering underneath and blocks pointer events).
* Optional class for DialogContent. Use it to lift the picker onto a higher
* rung of the overlay ladder when it opens over another fixed overlay (the
* desktop onboarding overlay, say) on the default modal rung it renders
* underneath and blocks pointer events.
*/
contentClassName?: string
}
@ -85,7 +85,7 @@ export function ModelPickerDialog({
// Open the full onboarding provider selector to add/switch a provider.
// Reuses the entire onboarding flow (OAuth rows, API-key form, device-code,
// model-confirm) instead of duplicating provider UI here. Closes the picker
// so the onboarding overlay (z-1300) isn't rendered underneath it.
// so the onboarding overlay isn't rendered underneath it.
const addProvider = () => {
startManualOnboarding()
onOpenChange(false)

View file

@ -92,9 +92,9 @@ export function NotificationStack() {
)
}
// Portaled to <body> with a z above the Radix dialog layer (overlay z-[120],
// content z-[130]) — see the top-center variant below for why.
const REGION_BASE = 'pointer-events-none fixed z-[200] flex gap-2'
// Portaled to <body> on the over-modal rung so a toast clears an open dialog —
// see the top-center variant below for why.
const REGION_BASE = 'pointer-events-none fixed z-(--z-over-modal) flex gap-2'
// Primary stack: top-center, collapsed to the latest toast with a "+N more"
// expander + clear-all — the noisy/important surface (errors, warnings,

View file

@ -308,15 +308,14 @@ function ConfirmingModelPanel({
</div>
{/*
ModelPickerDialog defaults to z-130 on its content, which renders
UNDER the onboarding overlay (z-1300) and breaks pointer events.
Bump it above with z-[1310] so the picker sits on top of the
onboarding panel. The dialog's own dim-backdrop layer stays at
its default z-120 the onboarding overlay is already dimming
the rest of the screen, so we don't want a second backdrop.
ModelPickerDialog's content sits on the modal rung, which is below the
onboarding overlay it would render underneath and swallow pointer
events. Lift it to the rung above onboarding. Its own dim-backdrop
layer stays on the modal-backdrop rung: onboarding already dims the
rest of the screen, so a second backdrop would double up.
*/}
<ModelPickerDialog
contentClassName="z-[1310]"
contentClassName="z-(--z-onboarding-popover)"
currentModel={flow.currentModel}
currentProvider={flow.providerSlug}
onOpenChange={setPickerOpen}

View file

@ -302,7 +302,7 @@ export function DesktopOnboardingOverlay({
return (
<div
className={cn(
'fixed inset-0 z-1300 flex items-center justify-center bg-(--ui-chat-surface-background) p-6 transition-opacity duration-[520ms] ease-out',
'fixed inset-0 z-(--z-onboarding) flex items-center justify-center bg-(--ui-chat-surface-background) p-6 transition-opacity duration-[520ms] ease-out',
// On the bare confirm screen, hold the surface (text-out + hold) so the
// per-element exit plays before it dissolves.
bare && leaving ? '[transition-delay:660ms]' : '',

View file

@ -45,10 +45,10 @@ export function SessionPickerDialog({ activeStoredSessionId, onOpenChange, onRes
return (
<DialogPrimitive.Root onOpenChange={onOpenChange} open={open}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/15 backdrop-blur-[1px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
<DialogPrimitive.Overlay className="fixed inset-0 z-(--z-over-modal) bg-black/15 backdrop-blur-[1px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
aria-describedby={undefined}
className="fixed left-1/2 top-[14vh] z-[210] w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95"
className="fixed left-1/2 top-[14vh] z-(--z-over-modal-content) w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95"
>
<DialogPrimitive.Title className="sr-only">{t.commandCenter.sections.sessions}</DialogPrimitive.Title>
<Command className="bg-transparent" loop>

View file

@ -28,7 +28,7 @@ function DialogOverlay({ className, ...props }: React.ComponentProps<typeof Dial
return (
<DialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-[120] pointer-events-auto bg-black/22 backdrop-blur-[0.125rem] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
'fixed inset-0 z-(--z-modal-backdrop) pointer-events-auto bg-black/22 backdrop-blur-[0.125rem] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
className
)}
data-slot="dialog-overlay"
@ -130,7 +130,7 @@ function DialogContent({
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto flex max-h-[85vh] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl bg-(--ui-chat-bubble-background) text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'fixed left-1/2 top-1/2 z-(--z-modal) pointer-events-auto flex max-h-[85vh] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl bg-(--ui-chat-bubble-background) text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
widthClass,
className,
// Callers often pass `gap-*` for the no-banner grid layout — suppress
@ -174,7 +174,7 @@ function DialogContent({
// Cap height at 85vh and let long content scroll inside the dialog
// instead of overflowing off-screen (long cron titles, tool detail
// dumps, etc.). Individual dialogs can still override via className.
'fixed left-1/2 top-1/2 z-[130] pointer-events-auto grid max-h-[85vh] -translate-x-1/2 -translate-y-1/2 gap-3 overflow-y-auto rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'fixed left-1/2 top-1/2 z-(--z-modal) pointer-events-auto grid max-h-[85vh] -translate-x-1/2 -translate-y-1/2 gap-3 overflow-y-auto rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) p-4 text-[length:var(--conversation-text-font-size)] text-foreground shadow-nous duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
widthClass,
className
)}

View file

@ -53,7 +53,7 @@ function SelectContent({
<SelectPrimitive.Portal container={container}>
<SelectPrimitive.Content
className={cn(
'relative z-[140] max-h-72 min-w-32 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=top]:slide-in-from-bottom-2 data-[side=right]:slide-in-from-left-2',
'relative z-(--z-modal-popover) max-h-72 min-w-32 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=top]:slide-in-from-bottom-2 data-[side=right]:slide-in-from-left-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className

View file

@ -82,7 +82,7 @@ function TooltipContent({
// rectangular dead space). Instant, no transition (delayDuration=0).
// pointer-events-none: the tip must never steal hover/clicks from the
// chrome underneath (titlebar tools, adjacent tabs, etc.).
className={cn('pointer-events-none z-[200] w-fit max-w-64 select-none', className)}
className={cn('pointer-events-none z-(--z-over-modal) w-fit max-w-64 select-none', className)}
data-slot="tooltip-content"
sideOffset={sideOffset}
{...props}

View file

@ -198,6 +198,28 @@
--ui-diff-remove-border: var(--ui-red);
--ui-diff-remove-background: color-mix(in srgb, var(--ui-red) 12%, transparent);
--ui-diff-remove-foreground: color-mix(in srgb, var(--ui-red) 70%, #000);
/* Overlay ladder. DESIGN.md: app-wide surfaces must not compete through
ad-hoc z-index literals pick the rung that describes the surface.
Values are deliberately sparse so a one-off can slot between two rungs
without a renumber. Local stacking inside a component (a sticky header
over its own scroll area) stays on plain `z-10`/`z-20`; these rungs are
only for surfaces that float over the app. */
--z-modal-backdrop: 120;
--z-modal: 130;
/* A select/dropdown/popover opened from inside a modal, portaled to body. */
--z-modal-popover: 140;
/* Must clear any open modal: toasts, tooltips, command-surface backdrops. */
--z-over-modal: 200;
--z-over-modal-content: 210;
--z-switcher-backdrop: 219;
--z-switcher: 220;
/* Boot and blocking states, in the order they can stack. */
--z-connecting: 1200;
--z-onboarding: 1300;
--z-onboarding-popover: 1310;
--z-setup: 1400;
--z-crash: 1500;
--ui-bg-chrome: color-mix(
in srgb,
var(--theme-background-seed) var(--theme-mix-chrome),