From 5855da0ba90279d8b431f27cc238effc76f5dee4 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sat, 12 Sep 2026 12:20:58 -0700 Subject: [PATCH] Paint a full-screen composer above the others A maximised composer goes position: fixed but stays a child of the dock, and had no z-index of its own. The dock is a stacking context, so the positioned parts of any composer later in the DOM (its recipients row, its editor) painted straight over the full-screen one. Give the maximised composer its own layer, and hide the other composers while one is full screen: they cannot be reached anyway, and the 24px inset would otherwise show their footers along the bottom edge. They stay mounted, so nothing being written in them is lost. Fixes #330 --- web/src/styles/app.css | 8 +- web/src/views/compose/ComposerDock.tsx | 4 +- .../compose/__tests__/composer-dock.test.tsx | 76 +++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 web/src/views/compose/__tests__/composer-dock.test.tsx diff --git a/web/src/styles/app.css b/web/src/styles/app.css index a47dda3..4fac07f 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -1547,7 +1547,13 @@ a.menu-item:hover { color: var(--fg); } .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); } +/* Full screen is still a child of the dock, so without a layer of its own the + positioned parts of any composer after it in the DOM (its recipients row, + its editor) would paint straight over it. The others are hidden while one + is full screen: they cannot be reached anyway, and the strip left under the + 24px inset would otherwise show their footers. */ +.composer.maximized { position: fixed; inset: 24px; z-index: 1; width: auto; height: auto; max-width: none; max-height: none; border-radius: var(--radius-lg); border-bottom: 1px solid var(--border); } +.composer-dock.has-maximized > .composer:not(.maximized) { display: none; } .composer-head { display: flex; align-items: center; gap: 4px; height: 44px; padding: 0 6px 0 14px; background: var(--bg-sunken); border-bottom: 1px solid var(--border); flex: 0 0 auto; cursor: default; } .composer-head .title { flex: 1; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .composer-head .status { color: var(--fg-faint); font-size: .8em; margin-right: 6px; white-space: nowrap; } diff --git a/web/src/views/compose/ComposerDock.tsx b/web/src/views/compose/ComposerDock.tsx index df29a54..878e5ee 100644 --- a/web/src/views/compose/ComposerDock.tsx +++ b/web/src/views/compose/ComposerDock.tsx @@ -9,8 +9,10 @@ export function ComposerDock() { if (!drafts.length) return null; // On mobile only the active composer is shown (full screen); others are minimized bars. const visible = isMobile ? drafts.filter((d) => d.key === activeKey || d.minimized) : drafts; + // On desktop a full-screen composer stands alone: the rest are hidden until it is restored. + const hasMaximized = !isMobile && drafts.some((d) => d.maximized && !d.minimized); return ( -
+
{visible.map((d) => ( ))} diff --git a/web/src/views/compose/__tests__/composer-dock.test.tsx b/web/src/views/compose/__tests__/composer-dock.test.tsx new file mode 100644 index 0000000..97932b5 --- /dev/null +++ b/web/src/views/compose/__tests__/composer-dock.test.tsx @@ -0,0 +1,76 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useCompose, type Draft } from "@/store/compose"; +import { ComposerDock } from "../ComposerDock"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +// The question is which composers the dock puts on screen, not what is inside +// them, so each composer is a bare marker carrying the state it was given. +vi.mock("../Composer", () => ({ + Composer: ({ draft }: { draft: Draft }) => ( +
+ ), +})); + +/* jsdom has no matchMedia; each test says which side of the 768px breakpoint it stands at. */ +function setWidth(px: number) { + window.matchMedia = ((q: string) => ({ + matches: /max-width:\s*(\d+)px/.test(q) ? px <= Number(RegExp.$1) : false, + media: q, + addEventListener() {}, + removeEventListener() {}, + })) as unknown as typeof window.matchMedia; +} + +const draft = (key: string, init: Partial = {}) => ({ key, minimized: false, maximized: false, ...init }) as Draft; + +describe("ComposerDock with a full-screen composer", () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + useCompose.setState({ drafts: [], activeKey: null }); + }); + + const render = (drafts: Draft[], activeKey: string) => { + useCompose.setState({ drafts, activeKey }); + act(() => root.render()); + }; + const dock = () => host.querySelector(".composer-dock")!; + + it("marks the dock so the other composers are hidden behind it", () => { + setWidth(1300); + render([draft("a"), draft("b", { maximized: true }), draft("c")], "b"); + expect(dock().classList.contains("has-maximized")).toBe(true); + // Every composer stays mounted: the hiding is the stylesheet's, so nothing being typed elsewhere is lost. + expect(host.querySelectorAll(".composer").length).toBe(3); + }); + + it("leaves the dock alone while nobody is full screen", () => { + setWidth(1300); + render([draft("a"), draft("b")], "b"); + expect(dock().classList.contains("has-maximized")).toBe(false); + }); + + it("does not count a full-screen composer that has since been minimised", () => { + setWidth(1300); + render([draft("a"), draft("b", { maximized: true, minimized: true })], "a"); + expect(dock().classList.contains("has-maximized")).toBe(false); + }); + + it("is not a phone concern: there the active composer is already the only one open", () => { + setWidth(400); + render([draft("a"), draft("b", { maximized: true })], "b"); + expect(dock().classList.contains("has-maximized")).toBe(false); + }); +});