summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-26 20:44:40 +0900
committerAdam Malczewski <[email protected]>2026-06-26 20:44:40 +0900
commita8c9be77cf5dd6249d8ea531c4ce79d823a6f600 (patch)
tree4a6bbb086751ebca5aab55de41087d8c2da9205a /src
parent51da5df8fc70efc2febda238faeaeacb21713699 (diff)
downloaddispatch-web-a8c9be77cf5dd6249d8ea531c4ce79d823a6f600.tar.gz
dispatch-web-a8c9be77cf5dd6249d8ea531c4ce79d823a6f600.zip
fix(heartbeat): make PromptEditor modal truly fullscreen
Diffstat (limited to 'src')
-rw-r--r--src/adapters/portal.test.ts49
-rw-r--r--src/adapters/portal.ts28
-rw-r--r--src/features/heartbeat/ui/PromptEditor.svelte8
3 files changed, 85 insertions, 0 deletions
diff --git a/src/adapters/portal.test.ts b/src/adapters/portal.test.ts
new file mode 100644
index 0000000..792d3b4
--- /dev/null
+++ b/src/adapters/portal.test.ts
@@ -0,0 +1,49 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { portal } from "./portal";
+
+describe("portal action", () => {
+ afterEach(() => {
+ // Strip any leftover teleported nodes between tests.
+ document.querySelectorAll("body > :not(script)").forEach((n) => {
+ if (n instanceof HTMLElement) n.remove();
+ });
+ });
+
+ it("teleports the node to document.body (escaping an ancestor with transform)", () => {
+ // Simulate the sidebar: a transformed ancestor establishes a containing
+ // block for `position: fixed`.
+ const ancestor = document.createElement("div");
+ ancestor.style.transform = "translateX(0)";
+ document.body.appendChild(ancestor);
+
+ const node = document.createElement("div");
+ node.setAttribute("data-testid", "modal");
+ ancestor.appendChild(node);
+ expect(node.parentNode).toBe(ancestor);
+
+ const action = portal(node);
+
+ // After the action, the node is a direct child of <body>, not the ancestor.
+ expect(node.parentNode).toBe(document.body);
+ expect(ancestor.contains(node)).toBe(false);
+
+ action.destroy();
+
+ // On destroy the node is removed from <body>.
+ expect(document.body.contains(node)).toBe(false);
+ });
+
+ it("is a no-op (does not throw) when document is unavailable (SSR guard)", () => {
+ const originalDocument = globalThis.document;
+ // @ts-expect-error — deliberately undefined to exercise the SSR guard.
+ globalThis.document = undefined;
+ try {
+ const stub = {} as HTMLElement;
+ const action = portal(stub);
+ // Must not throw, and returns a destroy that is safe to call.
+ action.destroy();
+ } finally {
+ globalThis.document = originalDocument;
+ }
+ });
+});
diff --git a/src/adapters/portal.ts b/src/adapters/portal.ts
new file mode 100644
index 0000000..10aec30
--- /dev/null
+++ b/src/adapters/portal.ts
@@ -0,0 +1,28 @@
+/**
+ * A Svelte `use:` action that teleports a node to `document.body`, escaping any
+ * ancestor that establishes a containing block for `position: fixed` (most
+ * commonly an ancestor with a `transform`, `filter`, `perspective`, or
+ * `will-change` — e.g. the sidebar's `transform: translateX(...)` container).
+ *
+ * Without this, a `position: fixed` modal rendered inside such an ancestor is
+ * positioned relative to the ANCESTOR, not the viewport (so it only covers the
+ * sidebar area instead of the full screen). Moving the node to `document.body`
+ * restores viewport-relative `fixed` positioning. Svelte still owns the node's
+ * lifecycle (children, bindings, events); we just relocate it + remove it on
+ * destroy as hygiene.
+ *
+ * No-op safely when there is no `document` (SSR / jsdom guards).
+ */
+export function portal(node: HTMLElement): { destroy(): void } {
+ if (typeof document === "undefined") {
+ return { destroy() {} };
+ }
+ document.body.appendChild(node);
+ return {
+ destroy() {
+ if (node.parentNode === document.body) {
+ document.body.removeChild(node);
+ }
+ },
+ };
+}
diff --git a/src/features/heartbeat/ui/PromptEditor.svelte b/src/features/heartbeat/ui/PromptEditor.svelte
index 2377428..867c32a 100644
--- a/src/features/heartbeat/ui/PromptEditor.svelte
+++ b/src/features/heartbeat/ui/PromptEditor.svelte
@@ -9,6 +9,7 @@
type LoadSystemPromptVariables,
} from "../../system-prompt";
import type { SaveHeartbeatConfig } from "../logic/types";
+ import { portal } from "../../../adapters/portal";
let {
systemPrompt,
@@ -141,8 +142,15 @@
<svelte:window onkeydown={onKeydown} />
+<!-- Teleported to <body> (use:portal) so `position: fixed` resolves against the
+ VIEWPORT, not the sidebar's `transform: translateX(...)` container — an
+ ancestor transform establishes a containing block for `fixed`, which would
+ otherwise clip this overlay to the sidebar area. (RunModal/SystemPromptBuilder
+ avoid this by rendering at the composition root; this modal lives inside
+ HeartbeatView, so it must escape its ancestor.) -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
+ use:portal
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"