From cd6dff534670e334c8d5a3986089b7850389f260 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 6 Sep 2026 21:39:45 -0700 Subject: [PATCH] Raise dialogs and the composer over the mobile drawer On a phone the folder list is the drawer, so it is also where a new folder is started -- and the New folder dialog was stacked at 900 against the drawer's 950, so it opened behind the folder list with only a sliver showing past the drawer's right edge. Unusable: the name field and the Cancel button were both underneath. The same trigger, the same fault, one layer down: Compose in the drawer opens a full-screen composer, and at 800 that came up behind the drawer too. A modal has to outrank the navigation that raised it. The dialog backdrop goes to 960 and the composer dock to 955, which keeps every relationship those two already had -- a dialog still clears a composer, popovers, tooltips and toasts still clear both -- and adds the one that was missing. Desktop is untouched: the drawer's z-index only exists below 768px, and nothing sat between 800 and 960 anywhere else. The stack is now written down beside `.dialog-backdrop`, and guarded by a test on the stylesheet rather than a component test: jsdom has no paint order, so nothing in a rendered tree can tell that a dialog is behind the drawer that opened it. No user-visible strings change; the nine catalogues are untouched, and the fallback count holds at 16 in each. --- .../styles/__tests__/stacking-order.test.ts | 53 +++++++++++++++++++ web/src/styles/app.css | 28 +++++++++- web/src/views/AppShell.tsx | 6 ++- 3 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 web/src/styles/__tests__/stacking-order.test.ts diff --git a/web/src/styles/__tests__/stacking-order.test.ts b/web/src/styles/__tests__/stacking-order.test.ts new file mode 100644 index 0000000..7226cb2 --- /dev/null +++ b/web/src/styles/__tests__/stacking-order.test.ts @@ -0,0 +1,53 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +/** + * The drawer is a phone's only way to a folder, an event, a contact or a new + * message -- and each of those answers with a dialog or a full-screen composer + * that the drawer used to cover, because both were stacked below it. Nothing + * in a component test sees that: jsdom has no paint order, and the store is + * perfectly happy while the dialog sits behind the thing that raised it. + * + * So the guard is on the stylesheet, which is where the bug was. + * + * Read off disk, not imported: `?raw` comes back empty for a stylesheet under + * vitest, which would pass every assertion below on an empty string. + */ +const here = import.meta.url.startsWith("file:") ? fileURLToPath(import.meta.url) : import.meta.url; +const css = readFileSync(resolve(dirname(here), "../app.css"), "utf8"); + +/** The `z-index` on the last rule for `selector`, which is the one that wins. */ +function layer(selector: string): number { + const rules = [...css.matchAll(new RegExp(`(?:^|[,{}\\s])${selector.replace(".", "\\.")}\\s*\\{([^}]*)\\}`, "g"))]; + expect(rules.length, `no rule for ${selector}`).toBeGreaterThan(0); + const zs = rules.map((r) => /z-index:\s*(\d+)/.exec(r[1]!)?.[1]).filter(Boolean); + expect(zs.length, `no z-index on ${selector}`).toBeGreaterThan(0); + return Number(zs[zs.length - 1]); +} + +describe("stacking order", () => { + it("puts a dialog over the mobile drawer that opened it", () => { + expect(layer(".dialog-backdrop")).toBeGreaterThan(layer(".sidebar")); + }); + + it("puts a full-screen composer over the drawer that opened it", () => { + expect(layer(".composer-dock")).toBeGreaterThan(layer(".sidebar")); + }); + + it("keeps a dialog raised from inside a composer above it", () => { + expect(layer(".dialog-backdrop")).toBeGreaterThan(layer(".composer-dock")); + }); + + it("keeps the drawer above its own backdrop", () => { + expect(layer(".sidebar")).toBeGreaterThan(layer(".drawer-backdrop")); + }); + + it("keeps popovers, tooltips and toasts above dialogs", () => { + const dialog = layer(".dialog-backdrop"); + expect(layer(".popover")).toBeGreaterThan(dialog); + expect(layer(".tooltip")).toBeGreaterThan(dialog); + expect(layer(".toast-host")).toBeGreaterThan(dialog); + }); +}); diff --git a/web/src/styles/app.css b/web/src/styles/app.css index b63ebbf..a47dda3 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1113,7 +1113,30 @@ a.menu-item:hover { color: var(--fg); } :root[data-theme="dark"] .tooltip { background: #e5e9f0; color: #0b1220; } /* Dialogs ---------------------------------------------------------------- */ -.dialog-backdrop { position: fixed; inset: 0; z-index: 900; background: rgba(2, 6, 23, 0.45); display: flex; align-items: center; justify-content: center; padding: 16px; animation: fade .15s var(--ease); backdrop-filter: blur(2px); } +/* + The stacking order everything below is a point on, low to high: + + 700 mobile tab bar, FAB + 940 drawer backdrop + 950 mobile navigation drawer + 955 composer dock + 960 dialog backdrop + 1000 popover + 2000 tooltip + 3000 toast + 5000 drag ghost + + The drawer is where a phone reaches everything, so it is also where a phone + starts a folder, an event, a contact and a message -- and each of those + answers with a dialog or a full-screen composer that the drawer then covered, + because both were stacked below it. A modal has to outrank the navigation + that opened it, or the drawer sits on top of the thing it just asked for and + the only way out is a press on the dimmed strip beside it. + + Above the composer, not level with it: a dialog raised from inside a composer + -- the discard guard, the attachment picker -- has to clear the composer too. +*/ +.dialog-backdrop { position: fixed; inset: 0; z-index: 960; background: rgba(2, 6, 23, 0.45); display: flex; align-items: center; justify-content: center; padding: 16px; animation: fade .15s var(--ease); backdrop-filter: blur(2px); } @keyframes fade { from { opacity: 0; } to { opacity: 1; } } .dialog { background: var(--bg-elev); border-radius: var(--radius-lg); box-shadow: var(--shadow-3); width: 100%; max-width: 520px; max-height: calc(100vh - 32px); display: flex; flex-direction: column; animation: rise .18s var(--ease); border: 1px solid var(--border); } @keyframes rise { from { opacity: 0; transform: translateY(10px) scale(.98); } to { opacity: 1; transform: none; } } @@ -1520,7 +1543,8 @@ a.menu-item:hover { color: var(--fg); } /* ========================================================================== Composer ========================================================================== */ -.composer-dock { position: fixed; right: 16px; bottom: 0; display: flex; align-items: flex-end; gap: 12px; z-index: 800; pointer-events: none; } +/* Above the mobile drawer, below a dialog -- see the stack by `.dialog-backdrop`. */ +.composer-dock { position: fixed; right: 16px; bottom: 0; display: flex; align-items: flex-end; gap: 12px; z-index: 955; pointer-events: none; } .composer { pointer-events: auto; width: 580px; max-width: calc(100vw - 32px); height: 600px; max-height: calc(100vh - 24px); display: flex; flex-direction: column; background: var(--bg-elev); border-radius: var(--radius-lg) var(--radius-lg) 0 0; box-shadow: var(--shadow-3); border: 1px solid var(--border); border-bottom: 0; overflow: hidden; animation: rise .2s var(--ease); } .composer.minimized { height: 44px; width: 280px; } .composer.maximized { position: fixed; inset: 24px; width: auto; height: auto; max-width: none; max-height: none; border-radius: var(--radius-lg); border-bottom: 1px solid var(--border); } diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index adcd35d..56c2f1c 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -151,8 +151,10 @@ export function AppShell({ children }: { children: ReactNode }) { Putting a close where the hamburger was means the second press lands on the control that undoes the first, which is where the hand is already going. It cannot be done by raising the top bar over the - drawer instead: it would then also sit over a full-screen composer, - which is stacked lower still. + drawer instead: the top bar sits under everything that takes the + screen -- see the stack by `.dialog-backdrop` -- and lifting it + past the drawer would put it in among the composer and the + dialogs, which it has no business covering. */} {isMobile && (