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
This commit is contained in:
2026-09-12 12:20:58 -07:00
parent c3d2dc2418
commit 5855da0ba9
3 changed files with 86 additions and 2 deletions
+7 -1
View File
@@ -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; }
+3 -1
View File
@@ -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 (
<div className="composer-dock">
<div className={`composer-dock${hasMaximized ? " has-maximized" : ""}`}>
{visible.map((d) => (
<Composer key={d.key} draft={isMobile && d.key !== activeKey ? { ...d, minimized: true } : d} />
))}
@@ -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 }) => (
<div className={`composer ${draft.maximized ? "maximized" : ""} ${draft.minimized ? "minimized" : ""}`} data-key={draft.key} />
),
}));
/* 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<Draft> = {}) => ({ 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(<ComposerDock />));
};
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);
});
});